Compare commits

...
Author SHA1 Message Date
deytenit 1b3d7cecb2 ci: Add release rebase workflow (#21) 2026-07-12 11:38:02 +03:00
deytenit c41450e884 fix: fix www docker build (#20) 2026-07-12 02:38:00 +03:00
deytenit 91f5245823 fix, feat, doc: massive build system refactorion, bug fixes and apex documentation (#19) 2026-07-12 01:57:40 +03:00
dependabot[bot] e8b42c60c2 build(deps): bump fast-uri from 3.1.1 to 3.1.2 in the npm_and_yarn group across 0 directory (#17)
Updates `fast-uri` from 3.1.1 to 3.1.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/fastify/fast-uri/releases">fast-uri's
releases</a>.</em></p>
<blockquote>
<h2>v3.1.2</h2>
<h2>⚠️ Security Release</h2>
<ul>
<li>Fix for <a
href="https://github.com/fastify/fast-uri/security/advisories/GHSA-v39h-62p7-jpjc">https://github.com/fastify/fast-uri/security/advisories/GHSA-v39h-62p7-jpjc</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>Handle malformed fragment decoding as a parse error by <a
href="https://github.com/mcollina"><code>@​mcollina</code></a> in <a
href="https://redirect.github.com/fastify/fast-uri/pull/171">fastify/fast-uri#171</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/fastify/fast-uri/compare/v3.1.1...v3.1.2">https://github.com/fastify/fast-uri/compare/v3.1.1...v3.1.2</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/fastify/fast-uri/commit/919dd8ea7689fcc220d0d9b71307f5095e723ef9"><code>919dd8e</code></a>
Bumped v3.1.2</li>
<li><a
href="https://github.com/fastify/fast-uri/commit/c65ba573714af6b8e19e481d9444c27bc4355d07"><code>c65ba57</code></a>
fixup: linting</li>
<li><a
href="https://github.com/fastify/fast-uri/commit/6c86c17c3d76fb93aa3700ec6c0fa00faeb97293"><code>6c86c17</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/fastify/fast-uri/commit/a95158ad308df4d92bbde4eba699ce5165e9f796"><code>a95158a</code></a>
Handle malformed fragment decoding without throwing (<a
href="https://redirect.github.com/fastify/fast-uri/issues/171">#171</a>)</li>
<li>See full diff in <a
href="https://github.com/fastify/fast-uri/compare/v3.1.1...v3.1.2">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=fast-uri&package-manager=npm_and_yarn&previous-version=3.1.1&new-version=3.1.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/deytenit/ermnvldmr.com/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 23:37:34 +03:00
dependabot[bot] f858cf1989 build(deps-dev): bump postcss from 8.5.6 to 8.5.10 in the npm_and_yarn group across 1 directory (#16)
Bumps the npm_and_yarn group with 1 update in the / directory:
[postcss](https://github.com/postcss/postcss).

Updates `postcss` from 8.5.6 to 8.5.10
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/postcss/postcss/releases">postcss's
releases</a>.</em></p>
<blockquote>
<h2>8.5.10</h2>
<ul>
<li>Fixed XSS via unescaped <code>&lt;/style&gt;</code> in non-bundler
cases (by <a
href="https://github.com/TharVid"><code>@​TharVid</code></a>).</li>
</ul>
<h2>8.5.9</h2>
<ul>
<li>Speed up source map encoding paring in case of the error.</li>
</ul>
<h2>8.5.8</h2>
<ul>
<li>Fixed <code>Processor#version</code>.</li>
</ul>
<h2>8.5.7</h2>
<ul>
<li>Improved source map annotation cleaning performance (by CodeAnt
AI).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/postcss/postcss/blob/main/CHANGELOG.md">postcss's
changelog</a>.</em></p>
<blockquote>
<h2>8.5.10</h2>
<ul>
<li>Fixed XSS via unescaped <code>&lt;/style&gt;</code> in non-bundler
cases (by <a
href="https://github.com/TharVid"><code>@​TharVid</code></a>).</li>
</ul>
<h2>8.5.9</h2>
<ul>
<li>Speed up source map encoding paring in case of the error.</li>
</ul>
<h2>8.5.8</h2>
<ul>
<li>Fixed <code>Processor#version</code>.</li>
</ul>
<h2>8.5.7</h2>
<ul>
<li>Improved source map annotation cleaning performance (by CodeAnt
AI).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/postcss/postcss/commit/33b9790263dc1562a46ce45d9532bd63e95b7986"><code>33b9790</code></a>
Release 8.5.10 version</li>
<li><a
href="https://github.com/postcss/postcss/commit/536c79e4b01e58a3a56b09c3c0cf2323f4b9a28b"><code>536c79e</code></a>
Escape &lt;/style&gt; in CSS output (<a
href="https://redirect.github.com/postcss/postcss/issues/2074">#2074</a>)</li>
<li><a
href="https://github.com/postcss/postcss/commit/afa96b2a139ce625c4d27973313479c7c85f39d4"><code>afa96b2</code></a>
Update dependencies (<a
href="https://redirect.github.com/postcss/postcss/issues/2073">#2073</a>)</li>
<li><a
href="https://github.com/postcss/postcss/commit/effe88bb87cabdc1876e02adbdd30f392f19f40d"><code>effe88b</code></a>
Typo (<a
href="https://redirect.github.com/postcss/postcss/issues/2072">#2072</a>)</li>
<li><a
href="https://github.com/postcss/postcss/commit/3ee79a2c4a11e41d52db50b444eebe38299495ad"><code>3ee79a2</code></a>
Thread model (<a
href="https://redirect.github.com/postcss/postcss/issues/2071">#2071</a>)</li>
<li><a
href="https://github.com/postcss/postcss/commit/2e0683daca4dc2919211b03774f6b2d137136c01"><code>2e0683d</code></a>
Create incident response docs (<a
href="https://redirect.github.com/postcss/postcss/issues/2070">#2070</a>)</li>
<li><a
href="https://github.com/postcss/postcss/commit/fe88ac29c06b7b218be32994cdc6ca1525bdf2c9"><code>fe88ac2</code></a>
Release 8.5.9 version</li>
<li><a
href="https://github.com/postcss/postcss/commit/c551632496b87ab3f1965bfda5dc386b6c71963e"><code>c551632</code></a>
Avoid RegExp when we can use simple JS</li>
<li><a
href="https://github.com/postcss/postcss/commit/89a6b744060eb8dee743351c785a9fbe37d4525a"><code>89a6b74</code></a>
Move SECURITY.txt for docs folder to keep GitHub page cleaner</li>
<li><a
href="https://github.com/postcss/postcss/commit/6ceb8a46af9f9de821faee98f861bdf84617347b"><code>6ceb8a4</code></a>
Create SECURITY.md</li>
<li>Additional commits viewable in <a
href="https://github.com/postcss/postcss/compare/8.5.6...8.5.10">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=postcss&package-manager=npm_and_yarn&previous-version=8.5.6&new-version=8.5.10)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/deytenit/ermnvldmr.com/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 14:46:23 +03:00
deytenitandcopilot-swe-agent[bot] ef6ca40c72 feat (www): Add Wanted (wishlist) page (#15)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-04 18:58:46 +03:00
182 changed files with 11174 additions and 1565 deletions

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
#/-------------------[DOTENV_PUBLIC_KEY]--------------------/
#/ public-key encryption for .env files /
#/ [how it works](https://dotenvx.com/encryption) /
#/----------------------------------------------------------/
DOTENV_PUBLIC_KEY="02a9d0f6522086b04ac7763e008a6ad0273c58258715e613c977fd6c82a2030d92"
# .env
COM_ERMNVLDMR_STATIC_S3_BUCKET="encrypted:BL1vJLB9uPSvsUvrJah4UegRU8uPdFjE7sya+Fae3rwOmVzWsK5+TP4z4YzigTAuwit4FANvANvhRV46se+ZgprYplU8Cw1tZ5mHKO4rWAjif2sJCnsGXUKp8GZ2J7PrMH35MY0YrXX4NVqe18b2NEv2B9K7"
COM_ERMNVLDMR_STATIC_S3_REGION="encrypted:BAj3iuo7tWWNR2V4yQ+LHnFCB4JcZp7TTfUtvv+2FPntNhwc/fV95IeFWz6S0z6aPRkDG+UQzPXNwdMz7y8ZbTHbduDDrgydHu1fST1NfQd2s1Bbpf+yJTG7dPBSc5RSNKZkkpB4v55qg/UC"
COM_ERMNVLDMR_STATIC_S3_ENDPOINT_URL="encrypted:BNkQrgsrxtz544vuJbgwWUh5cPjHG5wbmHNcxTUWrzbFJJsdncaAitviCvIrS7Q41w+FiWkssv3D05Tu8Inn2P62E6G3PU4gNxO2oLdmGEdrzUqB/xvxOvLvos42j54QOZf7lOqMPKH6SL2/rheCAo10gSCicCqkfh4xsCAbYpyL"
COM_ERMNVLDMR_STATIC_AWS_ACCESS_KEY_ID="encrypted:BCsp8p4bp3cB+9grxHo634isRUaFGU3MOc1lr8ClSFRYLjqjqukyrbJCTZWV2Xdo8iQ33EylmbzUltbwzTWMQcjJSvIskesVLyV93q60o/Mu9L9jg/apwHuBgHv4zVuHstJ0zkHWgapbNTIlWD3RCm0ujeX/H1gBH3U="
COM_ERMNVLDMR_STATIC_AWS_SECRET_ACCESS_KEY="encrypted:BM7aPliOyiPrqFPZMfqpn534Nu+Y0Nbh/YRoxv2KY6XAi2dNO0LSEf97DqSUQYdsnu9iibZmVNktF2K/D6mtHr9+gumF6KsmaLH65KE7c9ly7NdFQHL9I26wxRq1Sr3Zgrfs954J6oURSlBRKqJFdcTsBltuuupGuqJHyLS9+DE0jD+bl0Qg0lA="
@@ -0,0 +1,145 @@
name: packages/ssg - next/required
on:
push:
branches: [next]
paths:
- 'packages/ssg/**'
- '.github/workflows/packages-ssg-next-required.yml'
- 'pnpm-lock.yaml'
concurrency:
group: ci-ssg-${{ github.ref_name }}
cancel-in-progress: true
permissions:
contents: read
actions: read
jobs:
setup-stage:
name: Setup - Stage
runs-on: ubuntu-latest
outputs:
cache-hit: ${{ steps.cache-pnpm-store.outputs.cache-hit }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- name: Setup Node.js with pnpm cache
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Cache pnpm store
id: cache-pnpm-store
uses: actions/cache@v3
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
build-configs:
name: Build - Configs
needs: setup-stage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Rsbuild config
run: pnpm --filter @ermnvldmr/rsbuild-config build
- name: Upload Config Artifacts
uses: actions/upload-artifact@v4
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
retention-days: 1
lint-ssg:
name: Lint - SSG
needs: build-configs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download Config Artifacts
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
- name: Lint SSG package
run: pnpm --filter @ermnvldmr/ssg lint
typecheck-ssg:
name: Typecheck - SSG
needs: build-configs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download Config Artifacts
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
- name: Typecheck SSG package
run: pnpm --filter @ermnvldmr/ssg typecheck
build-ssg:
name: Build - SSG
needs: build-configs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download Config Artifacts
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist
path: packages/rsbuild-config/dist
- name: Build SSG package
run: pnpm --filter @ermnvldmr/ssg build
@@ -0,0 +1,145 @@
name: packages/ssg - pr/required
on:
pull_request:
branches: [next]
paths:
- 'packages/ssg/**'
- '.github/workflows/packages-ssg-pr-required.yml'
- 'pnpm-lock.yaml'
concurrency:
group: ci-pr-ssg-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
actions: read
jobs:
setup-stage:
name: Setup - Stage
runs-on: ubuntu-latest
outputs:
cache-hit: ${{ steps.cache-pnpm-store.outputs.cache-hit }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- name: Setup Node.js with pnpm cache
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Cache pnpm store
id: cache-pnpm-store
uses: actions/cache@v3
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
build-configs:
name: Build - Configs
needs: setup-stage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Rsbuild config
run: pnpm --filter @ermnvldmr/rsbuild-config build
- name: Upload Config Artifacts
uses: actions/upload-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
retention-days: 1
lint-ssg:
name: Lint - SSG
needs: build-configs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download Config Artifacts
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Lint SSG package
run: pnpm --filter @ermnvldmr/ssg lint
typecheck-ssg:
name: Typecheck - SSG
needs: build-configs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download Config Artifacts
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Typecheck SSG package
run: pnpm --filter @ermnvldmr/ssg typecheck
build-ssg:
name: Build - SSG
needs: build-configs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.12.3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download Config Artifacts
uses: actions/download-artifact@v4
with:
name: rsbuild-config-dist-${{ github.run_id }}
path: packages/rsbuild-config/dist
- name: Build SSG package
run: pnpm --filter @ermnvldmr/ssg build
@@ -12,6 +12,10 @@ concurrency:
group: ci-stl-${{ github.ref_name }}
cancel-in-progress: true
permissions:
contents: read
actions: read
jobs:
setup-stage:
name: Setup - Stage
@@ -12,6 +12,10 @@ concurrency:
group: ci-pr-stl-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
actions: read
jobs:
setup-stage:
name: Setup - Stage
@@ -14,6 +14,10 @@ concurrency:
group: ci-ui-${{ github.ref_name }}
cancel-in-progress: true
permissions:
contents: read
actions: read
jobs:
setup-stage:
name: Setup - Stage
@@ -14,6 +14,10 @@ concurrency:
group: ci-pr-ui-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
actions: read
jobs:
setup-stage:
name: Setup - Stage
@@ -10,6 +10,10 @@ concurrency:
group: ci-docs-${{ github.ref_name }}
cancel-in-progress: true
permissions:
contents: read
actions: read
jobs:
setup-hugo:
name: Setup - Hugo
@@ -11,6 +11,10 @@ concurrency:
group: ci-pr-docs-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
actions: read
jobs:
setup-hugo:
name: Setup - Hugo
@@ -15,6 +15,10 @@ concurrency:
group: ci-static-${{ github.ref_name }}
cancel-in-progress: true
permissions:
contents: read
actions: read
jobs:
setup-stage:
name: Setup - Stage
@@ -15,6 +15,10 @@ concurrency:
group: ci-pr-static-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
actions: read
jobs:
setup-stage:
name: Setup - Stage
@@ -3,9 +3,13 @@ name: services/www - next/checkout-release-branch
on:
workflow_dispatch:
permissions:
contents: read
actions: read
jobs:
workflow-create-release-branch:
name: Workflow - Create Release Branch
workflow-release-branch:
name: Workflow - Create or Update Release Branch
runs-on: ubuntu-latest
permissions:
contents: write
@@ -16,19 +20,40 @@ jobs:
with:
fetch-depth: 0
- name: Determine Branch Name
id: branch
- name: Determine Mode and Branch Name
id: plan
run: |
BRANCH_NAME=$(bash ci/scripts/semantic-release.sh checkout services/www)
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
REF="${{ github.ref_name }}"
if [[ "$REF" =~ ^releases/www/v[0-9]+\.[0-9]+$ ]]; then
# Dispatched against an existing release branch: update it.
echo "mode=update" >> "$GITHUB_OUTPUT"
echo "branch_name=$REF" >> "$GITHUB_OUTPUT"
else
# Dispatched against next (or any other ref): create a new release branch.
BRANCH_NAME=$(bash ci/scripts/semantic-release.sh checkout services/www)
echo "mode=create" >> "$GITHUB_OUTPUT"
echo "branch_name=$BRANCH_NAME" >> "$GITHUB_OUTPUT"
fi
- name: Create and Push Branch
if: steps.plan.outputs.mode == 'create'
run: |
git checkout -b ${{ steps.branch.outputs.branch_name }}
git push origin ${{ steps.branch.outputs.branch_name }}
git checkout -b ${{ steps.plan.outputs.branch_name }}
git push origin ${{ steps.plan.outputs.branch_name }}
- name: Rebase Existing Branch onto next
if: steps.plan.outputs.mode == 'update'
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# Bring the release branch up to date with its father (next),
# replaying any release-branch-only commits on top of the latest next.
git fetch origin next
git rebase FETCH_HEAD
git push --force-with-lease origin ${{ steps.plan.outputs.branch_name }}
- name: Trigger Release Workflow
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh workflow run services-www-releases-required.yml --ref ${{ steps.branch.outputs.branch_name }}
gh workflow run services-www-releases-required.yml --ref ${{ steps.plan.outputs.branch_name }}
@@ -15,6 +15,10 @@ concurrency:
group: ci-www-${{ github.ref_name }}
cancel-in-progress: true
permissions:
contents: read
actions: read
jobs:
setup-stage:
name: Setup - Stage
@@ -15,6 +15,10 @@ concurrency:
group: ci-pr-www-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
actions: read
jobs:
setup-stage:
name: Setup - Stage
@@ -3,6 +3,10 @@ name: services/www - releases/dispatch-release
on:
workflow_dispatch:
permissions:
contents: read
actions: read
jobs:
setup-stage:
name: Setup - Stage
@@ -9,6 +9,10 @@ concurrency:
group: ci-www-rel-${{ github.ref_name }}
cancel-in-progress: true
permissions:
contents: read
actions: read
jobs:
setup-stage:
name: Setup - Stage
+5 -2
View File
@@ -2,6 +2,9 @@
dist/
*.tsbuildinfo
# service static assets (local only, synced with S3)
**/public/static/
# dependencies
node_modules/
@@ -18,9 +21,9 @@ storybook-static/
test-results/
playwright-report/
# environment variables
# environment variables (.env.dev is encrypted and tracked; .env is plaintext)
.env
.env.production
.env.keys
# macOS-specific files
.DS_Store
+1
View File
@@ -13,6 +13,7 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
# Copy all package.json files to allow pnpm to fetch dependencies
COPY packages/eslint-config/package.json ./packages/eslint-config/
COPY packages/rsbuild-config/package.json ./packages/rsbuild-config/
COPY packages/ssg/package.json ./packages/ssg/
COPY packages/stl/package.json ./packages/stl/
COPY packages/storybook-config/package.json ./packages/storybook-config/
COPY packages/ui/package.json ./packages/ui/
+32
View File
@@ -0,0 +1,32 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STATIC_DIR="$SCRIPT_DIR/../../services/www/public/static"
if [ ! -d "$STATIC_DIR" ]; then
echo "Directory $STATIC_DIR does not exist. Skipping."
exit 0
fi
echo "Optimizing images in $STATIC_DIR using cwebp..."
# Find all png, jpg, jpeg files
find "$STATIC_DIR" -type f \( -name "*.png" -o -name "*.jpg" -o -name "*.jpeg" \) | while read -r file; do
# Determine output path (replace extension with .webp)
dir=$(dirname "$file")
base=$(basename "$file")
ext="${base##*.}"
name="${base%.*}"
output="$dir/$name.webp"
# Check if webp already exists and is newer
if [ -f "$output" ] && [ "$output" -nt "$file" ]; then
echo "Skipping $file (already optimized)"
else
echo "Optimizing: $file -> $output"
cwebp -q 80 "$file" -o "$output"
fi
done
echo "Image optimization complete!"
+105
View File
@@ -0,0 +1,105 @@
# @ermnvldmr/cli
An extensible, configuration-driven CLI toolkit designed specifically for the ermnvldmr monorepo.
## Overview
The `@ermnvldmr/cli` package provides a unified way to declare, manage, and execute scripts across different packages and services in the monorepo. Instead of cluttering `package.json` with complex bash scripts or maintaining ad-hoc Node.js script files, developers can define their package's commands in a strongly-typed `.config/cli.ts` file.
Under the hood, it uses [Commander.js](https://github.com/tj/commander.js) for argument parsing and [JITI](https://github.com/unjs/jiti) for seamless, dynamic TypeScript execution.
## Features
- **Centralized Configuration:** Define all commands in a single `.config/cli.ts` file per package.
- **Type-Safe Builders:** Use `bashCommand` and `nodeCommand` builders to get strict TypeScript inference.
- **Bash & Node Support:** Easily execute shell scripts (via `execa`) or arbitrary asynchronous Node.js functions.
- **Commander Integration:** Automatically registers commands, descriptions, and arguments into a robust CLI interface.
## Installation
This package is intended to be used as a development dependency within the monorepo's workspace packages.
```bash
pnpm add -D @ermnvldmr/cli --workspace
```
## Usage
### 1. Create a Configuration File
Create a `.config/cli.ts` file in the root of your package/service (e.g., `packages/ui/.config/cli.ts`).
```typescript
import { defineCli, bashCommand, nodeCommand } from '@ermnvldmr/cli';
export default defineCli({
commands: [
bashCommand({
name: 'hello',
description: 'Prints a hello message via bash',
script: 'echo "Hello from Bash!"'
}),
nodeCommand({
name: 'build:custom',
description: 'Runs a custom node build step',
options: [
{
flags: '--minify',
description: 'Enable minification',
defaultValue: false
}
],
action: async (options) => {
console.log('Building with node...');
if (options.minify) {
console.log('Minification enabled!');
}
}
})
]
});
```
### 2. Run the CLI
You can invoke the CLI directly using `pnpm exec` from the package directory:
```bash
pnpm exec ermnvldmr-cli hello
pnpm exec ermnvldmr-cli build:custom --minify
```
Alternatively, you can wire it into your `package.json` scripts:
```json
{
"scripts": {
"cli": "ermnvldmr-cli",
"hello": "ermnvldmr-cli hello"
}
}
```
## API Reference
### `defineCli(config: CliConfig)`
An identity helper function that provides strict TypeScript type-inference for your configuration object. It returns the configuration unmodified.
### `bashCommand(config: Omit<BashCommandConfig, 'type'>)`
A builder function for defining shell commands.
- **`script`**: The exact bash string to execute. It will be executed using `execa` with `shell: true` and `stdio: 'inherit'`.
### `nodeCommand(config: Omit<NodeCommandConfig, 'type'>)`
A builder function for defining arbitrary JavaScript/TypeScript actions.
- **`action`**: An asynchronous function `(options: Record<string, unknown>) => Promise<void> | void` that executes when the command is invoked.
## Development
This package adheres to the global monorepo standards outlined in `GEMINI.md`:
- **Strict TypeScript**: No `any` types are permitted.
- **TSDoc**: All exported members must include comprehensive TSDoc comments.
- **TDD**: Modifications and new builders should be accompanied by Vitest unit tests.
+45
View File
@@ -0,0 +1,45 @@
{
"name": "@ermnvldmr/cli",
"version": "1.0.0",
"description": "Extensible CLI tool for ermnvldmr projects",
"type": "module",
"bin": {
"ermnvldmr-cli": "./dist/bin.js"
},
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src --max-warnings 0",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1075.0",
"@dotenvx/dotenvx": "^1.75.1",
"commander": "^13.1.0",
"execa": "^9.5.2",
"fast-glob": "^3.3.3",
"jiti": "^2.4.2",
"mime-types": "^3.0.2"
},
"devDependencies": {
"@ermnvldmr/eslint-config": "workspace:*",
"@ermnvldmr/rsbuild-config": "workspace:*",
"@types/mime-types": "^3.0.1",
"@types/node": "^22.13.0",
"sharp": "^0.35.2",
"typescript": "^5.8.2",
"vitest": "^4.0.18"
}
}
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env node
import { runCli } from './lib/runner.js';
runCli().catch((err) => {
console.error('An unexpected error occurred:', err);
process.exit(1);
});
+17
View File
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { defineCli, bashCommand, nodeCommand } from './index.js';
describe('public exports', () => {
it('defineCli returns the passed config unmodified', () => {
const config = defineCli({
commands: [],
});
expect(config.commands).toEqual([]);
});
it('exports builders', () => {
expect(typeof bashCommand).toBe('function');
expect(typeof nodeCommand).toBe('function');
});
});
+25
View File
@@ -0,0 +1,25 @@
import type { CliConfig } from './lib/types.js';
/**
* Helper function to define the CLI configuration with strict type-safety.
*
* @param config - The CLI configuration containing the array of commands.
* @returns The unmodified CLI configuration.
* @example
* ```typescript
* const config = defineCli({
* commands: [
* bashCommand({ name: 'hello', description: 'say hello', script: 'echo hello' })
* ]
* });
* ```
*/
export function defineCli(config: CliConfig): CliConfig {
return config;
}
export { bashCommand } from './lib/builders/bash.js';
export { nodeCommand } from './lib/builders/node.js';
export { s3SyncCommand } from './lib/builders/s3Sync.js';
export { env } from './lib/env.js';
export * from './lib/types.js';
@@ -0,0 +1,18 @@
import { describe, it, expect } from 'vitest';
import { bashCommand } from './bash.js';
describe('bashCommand builder', () => {
it('should return a properly formatted bash command config', () => {
const config = bashCommand({
name: 'hello',
description: 'says hello',
script: 'echo "hello"',
});
expect(config.name).toBe('hello');
expect(config.description).toBe('says hello');
expect(config.script).toBe('echo "hello"');
expect(config.type).toBe('bash');
});
});
+24
View File
@@ -0,0 +1,24 @@
import type { BashCommandConfig } from '../types.js';
/**
* Creates a Bash command configuration.
*
* @param config - The command configuration excluding the 'type' field.
* @returns The fully formatted Bash command configuration.
* @example
* ```typescript
* const config = bashCommand({
* name: 'hello',
* description: 'says hello',
* script: 'echo "hello"'
* });
* ```
*/
export function bashCommand(
config: Omit<BashCommandConfig, 'type'>
): BashCommandConfig {
return {
...config,
type: 'bash',
};
}
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { nodeCommand } from './node.js';
describe('nodeCommand builder', () => {
it('should return a properly formatted node command config', () => {
const action = async () => {};
const config = nodeCommand({
name: 'build',
description: 'builds stuff',
action,
});
expect(config.name).toBe('build');
expect(config.description).toBe('builds stuff');
expect(config.action).toBe(action);
expect(config.type).toBe('node');
});
});
+24
View File
@@ -0,0 +1,24 @@
import type { NodeCommandConfig } from '../types.js';
/**
* Creates a Node command configuration.
*
* @param config - The command configuration excluding the 'type' field.
* @returns The fully formatted Node command configuration.
* @example
* ```typescript
* const config = nodeCommand({
* name: 'run',
* description: 'runs action',
* action: () => console.log('hello')
* });
* ```
*/
export function nodeCommand(
config: Omit<NodeCommandConfig, 'type'>
): NodeCommandConfig {
return {
...config,
type: 'node',
};
}
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { s3SyncCommand } from './s3Sync.js';
describe('s3SyncCommand', () => {
it('should create an s3-sync command config', () => {
const config = s3SyncCommand({
name: 'sync',
description: 'Sync files',
targetPrefix: 'www'
});
expect(config.type).toBe('s3-sync');
expect(config.name).toBe('sync');
expect(config.targetPrefix).toBe('www');
});
});
+24
View File
@@ -0,0 +1,24 @@
import type { S3SyncCommandConfig } from '../types.js';
/**
* Creates an S3 Sync command configuration.
*
* @param config - The command configuration excluding the 'type' field.
* @returns The fully formatted S3 Sync command configuration.
* @example
* ```typescript
* const config = s3SyncCommand({
* name: 'sync',
* description: 'Sync files',
* targetPrefix: 'www'
* });
* ```
*/
export function s3SyncCommand(
config: Omit<S3SyncCommandConfig, 'type'>
): S3SyncCommandConfig {
return {
...config,
type: 's3-sync',
};
}
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock dotenvx before importing env
vi.mock('@dotenvx/dotenvx', () => ({
config: vi.fn(),
}));
// Mock node:fs so findRoot returns a predictable root
vi.mock('node:fs', () => ({
existsSync: vi.fn((p: string) => String(p).endsWith('pnpm-workspace.yaml')),
}));
describe('env', () => {
beforeEach(async () => {
// Reset module registry so the `loaded` singleton resets between tests
vi.resetModules();
});
it('calls dotenvx config() on first access with the root .env.dev path', async () => {
const { config } = await import('@dotenvx/dotenvx');
const { env } = await import('./env.js');
process.env.TEST_VAR = 'hello';
env('TEST_VAR');
expect(config).toHaveBeenCalledTimes(1);
expect(vi.mocked(config).mock.calls[0]![0]).toMatchObject({ path: expect.stringContaining('.env.dev') });
delete process.env.TEST_VAR;
});
it('returns the environment variable value', async () => {
const { env } = await import('./env.js');
process.env.MY_KEY = 'my-value';
expect(env('MY_KEY')).toBe('my-value');
delete process.env.MY_KEY;
});
it('returns the fallback when the variable is not set', async () => {
const { env } = await import('./env.js');
delete process.env.MISSING_KEY;
expect(env('MISSING_KEY', 'fallback-value')).toBe('fallback-value');
});
it('returns undefined when the variable is not set and no fallback is provided', async () => {
const { env } = await import('./env.js');
delete process.env.MISSING_KEY;
expect(env('MISSING_KEY')).toBeUndefined();
});
it('calls dotenvx config() only once across multiple env() calls within the same import', async () => {
const { config } = await import('@dotenvx/dotenvx');
const mockConfig = vi.mocked(config);
mockConfig.mockClear();
const { env } = await import('./env.js');
env('FOO');
env('BAR');
env('BAZ');
// config() should have been called exactly once for this fresh module instance
expect(mockConfig).toHaveBeenCalledTimes(1);
});
});
+73
View File
@@ -0,0 +1,73 @@
import { config } from '@dotenvx/dotenvx';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
let loaded = false;
/**
* Walks up the directory tree from `start` until it finds a directory
* containing `pnpm-workspace.yaml` (the monorepo root), or reaches the
* filesystem root.
*
* @param start - Directory to begin the search from.
* @returns The path of the monorepo root directory, or `start` as a fallback.
*
* @internal
*/
function findRoot(start: string): string {
let dir = start;
while (true) {
if (existsSync(join(dir, 'pnpm-workspace.yaml'))) {
return dir;
}
const parent = dirname(dir);
if (parent === dir) {
// Reached the filesystem root without finding the marker.
return start;
}
dir = parent;
}
}
/**
* Ensures dotenvx has loaded `.env.dev` from the monorepo root into `process.env`.
* Walks up from the current working directory to find the root, then loads the
* `.env.dev` file located there. Safe to call multiple times — runs only once per
* process.
*
* @internal
*/
function ensureLoaded(): void {
if (!loaded) {
config({ path: join(findRoot(process.cwd()), '.env.dev') });
loaded = true;
}
}
/**
* Reads an environment variable by key, loading `.env.dev` files via dotenvx on
* the first call. Optionally returns a fallback value when the variable is
* not set.
*
* Designed for use inside `cli.ts` config files so that env values are
* resolved **at command execution time** (after dotenvx has populated
* `process.env`), not at config-parse time.
*
* @param key - The name of the environment variable to read.
* @param fallback - Optional fallback value returned when the variable is absent.
* @returns The environment variable value, the fallback, or `undefined`.
*
* @example
* ```typescript
* import { env } from '@ermnvldmr/cli';
*
* s3SyncCommand({
* bucket: env('MY_BUCKET'),
* region: env('AWS_REGION', 'us-east-1'),
* });
* ```
*/
export function env(key: string, fallback?: string): string | undefined {
ensureLoaded();
return process.env[key] ?? fallback;
}
+49
View File
@@ -0,0 +1,49 @@
import { Command } from 'commander';
import { describe, it, expect, vi } from 'vitest';
import { registerCommands } from './runner.js';
import { runS3Sync } from './runners/s3SyncRunner.js';
vi.mock('./runners/s3SyncRunner.js', () => ({
runS3Sync: vi.fn(),
}));
describe('registerCommands', () => {
it('registers commands into a Commander instance', async () => {
const program = new Command();
const actionSpy = vi.fn();
registerCommands(program, {
commands: [
{
type: 'node',
name: 'test-cmd',
description: 'A test command',
action: actionSpy,
}
]
});
// We simulate parsing args to run the action. The registered action is
// async, so parseAsync + await ensures it runs before we assert.
await program.parseAsync(['node', 'script', 'test-cmd']);
expect(actionSpy).toHaveBeenCalled();
});
it('registers s3-sync commands into a Commander instance', async () => {
const program = new Command();
const cmd = {
type: 's3-sync' as const,
name: 'sync-cmd',
description: 'An S3 sync command',
targetPrefix: 'my-site',
};
registerCommands(program, {
commands: [cmd]
});
await program.parseAsync(['node', 'script', 'sync-cmd']);
expect(runS3Sync).toHaveBeenCalledWith(cmd, expect.any(Object));
});
});
+119
View File
@@ -0,0 +1,119 @@
import fs from 'fs';
import path from 'path';
import { Command } from 'commander';
import { execaCommand } from 'execa';
import { createJiti } from 'jiti';
import type { CliConfig } from './types.js';
/**
* Registers commands from the configuration into a Commander program.
*
* @param program - The Commander program instance.
* @param config - The CLI configuration containing the commands to register.
* @example
* ```typescript
* import { Command } from 'commander';
* const program = new Command();
* registerCommands(program, { commands: [] });
* ```
*/
export function registerCommands(program: Command, config: CliConfig): void {
for (const cmd of config.commands) {
const command = program.command(cmd.name).description(cmd.description);
if (cmd.options) {
for (const opt of cmd.options) {
command.option(opt.flags, opt.description, opt.defaultValue);
}
}
switch (cmd.type) {
case 'bash': {
command.action(async () => {
try {
await execaCommand(cmd.script, { stdio: 'inherit', shell: true });
} catch (err: unknown) {
const isErrorWithExitCode = (e: unknown): e is { exitCode: number } => {
return typeof e === 'object' && e !== null && 'exitCode' in e && typeof Reflect.get(e, 'exitCode') === 'number';
};
process.exit(isErrorWithExitCode(err) ? err.exitCode : 1);
}
});
break;
}
case 'node': {
command.action(async (options: Record<string, unknown>) => {
try {
await cmd.action(options);
} catch (err: unknown) {
console.error(err);
process.exit(1);
}
});
break;
}
case 's3-sync': {
command.action(async (options: Record<string, unknown>) => {
try {
const { runS3Sync } = await import('./runners/s3SyncRunner.js');
await runS3Sync(cmd, options);
} catch (err: unknown) {
console.error(err);
process.exit(1);
}
});
break;
}
default: {
const _exhaustiveCheck: never = cmd;
void _exhaustiveCheck;
break;
}
}
}
}
/**
* Main entry point function that loads the CLI configuration and executes the runner.
*
* @param cwd - Current working directory (defaults to process.cwd()).
* @param argv - Process arguments (defaults to process.argv).
* @example
* ```typescript
* await runCli();
* ```
*/
export async function runCli(cwd: string = process.cwd(), argv: string[] = process.argv): Promise<void> {
const configPath = path.join(cwd, '.config/cli.ts');
if (!fs.existsSync(configPath)) {
console.error(`Error: No .config/cli.ts found in ${cwd}`);
process.exit(1);
}
const jiti = createJiti(import.meta.url);
const rawModule = await jiti.import(configPath);
const isCliConfigModule = (mod: unknown): mod is { default: CliConfig } => {
return typeof mod === 'object' && mod !== null && 'default' in mod;
};
if (!isCliConfigModule(rawModule)) {
console.error(`Error: Invalid CLI configuration export in ${configPath}`);
process.exit(1);
}
const config = rawModule.default;
const program = new Command();
program
.name('ermnvldmr-cli')
.description('Extensible CLI runner')
.version('1.0.0');
registerCommands(program, config);
program.parse(argv);
}
@@ -0,0 +1,300 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { runS3Sync } from './s3SyncRunner.js';
import type { S3SyncCommandConfig } from '../types.js';
interface MockPutObjectCommandInput {
Bucket: string;
Key: string;
Body: Buffer;
ContentType: string;
}
class MockPutObjectCommand {
constructor(public input: MockPutObjectCommandInput) {}
}
// Mock dotenvx
vi.mock('@dotenvx/dotenvx', () => ({
config: vi.fn(),
}));
// Mock S3Client
const mockSend = vi.fn<(command: MockPutObjectCommand) => Promise<unknown>>();
vi.mock('@aws-sdk/client-s3', () => {
return {
S3Client: class {
send = mockSend;
},
PutObjectCommand: class {
constructor(public input: MockPutObjectCommandInput) {}
},
};
});
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
describe('runS3Sync', () => {
const tempDir = path.resolve(__dirname, 'temp_test_s3_sync');
let cwdSpy: { mockRestore: () => void };
beforeEach(() => {
mockSend.mockReset();
// Set required environment variables
process.env.S3_BUCKET = 'my-bucket';
process.env.AWS_REGION = 'us-east-1';
process.env.S3_ENDPOINT_URL = 'https://s3.example.com';
process.env.AWS_ACCESS_KEY_ID = 'access-key';
process.env.AWS_SECRET_ACCESS_KEY = 'secret-key';
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
fs.mkdirSync(tempDir, { recursive: true });
// Mock process.cwd to return tempDir so glob search happens in tempDir/src
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tempDir);
});
afterEach(() => {
cwdSpy.mockRestore();
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('should throw error if required env variables are missing', async () => {
const cmd: S3SyncCommandConfig = {
name: 'sync',
description: 'desc',
targetPrefix: 'test',
type: 's3-sync'
};
// Clear env vars
delete process.env.S3_BUCKET;
await expect(runS3Sync(cmd, {})).rejects.toThrow('Missing required S3 environment variables');
});
it('should successfully sync files using default sourceDir and replace references in source files', async () => {
// Default sourceDir is public/static
const publicStaticDir = path.join(tempDir, 'public/static');
fs.mkdirSync(publicStaticDir, { recursive: true });
// Create dummy source folder and files (where references will be replaced)
const srcDir = path.join(tempDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
const htmlSourcePath = path.join(srcDir, 'index.tsx');
const cssSourcePath = path.join(srcDir, 'style.css');
fs.writeFileSync(htmlSourcePath, 'const img = "/static/logo.png";');
fs.writeFileSync(cssSourcePath, 'body { background: url(/static/logo.png); }');
// Create dummy files inside the static directory to upload
const imagePath = path.join(publicStaticDir, 'logo.png');
fs.writeFileSync(imagePath, 'binary-content-mock');
const cmd: S3SyncCommandConfig = {
name: 'sync',
description: 'desc',
targetPrefix: 'my-site',
type: 's3-sync',
};
// Run sync (which should default to public/static)
await runS3Sync(cmd, {});
// Check if the HTML and CSS source file references were replaced
const htmlContent = fs.readFileSync(htmlSourcePath, 'utf-8');
const cssContent = fs.readFileSync(cssSourcePath, 'utf-8');
expect(htmlContent).toContain('https://s3.example.com/my-bucket/my-site/logo.png');
expect(cssContent).toContain('https://s3.example.com/my-bucket/my-site/logo.png');
// Check if S3Client sent PutObjectCommands for the file
expect(mockSend).toHaveBeenCalledTimes(1);
const calls = mockSend.mock.calls;
const commandInstance = calls[0][0];
expect(commandInstance.input.Key).toBe('my-site/logo.png');
});
it('should replace longer overlapping filenames before shorter ones', async () => {
const publicStaticDir = path.join(tempDir, 'public/static');
fs.mkdirSync(publicStaticDir, { recursive: true });
const srcDir = path.join(tempDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
const sourcePath = path.join(srcDir, 'index.tsx');
fs.writeFileSync(sourcePath, 'const img1 = "/static/logo.png.webp"; const img2 = "/static/logo.png";');
fs.writeFileSync(path.join(publicStaticDir, 'logo.png.webp'), 'webp-mock');
fs.writeFileSync(path.join(publicStaticDir, 'logo.png'), 'png-mock');
const cmd: S3SyncCommandConfig = {
name: 'sync',
description: 'desc',
targetPrefix: 'my-site',
type: 's3-sync',
};
await runS3Sync(cmd, {});
const content = fs.readFileSync(sourcePath, 'utf-8');
expect(content).toContain('https://s3.example.com/my-bucket/my-site/logo.png.webp');
expect(content).toContain('https://s3.example.com/my-bucket/my-site/logo.png');
expect(content).not.toContain('/static/logo.png.webp');
expect(content).not.toContain('/static/logo.png');
});
it('should respect beforeUpload hook if provided', async () => {
const publicStaticDir = path.join(tempDir, 'public/static');
fs.mkdirSync(publicStaticDir, { recursive: true });
const htmlPath = path.join(publicStaticDir, 'index.html');
fs.writeFileSync(htmlPath, '<html><body>Hello</body></html>');
const cmd: S3SyncCommandConfig = {
name: 'sync',
description: 'desc',
targetPrefix: 'my-site',
type: 's3-sync',
hooks: {
beforeUpload: (files) => {
const processed = files.map(file => {
const ext = path.extname(file);
const base = path.basename(file, ext);
return {
originalName: path.basename(file),
finalName: `custom-${base}${ext}`,
absolutePath: file,
};
});
return Promise.resolve(processed);
}
}
};
await runS3Sync(cmd, {});
expect(mockSend).toHaveBeenCalledTimes(1);
const commandInstance = mockSend.mock.calls[0][0];
expect(commandInstance.input.Key).toBe('my-site/custom-index.html');
});
it('should resolve S3 configuration parameters passed directly via configuration object', async () => {
const publicStaticDir = path.join(tempDir, 'public/static');
fs.mkdirSync(publicStaticDir, { recursive: true });
// Write a dummy file to upload so the upload loop runs
fs.writeFileSync(path.join(publicStaticDir, 'test.txt'), 'content');
// Clear standard env vars to ensure fallback is not used
delete process.env.S3_BUCKET;
delete process.env.AWS_REGION;
delete process.env.AWS_ACCESS_KEY_ID;
delete process.env.AWS_SECRET_ACCESS_KEY;
const cmd: S3SyncCommandConfig = {
name: 'sync',
description: 'desc',
targetPrefix: 'test',
bucket: 'direct-bucket',
region: 'us-west-2',
accessKeyId: 'direct-access-key',
secretAccessKey: 'direct-secret-key',
type: 's3-sync'
};
await runS3Sync(cmd, {});
expect(mockSend).toHaveBeenCalled();
const commandInstance = mockSend.mock.calls[0][0];
expect(commandInstance.input.Bucket).toBe('direct-bucket');
});
it('should run beforeSync hook as shell command if provided as string', async () => {
const publicStaticDir = path.join(tempDir, 'public/static');
fs.mkdirSync(publicStaticDir, { recursive: true });
fs.writeFileSync(path.join(publicStaticDir, 'test.txt'), 'content');
const cmd: S3SyncCommandConfig = {
name: 'sync',
description: 'desc',
targetPrefix: 'test',
type: 's3-sync',
hooks: {
beforeSync: 'echo "hello from beforeSync script"',
},
};
await runS3Sync(cmd, {});
expect(mockSend).toHaveBeenCalled();
});
it('should run beforeSync hook as async function if provided as function', async () => {
const publicStaticDir = path.join(tempDir, 'public/static');
fs.mkdirSync(publicStaticDir, { recursive: true });
fs.writeFileSync(path.join(publicStaticDir, 'test.txt'), 'content');
const beforeSyncFn = vi.fn().mockResolvedValue(undefined);
const cmd: S3SyncCommandConfig = {
name: 'sync',
description: 'desc',
targetPrefix: 'test',
type: 's3-sync',
hooks: {
beforeSync: beforeSyncFn,
},
};
await runS3Sync(cmd, {});
expect(beforeSyncFn).toHaveBeenCalledTimes(1);
expect(mockSend).toHaveBeenCalled();
});
it('should automatically map png/jpg/jpeg to webp if counterpart exists and deduplicate uploads', async () => {
const publicStaticDir = path.join(tempDir, 'public/static');
fs.mkdirSync(publicStaticDir, { recursive: true });
// Create png and webp counterpart
fs.writeFileSync(path.join(publicStaticDir, 'logo.png'), 'png-content');
fs.writeFileSync(path.join(publicStaticDir, 'logo.webp'), 'webp-content');
// Create a source file referencing the png
const srcDir = path.join(tempDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
const sourcePath = path.join(srcDir, 'index.tsx');
fs.writeFileSync(sourcePath, 'const img = "/static/logo.png";');
const cmd: S3SyncCommandConfig = {
name: 'sync',
description: 'desc',
targetPrefix: 'test',
type: 's3-sync',
};
await runS3Sync(cmd, {});
// Reference replacement check: /static/logo.png should be replaced by S3 URL of logo.webp
const content = fs.readFileSync(sourcePath, 'utf-8');
expect(content).toContain('https://s3.example.com/my-bucket/test/logo.webp');
expect(content).not.toContain('logo.png');
// Deduplication check: only logo.webp should have been uploaded once (mockSend called once for logo.webp)
expect(mockSend).toHaveBeenCalledTimes(1);
const commandInstance = mockSend.mock.calls[0][0];
expect(commandInstance.input.Key).toBe('test/logo.webp');
});
});
@@ -0,0 +1,219 @@
import fs from 'fs';
import path from 'path';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { execaCommand } from 'execa';
import fg from 'fast-glob';
import mime from 'mime-types';
import type { S3SyncCommandConfig, ProcessedFile } from '../types.js';
/**
* Normalizes prefix and name to construct a clean S3 key.
*
* @param prefix - The target directory prefix.
* @param name - The relative path/name of the file.
* @returns The clean, normalized S3 key.
*
* @example
* ```typescript
* normalizeKey('static', 'js/main.js'); // 'static/js/main.js'
* ```
*/
function normalizeKey(prefix: string, name: string): string {
const parts = [prefix, name]
.filter(Boolean)
.map(p => p.replace(/^\/+|\/+$/g, '').replace(/\\/g, '/'));
return parts.join('/');
}
/**
* Runs the S3 Sync command.
*
* Loads environment variables, instantiates S3Client, processes files,
* replaces static asset references in source files (`/static/filename` -> S3 URL),
* and uploads the assets to S3.
*
* @param cmd - The configuration for the S3 sync command.
* @param options - Additional options passed from Commander.
* @returns A promise that resolves when the synchronization is complete.
* @throws An error if required environment variables are missing.
*
* @example
* ```typescript
* await runS3Sync(cmd, {});
* ```
*/
export async function runS3Sync(
cmd: S3SyncCommandConfig,
options: Record<string, unknown> = {}
): Promise<void> {
void options;
const bucket = cmd.bucket ?? process.env.S3_BUCKET;
const region = cmd.region ?? process.env.AWS_REGION ?? process.env.S3_REGION;
const endpoint = cmd.endpoint ?? process.env.S3_ENDPOINT_URL ?? process.env.S3_ENDPOINT;
const accessKey = cmd.accessKeyId ?? process.env.AWS_ACCESS_KEY_ID ?? process.env.S3_ACCESS_KEY_ID;
const secretKey = cmd.secretAccessKey ?? process.env.AWS_SECRET_ACCESS_KEY ?? process.env.S3_SECRET_ACCESS_KEY;
if (!bucket || !region || !accessKey || !secretKey) {
throw new Error('Missing required S3 environment variables');
}
const s3Client = new S3Client({
region,
...(endpoint ? { endpoint } : {}),
credentials: {
accessKeyId: accessKey,
secretAccessKey: secretKey,
},
forcePathStyle: true,
});
if (cmd.hooks?.beforeSync) {
const hook = cmd.hooks.beforeSync;
if (typeof hook === 'string') {
console.log(`Running beforeSync script: ${hook}`);
await execaCommand(hook, { stdio: 'inherit', shell: true });
} else {
console.log('Running beforeSync hook...');
await hook();
}
}
const sourceDir = cmd.sourceDir ?? './public/static';
const resolvedSourceDir = path.resolve(process.cwd(), sourceDir);
if (!fs.existsSync(resolvedSourceDir)) {
throw new Error(`Source directory does not exist: ${resolvedSourceDir}`);
}
// Find all files in the source directory, including dotfiles
const rawFiles = await fg('**/*', {
cwd: resolvedSourceDir,
onlyFiles: true,
absolute: true,
dot: true,
});
// Process files through the hook if it exists, otherwise use the default mapping with auto-optimization detection
let processedFiles: ProcessedFile[];
if (cmd.hooks?.beforeUpload) {
const rawProcessed = await cmd.hooks.beforeUpload(rawFiles);
processedFiles = rawProcessed.map((file) => ({
...file,
originalName: file.originalName.replace(/\\/g, '/'),
finalName: file.finalName.replace(/\\/g, '/'),
}));
} else {
// Index raw files by normalized relative path
const rawRels = new Set(
rawFiles.map((file) => path.relative(resolvedSourceDir, file).replace(/\\/g, '/'))
);
processedFiles = rawFiles.map((file) => {
const rel = path.relative(resolvedSourceDir, file).replace(/\\/g, '/');
const ext = path.extname(file).toLowerCase();
if (['.png', '.jpg', '.jpeg'].includes(ext)) {
const webpRel = rel.slice(0, -ext.length) + '.webp';
if (rawRels.has(webpRel)) {
// An optimized WebP version exists in the directory!
// Map original reference to the WebP final upload path
return {
originalName: rel,
finalName: webpRel,
absolutePath: path.resolve(resolvedSourceDir, webpRel),
};
}
}
return {
originalName: rel,
finalName: rel,
absolutePath: file,
};
});
}
/**
* Helper to construct the S3 URL for a final filename.
*
* @param finalName - The final processed name of the file.
* @returns The public URL of the file on S3.
*
* @example
* ```typescript
* getS3Url('js/main.js'); // 'https://my-bucket.s3.us-east-1.amazonaws.com/my-prefix/js/main.js'
* ```
*/
const getS3Url = (finalName: string): string => {
const normalizedKey = normalizeKey(cmd.targetPrefix, finalName);
if (endpoint) {
return `https://${bucket}/${normalizedKey}`;
}
return `https://${bucket}.s3.${region}.amazonaws.com/${normalizedKey}`;
};
// Find workspace source files matching `src/**/*.{ts,tsx,css,md,mdx}`
const sourceFiles = await fg('src/**/*.{ts,tsx,css,md,mdx}', {
cwd: process.cwd(),
absolute: true,
});
// Sort assets by length descending to handle overlapping name replacement correctly
const sortedAssets = [...processedFiles].sort(
(a, b) => b.originalName.length - a.originalName.length
);
// Replace static asset references in workspace source files
for (const sourceFile of sourceFiles) {
let content = await fs.promises.readFile(sourceFile, 'utf-8');
if (!content.includes('/static/')) {
continue;
}
let modified = false;
for (const asset of sortedAssets) {
let searchName = asset.originalName;
if (searchName.startsWith('static/')) {
searchName = searchName.substring('static/'.length);
} else if (searchName.startsWith('/static/')) {
searchName = searchName.substring('/static/'.length);
}
const searchStr = `/static/${searchName}`;
const assetUrl = getS3Url(asset.finalName);
if (content.includes(searchStr)) {
content = content.split(searchStr).join(assetUrl);
modified = true;
}
}
if (modified) {
await fs.promises.writeFile(sourceFile, content, 'utf-8');
}
}
// Deduplicate files for S3 upload by their S3 key (finalName)
const uniqueUploads = Array.from(
new Map(processedFiles.map((file) => [file.finalName, file])).values()
);
// Upload files to S3
console.log(`Syncing ${uniqueUploads.length} unique files to s3://${bucket}/${cmd.targetPrefix}...`);
for (const file of uniqueUploads) {
const s3Key = normalizeKey(cmd.targetPrefix, file.finalName);
const contentType = mime.lookup(file.absolutePath) || 'application/octet-stream';
const fileContent = await fs.promises.readFile(file.absolutePath);
await s3Client.send(
new PutObjectCommand({
Bucket: bucket,
Key: s3Key,
Body: fileContent,
ContentType: contentType,
})
);
}
}
+136
View File
@@ -0,0 +1,136 @@
/**
* Base configuration interface for all CLI commands.
*/
export interface BaseCommandConfig {
/** The name of the command */
name: string;
/** A brief description of what the command does */
description: string;
/** Optional array of command-line options/flags */
options?: {
/** The flag definitions, e.g., '-f, --force' */
flags: string;
/** Description of the option */
description: string;
/** The default value if the option is not provided */
defaultValue?: string | boolean | string[];
}[];
}
/**
* Configuration for a Bash script based command.
*/
export interface BashCommandConfig extends BaseCommandConfig {
/** The bash script or command to execute */
script: string;
/** The type discriminator for bash commands */
type: 'bash';
}
/**
* Configuration for a Node.js function based command.
*/
export interface NodeCommandConfig extends BaseCommandConfig {
/** The action function to execute when the command is run */
action: (options: Record<string, unknown>) => Promise<void> | void;
/** The type discriminator for node commands */
type: 'node';
}
/**
* Represents a processed file ready for upload.
*/
export interface ProcessedFile {
/** The original name of the file before processing */
originalName: string;
/** The final name of the file after processing, used as S3 key */
finalName: string;
/** The absolute path of the file on the local filesystem */
absolutePath: string;
}
/**
* A hook value that can be either a shell command string or an async JS function.
*
* - **`string`** — executed as a shell command via the system shell.
* - **`() => Promise<void>`** — called directly, useful for programmatic pre-processing.
*/
export type HookValue = string | (() => Promise<void>);
/**
* Lifecycle hooks for the S3 sync command.
*/
export interface S3SyncHooks {
/**
* Runs before the sync process starts (before files are discovered).
*
* Accepts either a shell command string or an async function.
*
* @example
* ```typescript
* // Shell command
* hooks: { beforeSync: 'bash ./scripts/optimize-images.sh' }
*
* // JS function
* hooks: { beforeSync: async () => { await optimizeImages(); } }
* ```
*/
beforeSync?: HookValue;
/**
* Runs after files are discovered but before they are uploaded.
*
* Receives the raw list of absolute file paths and must return the processed
* file descriptors to upload. Useful for filtering, renaming, or replacing
* files (e.g., swapping `.png` originals for their `.webp` counterparts).
*
* @param files - Absolute paths of all discovered files.
* @returns The processed file list to upload.
*
* @example
* ```typescript
* hooks: {
* beforeUpload: async (files) =>
* files.map((f) => ({ originalName: path.basename(f), finalName: path.basename(f), absolutePath: f }))
* }
* ```
*/
beforeUpload?: (files: string[]) => Promise<ProcessedFile[]>;
}
/**
* Configuration for an S3 synchronization command.
*/
export interface S3SyncCommandConfig extends BaseCommandConfig {
/** The type discriminator for S3 sync commands */
type: 's3-sync';
/** The source directory containing files to sync */
sourceDir?: string;
/** The prefix (directory) under which to upload the files in the S3 bucket */
targetPrefix: string;
/** Optional S3 bucket name */
bucket?: string;
/** Optional AWS region */
region?: string;
/** Optional custom endpoint URL (e.g., for MinIO or Yandex Cloud) */
endpoint?: string;
/** Optional AWS Access Key ID */
accessKeyId?: string;
/** Optional AWS Secret Access Key */
secretAccessKey?: string;
/** Hooks to modify the sync process at various lifecycle stages */
hooks?: S3SyncHooks;
}
/**
* Union type representing any valid CLI command configuration.
*/
export type CliCommand = BashCommandConfig | NodeCommandConfig | S3SyncCommandConfig;
/**
* Root configuration object for the CLI toolkit.
*/
export interface CliConfig {
/** Array of registered commands */
commands: CliCommand[];
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"declaration": true,
"sourceMap": true,
"module": "NodeNext",
"moduleResolution": "NodeNext"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { baseVitestConfig } from '@ermnvldmr/rsbuild-config/dev';
import { mergeConfig } from 'vitest/config';
export default mergeConfig(baseVitestConfig, {
test: {
environment: 'node',
},
});
@@ -1,31 +0,0 @@
import { join } from 'node:path';
import fg from 'fast-glob';
export function discoverEntries(root: string, pattern: string): Record<string, string> {
const files = fg.sync(pattern, { cwd: root });
const entries: Record<string, string> = {};
for (const file of files) {
// We expect the user to provide a pattern like './src/app/*.tsx'
// We'll strip the directory parts to get the name
const parts = file.split('/');
// Remove extension from the last part
const filename = parts[parts.length - 1].replace(/\.tsx$/, '');
// For nested routes, we might want to keep the path but relative to the app root
// But since you want 'index', 'articles/index', etc.
// Let's just use the file path relative to the common root 'src/app' if it exists in the path
const appMatch = file.match(/src\/app\/(.*)\.tsx$/);
let name = appMatch ? appMatch[1] : filename;
// If it's src/app/articles/index.tsx -> name is 'articles/index'
// We want it to be 'articles/index' to generate 'dist/articles/index.html'
// Rsbuild automatically maps 'articles/index' entry to 'dist/articles/index.html'
// The previous replace(/\/index$/, '') was stripping the 'index' making it 'articles'
// which generates 'dist/articles.html' instead of 'dist/articles/index.html'
entries[name] = join(root, file);
}
return entries;
}
@@ -1,3 +1,2 @@
export * from './rsbuild.js';
export * from './vitest.js';
export * from './entries.js';
@@ -4,7 +4,6 @@ import { pluginTypeCheck } from '@rsbuild/plugin-type-check';
import { pluginDts } from 'rsbuild-plugin-dts';
import { mergeConfig } from '../utils/merge.js';
import { ssgPlugin } from './ssg.js';
/**
* Defines a standard Rsbuild configuration for services (apps).
@@ -22,7 +21,7 @@ import { ssgPlugin } from './ssg.js';
*/
export function defineServiceConfig(config: RsbuildConfig = {}): RsbuildConfig {
const defaultConfig: RsbuildConfig = {
plugins: [pluginReact(), pluginTypeCheck(), ssgPlugin()],
plugins: [pluginReact(), pluginTypeCheck()],
output: {
distPath: {
font: 'static/font',
+52
View File
@@ -0,0 +1,52 @@
{
"name": "@ermnvldmr/ssg",
"version": "0.0.1",
"description": "Static Site Generation framework for ermnvldmr Rsbuild + React services",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./src/index.ts"
},
"./dev": {
"import": "./dist/dev.js",
"types": "./dist/dev.d.ts",
"default": "./src/dev.ts"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"lint": "eslint src --max-warnings 0",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@ermnvldmr/eslint-config": "workspace:*",
"@ermnvldmr/rsbuild-config": "workspace:*",
"@rsbuild/core": "^1.7.3",
"@rsbuild/plugin-react": "^1.1.1",
"@rsbuild/plugin-type-check": "^1.1.1",
"@types/node": "*",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"critters": "^0.0.25",
"fast-glob": "^3.3.3",
"jsdom": "^28.0.0",
"rsbuild-plugin-dts": "^0.19.5",
"typescript": "^5.8.2"
},
"peerDependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
}
}
+1
View File
@@ -0,0 +1 @@
export * from './lib/dev.js';
+1
View File
@@ -0,0 +1 @@
export * from './lib/index.js';
+36
View File
@@ -0,0 +1,36 @@
import { join } from 'node:path';
import fg from 'fast-glob';
/**
* Discovers page entry files matching a glob pattern and maps them to Rsbuild entry names.
*
* Entry names follow the file path relative to `src/app/`, with the `.tsx` extension
* stripped. For example, `./src/app/articles/index.tsx` becomes `articles/index`,
* which Rsbuild maps to `dist/articles/index.html`.
*
* @param root - Absolute path to the service root directory (use `import.meta.dirname`).
* @param pattern - Glob pattern relative to `root`, e.g. `'./src/app/**\/*.tsx'`.
* @returns A record of entry name to absolute file path suitable for `source.entry`.
*
* @example
* ```typescript
* source: {
* entry: discoverEntries(import.meta.dirname, './src/app/**\/*.tsx'),
* }
* ```
*/
export function discoverEntries(root: string, pattern: string): Record<string, string> {
const files = fg.sync(pattern, { cwd: root });
const entries: Record<string, string> = {};
for (const file of files) {
const parts = file.split('/');
const filename = parts[parts.length - 1].replace(/\.tsx$/, '');
const appMatch = /src\/app\/(.*)\.tsx$/.exec(file);
const name = appMatch ? appMatch[1] : filename;
entries[name] = join(root, file);
}
return entries;
}
@@ -3,6 +3,7 @@ import { createRequire } from 'node:module';
import path from 'node:path';
import React from 'react';
import type { RsbuildPlugin } from '@rsbuild/core';
/**
@@ -14,6 +15,11 @@ import type { RsbuildPlugin } from '@rsbuild/core';
* 3. Injects the rendered HTML into the web build's HTML files
* 4. Runs critters to inline critical CSS and make the main stylesheet async
* 5. Cleans up dist-ssr/
*
* @example
* ```typescript
* ssgPlugin()
* ```
*/
export function ssgPlugin(): RsbuildPlugin {
return {
@@ -33,14 +39,14 @@ export function ssgPlugin(): RsbuildPlugin {
return;
}
// Normalise entries: value may be a string path or an EntryDescription object
const entries: Record<string, string> = {};
for (const [name, value] of Object.entries(rawEntries)) {
if (typeof value === 'string') {
entries[name] = value;
} else if (value && typeof value === 'object' && 'import' in value) {
} else if (typeof value === 'object' && 'import' in value) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const imp = (value as { import: string | string[] }).import;
entries[name] = Array.isArray(imp) ? imp[0] : imp;
entries[name] = Array.isArray(imp) ? (imp[0] ?? '') : imp;
}
}
@@ -50,27 +56,24 @@ export function ssgPlugin(): RsbuildPlugin {
return;
}
// api.context.distPath is the absolute path to the web build output directory.
// Fallback: derive from rootPath + output.distPath.root if the property is absent.
const webDistPath: string =
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
(api.context as { distPath?: string }).distPath ??
path.join(
api.context.rootPath,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
(config.output?.distPath as { root?: string } | undefined)?.root ?? 'dist'
);
const ssrDistPath = path.join(path.dirname(webDistPath), 'dist-ssr');
// ── Step 1: Build node-target CJS bundles ─────────────────────────────
console.log('[ssg-plugin] Building SSR bundles...');
const { createRsbuild } = await import('@rsbuild/core');
// Reuse web build plugins minus ssgPlugin (avoids recursion) and pluginTypeCheck
// (type-checking is not needed for the SSR build). This automatically includes
// pluginReact, pluginMdx, and any other service-level plugins.
const SSG_PLUGIN_NAME = 'ermnvldmr:ssg-plugin';
const TYPE_CHECK_PLUGIN_NAME = 'rsbuild:type-check';
const ssrPlugins = (config.plugins ?? []).filter((p) => {
if (!p || typeof p !== 'object') return true;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const name = (p as { name?: string }).name;
return name !== SSG_PLUGIN_NAME && name !== TYPE_CHECK_PLUGIN_NAME;
});
@@ -84,8 +87,6 @@ export function ssgPlugin(): RsbuildPlugin {
target: 'node',
distPath: { root: ssrDistPath, js: '.' },
filename: { js: '[name].cjs' },
// Externalize React so the SSR bundle shares the same React instance
// as react-dom/server, preventing "null dispatcher" hook errors.
externals: {
react: 'commonjs react',
'react-dom': 'commonjs react-dom',
@@ -99,12 +100,10 @@ export function ssgPlugin(): RsbuildPlugin {
await ssrBuild.build();
console.log('[ssg-plugin] SSR bundles ready.');
// ── Step 2: Render, inject, inline critical CSS ───────────────────────
const { renderToString } = await import('react-dom/server');
// createRequire from ESM context — needed to load CJS modules (critters, SSR bundles)
const _require = createRequire(import.meta.url);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const Critters = _require('critters') as new (opts?: {
path?: string;
publicPath?: string;
@@ -114,28 +113,27 @@ export function ssgPlugin(): RsbuildPlugin {
reduceInlineStyles?: boolean;
}) => { process(html: string): Promise<string> };
// critters `path` is the filesystem root that corresponds to publicPath.
// It resolves <link href="..."> to webDistPath. Use configured assetPrefix for locale builds.
const configuredAssetPrefix =
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
(config.output as { assetPrefix?: string } | undefined)?.assetPrefix ?? '/';
const critters = new Critters({
path: webDistPath,
publicPath: configuredAssetPrefix,
preload: 'swap', // <link rel="preload" as="style" onload="this.rel='stylesheet'">
inlineFonts: false, // fonts are handled via separate <link rel="preload"> tags
pruneSource: false, // keep full CSS file for post-hydration correctness
reduceInlineStyles: false, // preserve theme init styles (prevents .dark rule from being stripped)
preload: 'swap',
inlineFonts: false,
pruneSource: false,
reduceInlineStyles: false,
});
for (const entryName of entryNames) {
const ssrBundlePath = path.join(ssrDistPath, `${entryName}.cjs`);
const webHtmlPath = path.join(webDistPath, `${entryName}.html`);
// Clear module cache so each entry loads fresh
const resolved = _require.resolve(ssrBundlePath);
delete _require.cache[resolved];
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const mod = _require(ssrBundlePath) as { default?: React.ComponentType };
const Component = mod.default;
@@ -144,12 +142,10 @@ export function ssgPlugin(): RsbuildPlugin {
continue;
}
// Render to HTML string
const renderedHtml = renderToString(
React.createElement(React.StrictMode, null, React.createElement(Component))
);
// Read web HTML, inject rendered content
const source = await fs.readFile(webHtmlPath, 'utf8');
const injected = source.replace(
/<div id="root"[^>]*>\s*<\/div>/,
@@ -162,10 +158,8 @@ export function ssgPlugin(): RsbuildPlugin {
);
}
// Run critters: inline critical CSS, make main stylesheet async
const processed = await critters.process(injected);
// Set <html lang> to the current build locale
const locale = process.env.LOCALE ?? 'en';
const localized = processed.replace(
/<html([^>]*)\slang="[^"]*"/,
@@ -176,7 +170,6 @@ export function ssgPlugin(): RsbuildPlugin {
console.log(`[ssg-plugin] ✓ ${entryName}`);
}
// ── Step 3: Cleanup ───────────────────────────────────────────────────
await fs.rm(ssrDistPath, { recursive: true, force: true });
console.log('[ssg-plugin] SSG complete. dist-ssr cleaned up.');
});
@@ -0,0 +1,26 @@
import { defineServiceConfig, mergeConfig } from '@ermnvldmr/rsbuild-config/dev';
import { type RsbuildConfig } from '@rsbuild/core';
import { ssgPlugin } from './ssg.js';
/**
* Defines a complete Rsbuild configuration for SSG services.
*
* Wraps `defineServiceConfig` from `@ermnvldmr/rsbuild-config` and pre-injects
* `ssgPlugin()` so consumers do not need to wire it manually.
*
* @param config - Optional Rsbuild configuration overrides.
* @returns A complete Rsbuild configuration object.
*
* @example
* ```typescript
* import { defineSSGServiceConfig, discoverEntries } from '@ermnvldmr/ssg/dev';
*
* export default defineSSGServiceConfig({
* source: { entry: discoverEntries(import.meta.dirname, './src/app/**\/*.tsx') },
* });
* ```
*/
export function defineSSGServiceConfig(config: RsbuildConfig = {}): RsbuildConfig {
return defineServiceConfig(mergeConfig(config, { plugins: [ssgPlugin()] }));
}
@@ -1,20 +1,26 @@
import React from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';
import '../../static/global.css';
/**
* Options for page creation
* Options for page creation.
*/
export interface PageOptions {
/** Document title to set on mount. */
title?: string;
/** Content of the meta description tag. */
description?: string;
}
/**
* Creates a React page component with hydration support.
* Mounts or hydrates a React component into the `#root` element.
*
* Skips DOM mounting during the SSG server-side render pass
* (`typeof window === 'undefined'`). On the client, hydrates if the
* root already has children (SSG output), otherwise performs a fresh
* `createRoot` render.
*
* @param Component - The React component to render.
* @param options - Page options like title.
* @param options - Optional page metadata (title, description).
*
* @example
* ```tsx
+4
View File
@@ -0,0 +1,4 @@
export * from './config/ssg.js';
export * from './config/entries.js';
export * from './config/ssgServiceConfig.js';
export { mergeConfig } from '@ermnvldmr/rsbuild-config/dev';
+1
View File
@@ -0,0 +1 @@
export * from './core/createPage.js';
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"outDir": "./dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"composite": true,
"declarationMap": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+4 -1
View File
@@ -9,7 +9,10 @@ const rbFontSizeClasses = RB_TYPES.flatMap((type) => RB_SIZES.map((size) => `${t
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
'font-size': [{ 'text-rb': rbFontSizeClasses }],
'font-size': [
{ 'text-rb': rbFontSizeClasses },
{ text: rbFontSizeClasses },
],
},
},
});
+1 -1
View File
@@ -30,6 +30,6 @@
"babel-plugin-istanbul": "^7.0.1",
"storybook-react-rsbuild": "^1.0.3",
"typescript": "^5.8.3",
"vite": "^8.0.5"
"vite": "^8.0.7"
}
}
+1 -1
View File
@@ -82,7 +82,7 @@
"http-server": "^14.1.1",
"jsdom": "^28.0.0",
"playwright": "^1.57.0",
"postcss": "^8.5.6",
"postcss": "^8.5.14",
"storybook": "^8.6.17",
"storybook-react-rsbuild": "^1.0.3",
"typescript": "^5.8.3",
@@ -0,0 +1,112 @@
import React from 'react';
import { Accordion } from './Accordion';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Accordion> = {
title: 'Components/Accordion',
component: Accordion,
tags: ['autodocs'],
argTypes: {
type: {
control: 'select',
options: ['single', 'multiple'],
},
collapsible: {
control: 'boolean',
},
},
};
export default meta;
type Story = StoryObj<typeof Accordion>;
export const Default: Story = {
args: {
type: 'single',
collapsible: true,
},
render: (args) => (
<Accordion {...args}>
<Accordion.Item value="item-1">
<Accordion.Trigger level={2}>Is it accessible?</Accordion.Trigger>
<Accordion.Content>
Yes. It adheres to the WAI-ARIA design pattern.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger level={2}>Is it styled?</Accordion.Trigger>
<Accordion.Content>
Yes. It comes with default styles that matches the design system.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-3">
<Accordion.Trigger level={2}>Is it animated?</Accordion.Trigger>
<Accordion.Content>
Yes. It uses CSS Grid transitions for smooth height animation.
</Accordion.Content>
</Accordion.Item>
</Accordion>
),
};
export const Multiple: Story = {
args: {
type: 'multiple',
},
render: (args) => (
<Accordion {...args}>
<Accordion.Item value="item-1">
<Accordion.Trigger level={3}>Section One</Accordion.Trigger>
<Accordion.Content>
This section can be open alongside others.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger level={3}>Section Two</Accordion.Trigger>
<Accordion.Content>
You can open as many sections as you want in multiple mode.
</Accordion.Content>
</Accordion.Item>
</Accordion>
),
};
export const DifferentHeaderLevels: Story = {
render: () => (
<Accordion type="single">
<Accordion.Item value="h1">
<Accordion.Trigger level={1}>Header Level 1</Accordion.Trigger>
<Accordion.Content>Content for H1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="h2">
<Accordion.Trigger level={2}>Header Level 2</Accordion.Trigger>
<Accordion.Content>Content for H2</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="h3">
<Accordion.Trigger level={3}>Header Level 3</Accordion.Trigger>
<Accordion.Content>Content for H3</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="h4">
<Accordion.Trigger level={4}>Header Level 4</Accordion.Trigger>
<Accordion.Content>Content for H4</Accordion.Content>
</Accordion.Item>
</Accordion>
),
};
export const Disabled: Story = {
render: () => (
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger level={3}>Enabled Item</Accordion.Trigger>
<Accordion.Content>This one works fine.</Accordion.Content>
</Accordion.Item>
<Accordion.Item disabled value="item-2">
<Accordion.Trigger level={3}>Disabled Item</Accordion.Trigger>
<Accordion.Content>You should not see this.</Accordion.Content>
</Accordion.Item>
</Accordion>
),
};
@@ -0,0 +1,221 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { vi } from 'vitest';
import { Accordion } from './Accordion';
describe('Accordion', () => {
it('renders correctly with all sub-components', () => {
render(
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger level={2}>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
expect(screen.getByText('Trigger 1')).toBeInTheDocument();
expect(screen.getByText('Content 1')).toBeInTheDocument();
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false');
});
it('toggles content on click in single mode', async () => {
const user = userEvent.setup();
render(
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger>Trigger 2</Accordion.Trigger>
<Accordion.Content>Content 2</Accordion.Content>
</Accordion.Item>
</Accordion>
);
const trigger1 = screen.getByText('Trigger 1');
const trigger2 = screen.getByText('Trigger 2');
await user.click(trigger1);
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'false');
await user.click(trigger2);
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'true');
});
it('allows multiple items to be open in multiple mode', async () => {
const user = userEvent.setup();
render(
<Accordion type="multiple">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger>Trigger 2</Accordion.Trigger>
<Accordion.Content>Content 2</Accordion.Content>
</Accordion.Item>
</Accordion>
);
await user.click(screen.getByText('Trigger 1'));
await user.click(screen.getByText('Trigger 2'));
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'true');
});
it('respects the collapsible prop in single mode', async () => {
const user = userEvent.setup();
render(
<Accordion collapsible={false} type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
const trigger = screen.getByText('Trigger 1');
// Open it
await user.click(trigger);
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
// Try to close it (should stay open because collapsible={false})
await user.click(trigger);
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
});
it('does not toggle when disabled', async () => {
const user = userEvent.setup();
render(
<Accordion type="single">
<Accordion.Item disabled value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
const trigger = screen.getByText('Trigger 1');
await user.click(trigger);
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('button')).toBeDisabled();
});
it('works as a controlled component', async () => {
const user = userEvent.setup();
const TestComponent = () => {
const [value, setValue] = React.useState<string[]>(['item-1']);
return (
<Accordion type="single" value={value} onValueChange={setValue}>
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger>Trigger 2</Accordion.Trigger>
<Accordion.Content>Content 2</Accordion.Content>
</Accordion.Item>
</Accordion>
);
};
render(<TestComponent />);
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'true');
await user.click(screen.getByText('Trigger 2'));
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'true');
});
it('works as an uncontrolled component with defaultValue', () => {
render(
<Accordion defaultValue={['item-2']} type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
<Accordion.Trigger>Trigger 2</Accordion.Trigger>
<Accordion.Content>Content 2</Accordion.Content>
</Accordion.Item>
</Accordion>
);
expect(screen.getByRole('button', { name: /Trigger 1/i })).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('button', { name: /Trigger 2/i })).toHaveAttribute('aria-expanded', 'true');
});
it('renders with valid HTML structure (heading containing button)', () => {
render(
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger level={3}>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
const heading = screen.getByRole('heading', { level: 3 });
const button = screen.getByRole('button');
expect(heading).toContainElement(button);
});
it('applies accessibility attributes to content when closed', () => {
render(
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
const content = screen.getByText('Content 1').closest('[role="region"]');
expect(content).toHaveAttribute('aria-hidden', 'true');
expect(content).toHaveAttribute('inert');
});
it('removes accessibility hiding attributes when open', async () => {
const user = userEvent.setup();
render(
<Accordion type="single">
<Accordion.Item value="item-1">
<Accordion.Trigger>Trigger 1</Accordion.Trigger>
<Accordion.Content>Content 1</Accordion.Content>
</Accordion.Item>
</Accordion>
);
await user.click(screen.getByText('Trigger 1'));
const content = screen.getByText('Content 1').closest('[role="region"]');
expect(content).toHaveAttribute('aria-hidden', 'false');
expect(content).not.toHaveAttribute('inert');
});
it('throws error when sub-components are used outside of Accordion', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<Accordion.Trigger>Trigger</Accordion.Trigger>)).toThrow(
'Accordion sub-components must be used within an Accordion component.'
);
expect(() => render(<Accordion.Item value="1">Item</Accordion.Item>)).toThrow(
'Accordion sub-components must be used within an Accordion component.'
);
expect(() => render(<Accordion.Content>Content</Accordion.Content>)).toThrow(
'AccordionItem sub-components must be used within an AccordionItem component.'
);
consoleSpy.mockRestore();
});
});
@@ -0,0 +1,95 @@
import { cn } from '@ermnvldmr/stl';
import React, { useState, useCallback, useMemo } from 'react';
import { AccordionContent } from './AccordionContent/AccordionContent';
import { AccordionItem } from './AccordionItem/AccordionItem';
import { AccordionTrigger } from './AccordionTrigger/AccordionTrigger';
import { AccordionContext } from './contexts/AccordionContext/AccordionContext';
import type { AccordionProps } from './types';
/**
* An Accordion component that allows users to reveal and hide content sections.
*
* Built with a compound component pattern for maximum flexibility.
* @example Basic usage:
*/
const AccordionRoot = ({
type = 'single',
value: controlledValue,
defaultValue = [],
onValueChange,
collapsible = true,
children,
className,
'data-testid': testId,
}: AccordionProps) => {
const [internalValue, setInternalValue] = useState<string[]>(defaultValue);
const isControlled = controlledValue !== undefined;
const currentValues = isControlled ? controlledValue : internalValue;
const handleValueChange = useCallback(
(itemValue: string) => {
let nextValues: string[];
if (type === 'single') {
const isCurrentOpen = currentValues.includes(itemValue);
if (isCurrentOpen && collapsible) {
nextValues = [];
} else if (isCurrentOpen && !collapsible) {
nextValues = [itemValue];
} else {
nextValues = [itemValue];
}
} else {
nextValues = currentValues.includes(itemValue)
? currentValues.filter((v) => v !== itemValue)
: [...currentValues, itemValue];
}
if (!isControlled) {
setInternalValue(nextValues);
}
onValueChange?.(nextValues);
},
[type, currentValues, collapsible, isControlled, onValueChange]
);
const contextValue = useMemo(
() => ({
type,
value: currentValues,
onValueChange: handleValueChange,
collapsible,
}),
[type, currentValues, handleValueChange, collapsible]
);
return (
<AccordionContext.Provider value={contextValue}>
<div className={cn('w-full', className)} data-testid={testId}>
{children}
</div>
</AccordionContext.Provider>
);
};
/**
* A collapsible accordion component system.
*
* @example
* ```tsx
* <Accordion type="single" collapsible>
* <Accordion.Item value="item-1">
* <Accordion.Trigger level={2}>Is it accessible?</Accordion.Trigger>
* <Accordion.Content>Yes. It adheres to the WAI-ARIA design pattern.</Accordion.Content>
* </Accordion.Item>
* </Accordion>
* ```
*/
export const Accordion = Object.assign(AccordionRoot, {
Item: AccordionItem,
Trigger: AccordionTrigger,
Content: AccordionContent,
});
@@ -0,0 +1,46 @@
import { cn } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React from 'react';
import { useAccordionItemContext } from '../contexts/AccordionItemContext/AccordionItemContext';
import type { AccordionContentProps } from '../types';
const contentVariants = cva('grid transition-[grid-template-rows] duration-200 ease-out', {
variants: {
isOpen: {
true: 'grid-rows-[1fr]',
false: 'grid-rows-[0fr]',
},
},
defaultVariants: {
isOpen: false,
},
});
/**
* The collapsible content of an Accordion item.
*
* Uses a CSS Grid transition for smooth height animation.
* @example Basic usage:
*/
export const AccordionContent = ({ children, className }: AccordionContentProps) => {
const { value, isOpen } = useAccordionItemContext();
return (
<div
aria-hidden={!isOpen}
aria-labelledby={`accordion-trigger-${value}`}
className={cn(contentVariants({ isOpen }), className)}
id={`accordion-content-${value}`}
inert={!isOpen ? true : undefined}
role="region"
>
<div className="overflow-hidden">
<div className="pb-4 pt-0 text-sm leading-relaxed">
{children}
</div>
</div>
</div>
);
};
@@ -0,0 +1,40 @@
import { cn } from '@ermnvldmr/stl';
import React, { useMemo } from 'react';
import { useAccordionContext } from '../contexts/AccordionContext/AccordionContext';
import { AccordionItemContext } from '../contexts/AccordionItemContext/AccordionItemContext';
import type { AccordionItemProps } from '../types';
/**
* An individual item within an Accordion.
*
* Provides context for the Trigger and Content sub-components.
* @example Basic usage:
*/
export const AccordionItem = ({
value,
children,
disabled = false,
className,
}: AccordionItemProps) => {
const { value: openValues } = useAccordionContext();
const isOpen = openValues.includes(value);
const contextValue = useMemo(
() => ({
value,
isOpen,
disabled,
}),
[value, isOpen, disabled]
);
return (
<AccordionItemContext.Provider value={contextValue}>
<div className={cn('border-b border-[var(--rb-outline)]/40', className)}>
{children}
</div>
</AccordionItemContext.Provider>
);
};
@@ -0,0 +1,95 @@
import { cn } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import { ChevronRight } from 'lucide-react';
import React, { useRef } from 'react';
import { useButton } from 'react-aria';
import { Header } from '../../Header/Header';
import { HStack } from '../../HStack/HStack';
import { useAccordionContext } from '../contexts/AccordionContext/AccordionContext';
import { useAccordionItemContext } from '../contexts/AccordionItemContext/AccordionItemContext';
import type { AccordionTriggerProps } from '../types';
const triggerVariants = cva(
'group flex w-full items-center justify-between py-4 text-left transition-all outline-none focus-visible:ring-2 focus-visible:ring-[var(--rb-ring)] focus-visible:ring-offset-2',
{
variants: {
isDisabled: {
true: 'cursor-not-allowed opacity-50',
false: '',
},
},
defaultVariants: {
isDisabled: false,
},
}
);
const iconVariants = cva(
'size-5 shrink-0 transition-transform duration-200 text-[var(--rb-outline)]',
{
variants: {
isOpen: {
true: 'rotate-90',
false: '',
},
},
defaultVariants: {
isOpen: false,
},
}
);
/**
* The interactive trigger for an Accordion item.
*
* Wraps the Header component and adds an expansion arrow.
* @example Basic usage:
* ```tsx
* <Accordion.Trigger>Click me</Accordion.Trigger>
* ```
*/
export const AccordionTrigger = ({
children,
className,
level = 3,
...headerProps
}: AccordionTriggerProps) => {
const { onValueChange } = useAccordionContext();
const { value, isOpen, disabled: isDisabled } = useAccordionItemContext();
const ref = useRef<HTMLButtonElement>(null);
const handleToggle = () => {
if (!isDisabled) {
onValueChange(value);
}
};
const { buttonProps } = useButton(
{
onPress: handleToggle,
isDisabled,
'aria-controls': `accordion-content-${value}`,
'aria-expanded': isOpen,
id: `accordion-trigger-${value}`,
type: 'button',
},
ref
);
return (
<Header level={level} {...headerProps}>
<button
{...buttonProps}
ref={ref}
className={cn(triggerVariants({ isDisabled }), className)}
>
<HStack align="center" className="w-full" gap={2}>
<ChevronRight className={cn(iconVariants({ isOpen }))} />
<span className="flex-1">{children}</span>
</HStack>
</button>
</Header>
);
};
@@ -0,0 +1,20 @@
import { createContext, useContext } from 'react';
import type { AccordionContextValue } from '../../types';
/**
* Context for sharing accordion state with items.
*/
export const AccordionContext = createContext<AccordionContextValue | null>(null);
/**
* Hook to access the Accordion context.
* @example Using the context:
*/
export function useAccordionContext(): AccordionContextValue {
const context = useContext(AccordionContext);
if (!context) {
throw new Error('Accordion sub-components must be used within an Accordion component.');
}
return context;
}
@@ -0,0 +1,20 @@
import { createContext, useContext } from 'react';
import type { AccordionItemContextValue } from '../../types';
/**
* Context for sharing specific accordion item state.
*/
export const AccordionItemContext = createContext<AccordionItemContextValue | null>(null);
/**
* Hook to access the AccordionItem context.
* @example Using the context:
*/
export function useAccordionItemContext(): AccordionItemContextValue {
const context = useContext(AccordionItemContext);
if (!context) {
throw new Error('AccordionItem sub-components must be used within an AccordionItem component.');
}
return context;
}
@@ -0,0 +1,9 @@
export * from './Accordion';
export * from './types';
export * from './contexts/AccordionContext/AccordionContext';
export * from './contexts/AccordionItemContext/AccordionItemContext';
// Export sub-components individually for flexibility
export * from './AccordionItem/AccordionItem';
export * from './AccordionTrigger/AccordionTrigger';
export * from './AccordionContent/AccordionContent';
@@ -0,0 +1,88 @@
import type { HeaderProps } from '../Header/Header';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
import type { ReactNode } from 'react';
/**
* Type of accordion behavior.
* - 'single': Only one item can be open at a time.
* - 'multiple': Multiple items can be open at a time.
*/
export type AccordionType = 'single' | 'multiple';
/**
* Context value for the root Accordion component.
*/
export interface AccordionContextValue {
/** Type of accordion behavior */
type: AccordionType;
/** Currently open values */
value: string[];
/** Callback when open values change */
onValueChange: (value: string) => void;
/** Whether the accordion is collapsible in single mode */
collapsible?: boolean;
}
/**
* Context value for an individual Accordion item.
*/
export interface AccordionItemContextValue {
/** Unique value for this item */
value: string;
/** Whether this item is currently open */
isOpen: boolean;
/** Whether this item is disabled */
disabled?: boolean;
}
/**
* Props for the root Accordion component.
*/
export interface AccordionProps extends ClassNameProps, TestIdProps {
/** Content of the accordion */
children: ReactNode;
/** Type of accordion behavior */
type?: AccordionType;
/** Controlled value of open items */
value?: string[];
/** Default value of open items (uncontrolled) */
defaultValue?: string[];
/** Callback when open values change */
onValueChange?: (value: string[]) => void;
/** Whether the accordion is collapsible in single mode */
collapsible?: boolean;
}
/**
* Props for the AccordionItem component.
*/
export interface AccordionItemProps extends ClassNameProps {
/** Unique value for this item */
value: string;
/** Content of the item (Trigger and Content) */
children: ReactNode;
/** Whether the item is disabled */
disabled?: boolean;
}
/**
* Props for the AccordionTrigger component.
*/
export interface AccordionTriggerProps extends ClassNameProps, Omit<HeaderProps, 'children' | 'level'> {
/** Content of the trigger (usually text) */
children: ReactNode;
/**
* The semantic header level (1-6).
* @default 3
*/
level?: 1 | 2 | 3 | 4 | 5 | 6;
}
/**
* Props for the AccordionContent component.
*/
export interface AccordionContentProps extends ClassNameProps {
/** Content to be revealed */
children: ReactNode;
}
@@ -0,0 +1,291 @@
import { Star, Code, Layout, Send, Zap, Shield, Cpu, Gamepad2, Plane, BookOpen, Check, Gift } from 'lucide-react';
import React from 'react';
import { BentoGrid } from './BentoGrid';
import { Header } from '../Header/Header';
import { Text } from '../Text/Text';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof BentoGrid> = {
title: 'Components/BentoGrid',
component: BentoGrid,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof BentoGrid>;
export const Showcase: Story = {
render: () => (
<div className="w-full max-w-6xl p-4">
<BentoGrid>
<BentoGrid.ImageCard
alt="Developer workstation"
colSpan={2}
overlayDescription="A deep dive into modern web architecture and performance optimization."
overlayTitle="Featured Project"
rowSpan={2}
src="https://images.unsplash.com/photo-1498050108023-c5249f4df085?auto=format&fit=crop&q=80&w=2072"
/>
<BentoGrid.InfoCard
description="Built on top of Rsbuild and Rspack for near-instant compilation and ultra-fast HMR."
icon={<Zap className="text-orange-500" size={24} />}
title="Lightning Fast"
/>
<BentoGrid.CTACard
buttonText="Get Started"
description="Connect with other developers and share your projects."
href="#"
title="Join the Community"
variant="primary"
/>
<BentoGrid.InfoCard
description="Strict TypeScript integration across the entire monorepo for maximum reliability."
footer={<div className="text-xs opacity-50">Strict Mode Enabled</div>}
icon={<Shield className="text-green-500" size={24} />}
title="Type Safe"
/>
<BentoGrid.ImageCard
alt="Code on screen"
colSpan={2}
overlayTitle="Clean Code"
src="https://images.unsplash.com/photo-1461749280684-dccba630e2f6?auto=format&fit=crop&q=80&w=2069"
/>
<BentoGrid.InfoCard
description="Easily extend and customize the design system with our modular component library."
icon={<Cpu className="text-blue-500" size={24} />}
title="Modular Architecture"
/>
<BentoGrid.CTACard
buttonText="View Docs"
description="Read the full documentation to learn more about the design system."
href="#"
title="Documentation"
/>
<BentoGrid.BaseCard className="bg-tertiary/10 border-dashed" colSpan={4}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 bg-tertiary rounded-full text-tertiary-text">
<Send size={20} />
</div>
<div>
<h4 className="font-bold text-text">Subscribe to Newsletter</h4>
<p className="text-sm text-muted-text">Stay updated with the latest releases and technical articles.</p>
</div>
</div>
<button className="px-6 py-2 bg-text text-[var(--rb-base)] rounded-md font-medium">Subscribe</button>
</div>
</BentoGrid.BaseCard>
</BentoGrid>
</div>
),
};
export const LayoutPatterns: Story = {
render: () => (
<div className="w-full max-w-6xl p-4 flex flex-col gap-12">
<section>
<h3 className="mb-4 text-xl font-bold">Standard 4-Column Grid</h3>
<BentoGrid>
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="h-32 flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
</BentoGrid>
</section>
<section>
<h3 className="mb-4 text-xl font-bold">Mixed Spans (Hero Pattern)</h3>
<BentoGrid>
<BentoGrid.BaseCard className="flex items-center justify-center bg-primary/20 border-primary" colSpan={2} rowSpan={2}>2x2 Hero</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100" colSpan={2}>2x1 Wide</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
</BentoGrid>
</section>
<section>
<h3 className="mb-4 text-xl font-bold">Tall & Wide Pattern</h3>
<BentoGrid>
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-secondary/20 border-secondary" colSpan={2}>2x1 Wide</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-tertiary/20 border-tertiary" rowSpan={2}>1x2 Tall</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100">1x1</BentoGrid.BaseCard>
<BentoGrid.BaseCard className="flex items-center justify-center bg-neutral-100" colSpan={2}>2x1</BentoGrid.BaseCard>
</BentoGrid>
</section>
</div>
),
};
export const AllCardTypes: Story = {
render: () => (
<div className="w-full max-w-6xl p-4">
<BentoGrid>
<BentoGrid.InfoCard
description="Used for features or information. Can have an icon and a footer."
footer={<div className="text-xs font-mono">v1.0.0</div>}
icon={<Layout size={24} />}
title="Info Card"
/>
<BentoGrid.ImageCard
alt="Abstract image"
overlayDescription="Perfect for visual showcases with text overlays."
overlayTitle="Image Card"
src="https://images.unsplash.com/photo-1481487196290-c152efe083f5?auto=format&fit=crop&q=80&w=2048"
/>
<BentoGrid.CTACard
buttonText="Learn More"
description="Drives action with an outline button."
href="#"
title="CTA Card (Default)"
/>
<BentoGrid.CTACard
buttonText="Sign Up Now"
description="High prominence with a solid background."
href="#"
title="CTA Card (Primary)"
variant="primary"
/>
<BentoGrid.ListCard
colSpan={2}
defaultValue={['features']}
description="A specialized card for categorized lists, ideal for wishlists or feature matrices."
icon={<div className="p-2 bg-blue-100 text-blue-700 rounded-lg"><Star size={20} /></div>}
sections={[
{
value: 'features',
label: 'Features',
labelIcon: <Check size={18} />,
labelClassName: 'text-green-600',
items: ['Responsive', 'Accessible', 'Themeable'],
}
]}
title="List Card"
/>
<BentoGrid.BaseCard className="flex flex-col gap-4">
<div className="p-2 bg-yellow-100 text-yellow-900 rounded-md w-fit">
<Code size={20} />
</div>
<h4 className="font-bold">Base Card (Custom)</h4>
<p className="text-sm">You can put any children here to create custom card types while maintaining the bento border and padding.</p>
</BentoGrid.BaseCard>
</BentoGrid>
</div>
),
};
export const WishlistVariant: Story = {
render: () => (
<div className="w-full max-w-6xl p-4">
<div className="mb-8">
<Header level={2}>My Wishlist</Header>
<Text className="text-muted-text" size="l">Things I want and things I already have, organized by category.</Text>
</div>
<BentoGrid>
<BentoGrid.ListCard
colSpan={2}
defaultValue={['wanted']}
description="My essential list of gadgets and hardware I use for daily productivity and entertainment."
icon={
<div className="p-3 bg-blue-100 text-blue-700 rounded-xl">
<Gamepad2 size={24} />
</div>
}
rowSpan={2}
sections={[
{
value: 'wanted',
label: 'Wanted',
labelIcon: <Gift size={18} />,
labelClassName: 'text-orange-600',
items: [
'Steam Deck OLED 1TB',
'Sony A7C II with 35mm lens',
'Keychron Mechanical Keyboard'
]
},
{
value: 'owned',
label: 'Already Owned',
labelIcon: <Check size={18} />,
labelClassName: 'text-green-600',
items: [
'MacBook Pro M3 Max',
'Sony WH-1000XM5 Headphones',
'LG 27" 4K Monitor'
]
}
]}
title="Gadgets & Tech"
/>
<BentoGrid.ListCard
colSpan={2}
description="Places I want to visit and explore around the world."
icon={
<div className="p-3 bg-green-100 text-green-700 rounded-xl">
<Plane size={24} />
</div>
}
sections={[
{
value: 'wanted',
label: 'Bucket List',
labelIcon: <Gift size={18} />,
labelClassName: 'text-orange-600',
items: [
'Kyoto, Japan (Cherry Blossom Season)',
'Reykjavik, Iceland',
'Swiss Alps'
]
},
{
value: 'owned',
label: 'Visited',
labelIcon: <Check size={18} />,
labelClassName: 'text-green-600',
items: [
'Paris, France',
'Rome, Italy',
'London, UK'
]
}
]}
title="Travel Destinations"
type="single"
/>
<BentoGrid.ListCard
colSpan={2}
description="My reading backlog spanning technical books, sci-fi, and fantasy."
icon={
<div className="p-3 bg-violet-100 text-violet-700 rounded-xl">
<BookOpen size={24} />
</div>
}
sections={[
{
value: 'wanted',
label: 'Reading List',
labelIcon: <Gift size={18} />,
labelClassName: 'text-orange-600',
items: [
'Designing Data-Intensive Applications',
'The Pragmatic Programmer',
'Dune Messiah'
]
}
]}
title="Books to Read"
type="single"
/>
</BentoGrid>
</div>
),
};
@@ -0,0 +1,121 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { BentoGrid } from './BentoGrid';
describe('BentoGrid', () => {
it('renders all card types correctly', () => {
render(
<BentoGrid>
<BentoGrid.BaseCard data-testid="base-card">Base Card Content</BentoGrid.BaseCard>
<BentoGrid.ImageCard
alt="Test Image"
overlayDescription="Image Description"
overlayTitle="Image Title"
src="test-image.jpg"
/>
<BentoGrid.InfoCard
description="Info Description"
footer={<span>Footer Content</span>}
title="Info Title"
/>
<BentoGrid.CTACard
buttonText="Click Me"
description="CTA Description"
href="/test"
title="CTA Title"
/>
<BentoGrid.ListCard
description="List Description"
sections={[
{
value: 's1',
label: 'Section 1',
items: ['Item A', 'Item B']
}
]}
title="List Title"
/>
</BentoGrid>
);
// BaseCard
expect(screen.getByTestId('base-card')).toBeInTheDocument();
expect(screen.getByText('Base Card Content')).toBeInTheDocument();
// ImageCard
expect(screen.getByAltText('Test Image')).toBeInTheDocument();
expect(screen.getByText('Image Title')).toBeInTheDocument();
expect(screen.getByText('Image Description')).toBeInTheDocument();
// InfoCard
expect(screen.getByText('Info Title')).toBeInTheDocument();
expect(screen.getByText('Info Description')).toBeInTheDocument();
expect(screen.getByText('Footer Content')).toBeInTheDocument();
// CTACard
expect(screen.getByText('CTA Title')).toBeInTheDocument();
expect(screen.getByText('CTA Description')).toBeInTheDocument();
expect(screen.getByText('Click Me')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Click Me/i })).toHaveAttribute('href', '/test');
// ListCard
expect(screen.getByText('List Title')).toBeInTheDocument();
expect(screen.getByText('List Description')).toBeInTheDocument();
expect(screen.getByText('Section 1')).toBeInTheDocument();
expect(screen.getByText('Item A')).toBeInTheDocument();
});
it('renders ListCard with object items (stable keys)', () => {
render(
<BentoGrid.ListCard
sections={[
{
value: 's1',
label: 'Section 1',
items: [
{ key: 'item-1', node: 'Stable Item 1' },
'Raw Item 2'
]
}
]}
title="List Title"
/>
);
expect(screen.getByText('Stable Item 1')).toBeInTheDocument();
expect(screen.getByText('Raw Item 2')).toBeInTheDocument();
});
it('applies span classes correctly', () => {
render(
<BentoGrid>
<BentoGrid.BaseCard colSpan={2} rowSpan={2}>
Spanning Card
</BentoGrid.BaseCard>
</BentoGrid>
);
const card = screen.getByText('Spanning Card');
expect(card).toHaveClass('md:col-span-2');
expect(card).toHaveClass('md:row-span-2');
});
it('applies primary variant to CTACard', () => {
render(
<BentoGrid>
<BentoGrid.CTACard
buttonText="Button"
description="Description"
href="/"
title="Primary CTA"
variant="primary"
/>
</BentoGrid>
);
const title = screen.getByText('Primary CTA');
const card = title.closest('.bg-primary');
expect(card).toBeInTheDocument();
});
});
@@ -0,0 +1,56 @@
import { cn, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
import { BentoGridBaseCard } from './BentoGridBaseCard';
import { BentoGridCTACard } from './BentoGridCTACard';
import { BentoGridImageCard } from './BentoGridImageCard';
import { BentoGridInfoCard } from './BentoGridInfoCard';
import { BentoGridListCard } from './BentoGridListCard';
import type { BentoGridProps } from './types';
const BentoGridComponent = forwardRef<HTMLDivElement, BentoGridProps>(function BentoGrid(
{ children, className, ...props },
ref
) {
return (
<div
{...props}
ref={ref}
className={cn(
'grid w-full grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-4',
className
)}
>
{children}
</div>
);
});
/**
* A responsive Bento Grid system for creating structured, masonry-like layouts.
*
* @example
* ```tsx
* <BentoGrid>
* <BentoGrid.ImageCard
* colSpan={2}
* rowSpan={2}
* src="/image.jpg"
* alt="Showcase"
* overlayTitle="Featured"
* />
* <BentoGrid.InfoCard
* title="Details"
* description="Key features of the project."
* />
* </BentoGrid>
* ```
*/
export const BentoGrid = Object.assign(genericMemo(BentoGridComponent), {
BaseCard: BentoGridBaseCard,
ImageCard: BentoGridImageCard,
InfoCard: BentoGridInfoCard,
CTACard: BentoGridCTACard,
ListCard: BentoGridListCard,
});
@@ -0,0 +1,55 @@
import { cn } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React from 'react';
import type { BentoCardBaseProps } from './types';
const baseCardVariants = cva(
'relative overflow-hidden rounded-xl border border-outline bg-base p-6 shadow-sm transition-all hover:shadow-md',
{
variants: {
colSpan: {
1: 'md:col-span-1',
2: 'md:col-span-2',
3: 'md:col-span-3',
4: 'md:col-span-4',
},
rowSpan: {
1: 'md:row-span-1',
2: 'md:row-span-2',
3: 'md:row-span-3',
},
},
defaultVariants: {
colSpan: 1,
rowSpan: 1,
},
}
);
/**
* Base card component that provides the foundational styling for all Bento cards.
* @example Basic usage:
*/
export const BentoGridBaseCard = ({
children,
className,
colSpan,
rowSpan,
href,
...props
}: BentoCardBaseProps) => {
// Render as an anchor when `href` is provided so the whole card is a link,
// otherwise a plain div. Styling and span variants are identical either way.
const Component: React.ElementType = href != null ? 'a' : 'div';
return (
<Component
className={cn(baseCardVariants({ colSpan, rowSpan }), href != null && 'block', className)}
href={href}
{...props}
>
{children}
</Component>
);
};
@@ -0,0 +1,111 @@
import { cn } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React from 'react';
import { BentoGridBaseCard } from './BentoGridBaseCard';
import { Button } from '../Button/Button';
import { Header } from '../Header/Header';
import { Text } from '../Text/Text';
import { VStack } from '../VStack/VStack';
import type { BentoCardBaseProps } from './types';
const cardVariants = cva('', {
variants: {
variant: {
default: '',
primary: 'bg-primary border-primary',
},
},
defaultVariants: {
variant: 'default',
},
});
const headerVariants = cva('', {
variants: {
variant: {
default: '',
primary: 'text-primary-text',
},
},
defaultVariants: {
variant: 'default',
},
});
const descriptionVariants = cva('', {
variants: {
variant: {
default: 'text-muted-text',
primary: 'text-primary-text/80',
},
},
defaultVariants: {
variant: 'default',
},
});
/**
*
*/
export interface BentoGridCTACardProps extends BentoCardBaseProps {
/** The title of the call to action */
title: string;
/** The description text */
description: string;
/** The text to display on the action button */
buttonText: string;
/** The destination URL for the action */
href: string;
/**
* Visual variant for the card.
* 'default' uses standard card styling.
* 'primary' uses a more prominent background.
*/
variant?: 'default' | 'primary';
}
/**
* A Bento card designed specifically to drive user action.
* @example Basic usage:
*/
export const BentoGridCTACard = ({
title,
description,
buttonText,
href,
variant = 'default',
className,
...baseProps
}: BentoGridCTACardProps) => {
return (
<BentoGridBaseCard
{...baseProps}
className={cn(cardVariants({ variant }), className)}
>
<VStack className="h-full justify-between" gap={4}>
<VStack gap={2}>
<Header className={cn(headerVariants({ variant }))} level={3}>
{title}
</Header>
<Text
className={cn(descriptionVariants({ variant }))}
size="m"
>
{description}
</Text>
</VStack>
<div className="mt-auto">
<Button
className="w-full sm:w-auto"
href={href}
variant={variant === 'primary' ? 'solid' : 'outline'}
>
{buttonText}
</Button>
</div>
</VStack>
</BentoGridBaseCard>
);
};
@@ -0,0 +1,70 @@
import { cn } from '@ermnvldmr/stl';
import React from 'react';
import { BentoGridBaseCard } from './BentoGridBaseCard';
import { Header } from '../Header/Header';
import { Image } from '../Image/Image';
import { Text } from '../Text/Text';
import { VStack } from '../VStack/VStack';
import type { BentoCardBaseProps } from './types';
/**
*
*/
export interface BentoGridImageCardProps extends BentoCardBaseProps {
/** Source URL of the image */
src: string;
/** Alt text for the image */
alt: string;
/** Optional title to overlay on the image */
overlayTitle?: string;
/** Optional description to overlay on the image */
overlayDescription?: string;
}
/**
* A Bento card designed to showcase an image with optional textual overlays.
* @example Basic usage:
*/
export const BentoGridImageCard = ({
src,
alt,
overlayTitle,
overlayDescription,
className,
...baseProps
}: BentoGridImageCardProps) => {
const hasOverlay = !!overlayTitle || !!overlayDescription;
return (
<BentoGridBaseCard {...baseProps} className={cn('p-0', className)}>
<div className="relative h-full w-full">
<Image
alt={alt}
className="h-full w-full object-cover"
height="100%"
ratio="auto"
src={src}
/>
{hasOverlay && (
<div className="absolute inset-0 flex flex-col justify-end bg-linear-to-t from-black/80 via-black/40 to-transparent p-6 text-white">
<VStack gap={1}>
{overlayTitle && (
<Header className="text-white" level={3}>
{overlayTitle}
</Header>
)}
{overlayDescription && (
<Text className="text-white/90" size="s">
{overlayDescription}
</Text>
)}
</VStack>
</div>
)}
</div>
</BentoGridBaseCard>
);
};
@@ -0,0 +1,47 @@
import React from 'react';
import { BentoGridBaseCard } from './BentoGridBaseCard';
import { Header } from '../Header/Header';
import { Text } from '../Text/Text';
import { VStack } from '../VStack/VStack';
import type { BentoCardBaseProps } from './types';
/**
*
*/
export interface BentoGridInfoCardProps extends BentoCardBaseProps {
/** The title of the card */
title: string;
/** The description text */
description?: string;
/** Optional icon or graphic to display at the top */
icon?: React.ReactNode;
/** Optional footer content */
footer?: React.ReactNode;
}
/**
* A versatile Bento card for text-heavy content or feature highlights.
* @example Basic usage:
*/
export const BentoGridInfoCard = ({
title,
description,
icon,
footer,
...baseProps
}: BentoGridInfoCardProps) => {
return (
<BentoGridBaseCard {...baseProps}>
<VStack className="h-full justify-between" gap={4}>
<VStack gap={2}>
{icon && <div className="text-primary-text">{icon}</div>}
<Header level={3}>{title}</Header>
{description && <Text className="text-muted-text" size="m">{description}</Text>}
</VStack>
{footer && <div className="mt-auto w-full">{footer}</div>}
</VStack>
</BentoGridBaseCard>
);
};
@@ -0,0 +1,126 @@
import { cn } from '@ermnvldmr/stl';
import React from 'react';
import { Accordion } from '../Accordion';
import { Header } from '../Header/Header';
import { HStack } from '../HStack/HStack';
import { List } from '../List';
import { BentoGridBaseCard } from './BentoGridBaseCard';
import { Text } from '../Text/Text';
import { VStack } from '../VStack/VStack';
import type { BentoCardBaseProps } from './types';
/**
*
*/
export interface BentoGridListCardSection {
/** Unique identifier for the accordion section */
value: string;
/** Label text for the accordion trigger */
label: string;
/** Optional icon next to the label */
labelIcon?: React.ReactNode;
/** Optional class name for the label text color (e.g. 'text-rb-orange-600') */
labelClassName?: string;
/** List of items to display when expanded. Can be raw nodes or objects with keys. */
items: (React.ReactNode | { key: string; node: React.ReactNode })[];
}
/**
*
*/
export interface BentoGridListCardProps extends BentoCardBaseProps {
/** Title of the card */
title: string;
/** Description text */
description?: string;
/** Optional icon next to the title. For colored backgrounds, wrap the icon in a styled div. */
icon?: React.ReactNode;
/** Sections containing lists of items, rendered as Accordions */
sections: BentoGridListCardSection[];
/** Which sections are expanded by default (uses section value) */
defaultValue?: string[];
/** Whether multiple sections can be open at once */
type?: 'single' | 'multiple';
}
/**
* A Bento card designed for categorized lists of items using Accordions.
*
* @example
* ```tsx
* <BentoGrid.ListCard
* title="My List"
* sections={[
* { value: 's1', label: 'Section 1', items: ['Item 1', 'Item 2'] }
* ]}
* />
* ```
*/
export const BentoGridListCard = ({
title,
description,
icon,
sections,
defaultValue,
type = 'multiple',
...baseProps
}: BentoGridListCardProps) => {
return (
<BentoGridBaseCard {...baseProps} className={cn('flex flex-col', baseProps.className)}>
<VStack className="h-full" gap={4}>
<HStack align="center" gap={3}>
{icon && <div>{icon}</div>}
<Header level={3}>{title}</Header>
</HStack>
{description && (
<Text className="text-muted-text">
{description}
</Text>
)}
<div className="flex-grow w-full">
<Accordion collapsible defaultValue={defaultValue} type={type}>
{sections.map((section) => (
<Accordion.Item key={section.value} value={section.value}>
<Accordion.Trigger className={section.labelClassName}>
<HStack align="center" gap={2}>
{section.labelIcon}
<Text size="l" type="body">{section.label}</Text>
</HStack>
</Accordion.Trigger>
<Accordion.Content>
<List>
{section.items.map((item, index) => {
const content = (() => {
if (item && typeof item === 'object' && 'node' in item) {
return item.node;
}
return item;
})();
const itemKey =
item && typeof item === 'object' && 'key' in item
? item.key
: typeof item === 'string'
? item
: index;
return (
<List.Item key={itemKey}>
<Text size="m">{content}</Text>
</List.Item>
);
})}
</List>
</Accordion.Content>
</Accordion.Item>
))}
</Accordion>
</div>
</VStack>
</BentoGridBaseCard>
);
};
@@ -0,0 +1,7 @@
export * from './BentoGrid';
export * from './types';
export * from './BentoGridBaseCard';
export * from './BentoGridImageCard';
export * from './BentoGridInfoCard';
export * from './BentoGridCTACard';
export * from './BentoGridListCard';
@@ -0,0 +1,47 @@
import type React from 'react';
/**
* Common props for all BentoGrid card variations.
*
* Typed against `HTMLElement` since a card can render as either a `div` or,
* when `href` is provided, an `a`.
*/
export interface BentoCardBaseProps extends React.HTMLAttributes<HTMLElement> {
/**
* Number of columns to span on medium screens and up.
* @default 1
*/
colSpan?: 1 | 2 | 3 | 4;
/**
* Number of rows to span on medium screens and up.
* @default 1
*/
rowSpan?: 1 | 2 | 3;
/**
* Additional CSS classes.
*/
className?: string;
/**
* The content of the card.
*/
children?: React.ReactNode;
/**
* If provided, the card renders as an anchor (`<a>`) so the entire card is a
* navigable link. Card styling and colSpan/rowSpan are preserved.
*/
href?: string;
}
/**
* Props for the root BentoGrid component.
*/
export interface BentoGridProps {
/**
* The cards to display in the grid.
*/
children: React.ReactNode;
/**
* Additional CSS classes.
*/
className?: string;
}
@@ -1,4 +1,5 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React, { forwardRef } from 'react';
import { Text } from '../Text/Text';
@@ -18,6 +19,28 @@ export interface BlockquoteProps
indent?: boolean;
}
const blockquoteVariants = cva('border-l-4 border-[var(--rb-primary)]', {
variants: {
variant: {
default: '',
inset: 'bg-[var(--rb-surface-variant)] p-6 rounded-r-lg',
},
indent: {
true: 'pl-6',
false: '',
},
gutterBottom: {
true: 'mb-6',
false: '',
},
},
defaultVariants: {
variant: 'default',
indent: true,
gutterBottom: false,
},
});
const BlockquoteComponent = forwardRef<HTMLQuoteElement, BlockquoteProps>(function Blockquote(
{
children,
@@ -34,13 +57,7 @@ const BlockquoteComponent = forwardRef<HTMLQuoteElement, BlockquoteProps>(functi
<blockquote
{...props}
ref={ref}
className={cn(
'border-l-4 border-[var(--rb-primary)]',
indent && 'pl-6',
gutterBottom && 'mb-6',
variant === 'inset' && 'bg-[var(--rb-surface-variant)] p-6 rounded-r-lg',
className
)}
className={cn(blockquoteVariants({ variant, indent, gutterBottom }), className)}
data-testid={testId}
>
{children}
@@ -1,5 +1,6 @@
import { localePath } from '@ermnvldmr/i18n';
import { cn } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import { ChevronRight } from 'lucide-react';
import React, { memo } from 'react';
@@ -46,6 +47,36 @@ export interface BreadcrumbsItemProps extends TestIdProps {
onClick?: React.MouseEventHandler<HTMLElement>;
}
const itemVariants = cva('group flex items-center transition-all duration-200', {
variants: {
isCurrent: {
true: '',
false: '',
},
isClickable: {
true: '',
false: '',
},
},
compoundVariants: [{ isCurrent: false, isClickable: true, className: 'cursor-pointer' }],
defaultVariants: {
isCurrent: false,
isClickable: false,
},
});
const itemTextVariants = cva('transition-all duration-200', {
variants: {
isCurrent: {
true: '',
false: 'group-hover:text-[var(--rb-text)] group-hover:underline underline-offset-4',
},
},
defaultVariants: {
isCurrent: false,
},
});
/**
* Individual step in the breadcrumb trail.
*/
@@ -63,19 +94,12 @@ const BreadcrumbsItem = memo(function BreadcrumbsItem({
return (
<li
aria-current={isCurrent ? 'page' : undefined}
className={cn(
'group flex items-center transition-all duration-200',
!isCurrent && isClickable && 'cursor-pointer',
className
)}
className={cn(itemVariants({ isCurrent, isClickable }), className)}
data-testid={testId}
>
<Text
as={resolvedHref ? 'a' : onClick ? 'button' : 'span'}
className={cn(
'transition-all duration-200',
!isCurrent && 'group-hover:text-[var(--rb-text)] group-hover:underline underline-offset-4'
)}
className={cn(itemTextVariants({ isCurrent }))}
color={isCurrent ? 'default' : 'muted'}
href={resolvedHref}
size="m"
@@ -79,4 +79,16 @@ describe('components/Button', () => {
render(<Button fullWidth>Full Width</Button>);
expect(screen.getByRole('button')).toHaveClass('w-full');
});
it('applies correct variant and color classes', () => {
const { rerender } = render(<Button color="primary" variant="solid">Primary Solid</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-primary');
expect(screen.getByRole('button')).toHaveClass('text-primary-text');
rerender(<Button color="secondary" variant="outline">Secondary Outline</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-transparent');
expect(screen.getByRole('button')).toHaveClass('border-secondary');
expect(screen.getByRole('button')).toHaveClass('text-secondary');
});
});
+106 -50
View File
@@ -1,4 +1,5 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React, { forwardRef, useRef } from 'react';
import { useButton, useLink, useFocusRing, useHover } from 'react-aria';
@@ -77,6 +78,109 @@ interface LinkButtonProps
*/
export type ButtonProps = ActionButtonProps | LinkButtonProps;
const buttonVariants = cva(
'inline-flex items-center justify-center font-medium transition-all duration-200 select-none outline-none relative overflow-hidden cursor-pointer active:scale-[0.98]',
{
variants: {
variant: {
solid: '',
outline: '',
ghost: '',
link: '',
},
color: {
primary: '',
'primary-negative': '',
secondary: '',
'secondary-negative': '',
tertiary: '',
'tertiary-negative': '',
error: '',
'error-negative': '',
neutral: '',
'neutral-negative': '',
},
size: {
s: 'h-8 px-3 text-sm gap-1.5',
m: 'h-10 px-4 text-base gap-2',
l: 'h-12 px-6 text-lg gap-3',
},
rounded: {
none: 'rounded-none',
md: 'rounded-md',
full: 'rounded-full',
},
fullWidth: {
true: 'w-full',
false: '',
},
isDisabled: {
true: 'opacity-50 cursor-not-allowed grayscale-[0.5] active:scale-100',
false: '',
},
},
compoundVariants: [
// Primary
{ variant: 'solid', color: 'primary', className: 'bg-primary text-primary-text' },
{ variant: 'solid', color: 'primary-negative', className: 'bg-primary-text text-primary' },
{ variant: 'outline', color: 'primary', className: 'bg-transparent border-2 text-primary border-primary hover:bg-primary/10' },
{ variant: 'outline', color: 'primary-negative', className: 'bg-transparent border-2 text-primary-text border-primary-text hover:bg-primary-text/10' },
{ variant: 'ghost', color: 'primary', className: 'bg-transparent border-transparent text-primary hover:bg-primary/10' },
{ variant: 'ghost', color: 'primary-negative', className: 'bg-transparent border-transparent text-primary-text hover:bg-primary-text/10' },
{ variant: 'link', color: 'primary', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-primary' },
{ variant: 'link', color: 'primary-negative', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-primary-text' },
// Secondary
{ variant: 'solid', color: 'secondary', className: 'bg-secondary text-secondary-text' },
{ variant: 'solid', color: 'secondary-negative', className: 'bg-secondary-text text-secondary' },
{ variant: 'outline', color: 'secondary', className: 'bg-transparent border-2 text-secondary border-secondary hover:bg-secondary/10' },
{ variant: 'outline', color: 'secondary-negative', className: 'bg-transparent border-2 text-secondary-text border-secondary-text hover:bg-secondary-text/10' },
{ variant: 'ghost', color: 'secondary', className: 'bg-transparent border-transparent text-secondary hover:bg-secondary/10' },
{ variant: 'ghost', color: 'secondary-negative', className: 'bg-transparent border-transparent text-secondary-text hover:bg-secondary-text/10' },
{ variant: 'link', color: 'secondary', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-secondary' },
{ variant: 'link', color: 'secondary-negative', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-secondary-text' },
// Tertiary
{ variant: 'solid', color: 'tertiary', className: 'bg-tertiary text-tertiary-text' },
{ variant: 'solid', color: 'tertiary-negative', className: 'bg-tertiary-text text-tertiary' },
{ variant: 'outline', color: 'tertiary', className: 'bg-transparent border-2 text-tertiary border-tertiary hover:bg-tertiary/10' },
{ variant: 'outline', color: 'tertiary-negative', className: 'bg-transparent border-2 text-tertiary-text border-tertiary-text hover:bg-tertiary-text/10' },
{ variant: 'ghost', color: 'tertiary', className: 'bg-transparent border-transparent text-tertiary hover:bg-tertiary/10' },
{ variant: 'ghost', color: 'tertiary-negative', className: 'bg-transparent border-transparent text-tertiary-text hover:bg-tertiary-text/10' },
{ variant: 'link', color: 'tertiary', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-tertiary' },
{ variant: 'link', color: 'tertiary-negative', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-tertiary-text' },
// Error
{ variant: 'solid', color: 'error', className: 'bg-error text-error-text' },
{ variant: 'solid', color: 'error-negative', className: 'bg-error-text text-error' },
{ variant: 'outline', color: 'error', className: 'bg-transparent border-2 text-error border-error hover:bg-error/10' },
{ variant: 'outline', color: 'error-negative', className: 'bg-transparent border-2 text-error-text border-error-text hover:bg-error-text/10' },
{ variant: 'ghost', color: 'error', className: 'bg-transparent border-transparent text-error hover:bg-error/10' },
{ variant: 'ghost', color: 'error-negative', className: 'bg-transparent border-transparent text-error-text hover:bg-error-text/10' },
{ variant: 'link', color: 'error', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-error' },
{ variant: 'link', color: 'error-negative', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-error-text' },
// Neutral
{ variant: 'solid', color: 'neutral', className: 'bg-muted text-[var(--rb-text)]' },
{ variant: 'solid', color: 'neutral-negative', className: 'bg-[var(--rb-text)] text-muted' },
{ variant: 'outline', color: 'neutral', className: 'bg-transparent border-2 text-muted border-muted hover:bg-muted/10' },
{ variant: 'outline', color: 'neutral-negative', className: 'bg-transparent border-2 text-[var(--rb-text)] border-[var(--rb-text)] hover:bg-[var(--rb-text)]/10' },
{ variant: 'ghost', color: 'neutral', className: 'bg-transparent border-transparent text-muted hover:bg-muted/10' },
{ variant: 'ghost', color: 'neutral-negative', className: 'bg-transparent border-transparent text-[var(--rb-text)] hover:bg-[var(--rb-text)]/10' },
{ variant: 'link', color: 'neutral', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-muted' },
{ variant: 'link', color: 'neutral-negative', className: 'bg-transparent border-transparent underline-offset-4 hover:underline px-0 h-auto text-[var(--rb-text)]' },
],
defaultVariants: {
variant: 'solid',
color: 'primary',
size: 'm',
rounded: 'md',
fullWidth: false,
isDisabled: false,
},
}
);
/**
* A highly extensible, polymorphic button component.
*
@@ -95,7 +199,7 @@ const ButtonComponent = forwardRef<HTMLElement, ButtonProps>(function Button(pro
rounded = 'md',
className,
'data-testid': testId,
isDisabled,
isDisabled = false,
href,
} = props;
@@ -124,62 +228,14 @@ const ButtonComponent = forwardRef<HTMLElement, ButtonProps>(function Button(pro
const { hoverProps, isHovered } = useHover({ isDisabled });
// Styles mapping
const sizeClasses: Record<ButtonSize, string> = {
s: 'h-8 px-3 text-sm gap-1.5',
m: 'h-10 px-4 text-base gap-2',
l: 'h-12 px-6 text-lg gap-3',
};
const iconClasses: Record<ButtonSize, string> = {
s: 'w-4 h-4',
m: 'w-5 h-5',
l: 'w-6 h-6',
};
const roundedClasses: Record<string, string> = {
none: 'rounded-none',
md: 'rounded-md',
full: 'rounded-full',
};
const getVariantClasses = (): string => {
const isNegative = color.endsWith('-negative');
const baseColor = color.replace('-negative', '');
const colors: Record<string, { base: string; text: string }> = {
primary: { base: 'var(--rb-primary-base)', text: 'var(--rb-primary-text)' },
secondary: { base: 'var(--rb-secondary-base)', text: 'var(--rb-secondary-text)' },
tertiary: { base: 'var(--rb-tertiary-base)', text: 'var(--rb-tertiary-text)' },
error: { base: 'var(--rb-error-base)', text: 'var(--rb-error-text)' },
neutral: { base: 'var(--rb-muted-base)', text: 'var(--rb-text)' },
};
const colorValues = colors[baseColor];
const backgroundColor = isNegative ? colorValues.text : colorValues.base;
const foregroundColor = isNegative ? colorValues.base : colorValues.text;
switch (variant) {
case 'solid':
return `bg-[${backgroundColor}] text-[${foregroundColor}] border-transparent`;
case 'outline':
return `bg-transparent text-[${backgroundColor}] border-[${backgroundColor}] border-2`;
case 'ghost':
return `bg-transparent text-[${backgroundColor}] border-transparent hover:bg-[${backgroundColor}]/10`;
case 'link':
return `bg-transparent text-[${backgroundColor}] border-transparent underline-offset-4 hover:underline px-0 h-auto`;
default:
return '';
}
};
const sharedClasses = cn(
'inline-flex items-center justify-center font-medium transition-all duration-200 select-none outline-none relative overflow-hidden cursor-pointer',
'active:scale-[0.98]',
sizeClasses[size],
roundedClasses[rounded],
getVariantClasses(),
fullWidth && 'w-full',
isDisabled && 'opacity-50 cursor-not-allowed grayscale-[0.5] active:scale-100',
buttonVariants({ variant, color, size, rounded, fullWidth, isDisabled }),
isFocusVisible && 'ring-2 ring-[var(--rb-ring)] ring-offset-2',
isHovered && !isDisabled && 'brightness-90 dark:brightness-110',
className
+43 -52
View File
@@ -1,4 +1,5 @@
import { cn } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import { usePress } from 'react-aria';
@@ -10,6 +11,45 @@ import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
export type { CodeLanguage };
const highlightedVariants = cva('block min-w-full font-mono text-sm [&>pre]:!bg-transparent [&>pre]:!p-0 [&>pre]:!m-0', {
variants: {
showLineNumbers: {
true: '[&>pre>code]:[counter-reset:line] [&>pre>code>.line]:relative [&>pre>code>.line]:pl-10 [&>pre>code>.line]:[counter-increment:line] [&>pre>code>.line]:before:content-[counter(line)] [&>pre>code>.line]:before:absolute [&>pre>code>.line]:before:left-0 [&>pre>code>.line]:before:top-0 [&>pre>code>.line]:before:inline-block [&>pre>code>.line]:before:w-8 [&>pre>code>.line]:before:text-right [&>pre>code>.line]:before:text-[var(--rb-muted-text)]/50 [&>pre>code>.line]:before:select-none [&>pre>code>.line]:before:border-r [&>pre>code>.line]:before:border-[var(--rb-outline)]/10 [&>pre>code>.line]:before:pr-3 [&>pre>code>.line]:before:font-mono [&>pre>code>.line]:before:text-[10px]',
false: '',
},
},
defaultVariants: {
showLineNumbers: false,
},
});
const blockVariants = cva('block min-w-full font-mono whitespace-pre', {
variants: {
showLineNumbers: {
true: '[counter-reset:line] [&>span]:relative [&>span]:pl-10 [&>span]:[counter-increment:line] [&>span]:before:content-[counter(line)] [&>span]:before:absolute [&>span]:before:left-0 [&>span]:before:top-[4px] [&>span]:before:inline-block [&>span]:before:w-8 [&>span]:before:text-right [&>span]:before:text-[var(--rb-muted-text)]/50 [&>span]:before:select-none [&>span]:before:border-r [&>span]:before:border-[var(--rb-outline)]/10 [&>span]:before:pr-3 [&>span]:before:font-mono [&>span]:before:text-[10px]',
false: '',
},
},
defaultVariants: {
showLineNumbers: false,
},
});
const inlineVariants = cva(
'relative inline-block rounded-sm px-1.5 py-0.5 text-sm font-mono text-[var(--rb-color-red-600)] dark:text-[var(--rb-color-red-400)] cursor-pointer transition-all duration-200 hover:text-[var(--rb-color-red-500)] hover:bg-[var(--rb-color-red-500)]/10 active:scale-95',
{
variants: {
isCopied: {
true: 'bg-[var(--rb-color-red-500)]/20 ring-1 ring-[var(--rb-color-red-500)]/40',
false: 'bg-transparent ring-1 ring-transparent',
},
},
defaultVariants: {
isCopied: false,
},
}
);
/**
* Props for the Code component.
*/
@@ -116,27 +156,7 @@ export const Code = memo(function Code({
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
ref={divRef}
className={cn(
'block min-w-full font-mono text-sm',
'[&>pre]:!bg-transparent [&>pre]:!p-0 [&>pre]:!m-0',
showLineNumbers && [
'[&>pre>code]:[counter-reset:line]',
'[&>pre>code>.line]:relative [&>pre>code>.line]:pl-10',
'[&>pre>code>.line]:[counter-increment:line]',
'[&>pre>code>.line]:before:content-[counter(line)]',
'[&>pre>code>.line]:before:absolute [&>pre>code>.line]:before:left-0 [&>pre>code>.line]:before:top-0',
'[&>pre>code>.line]:before:inline-block',
'[&>pre>code>.line]:before:w-8',
'[&>pre>code>.line]:before:text-right',
'[&>pre>code>.line]:before:text-[var(--rb-muted-text)]/50',
'[&>pre>code>.line]:before:select-none',
'[&>pre>code>.line]:before:border-r',
'[&>pre>code>.line]:before:border-[var(--rb-outline)]/10',
'[&>pre>code>.line]:before:pr-3',
'[&>pre>code>.line]:before:font-mono [&>pre>code>.line]:before:text-[10px]',
],
className
)}
className={cn(highlightedVariants({ showLineNumbers }), className)}
data-testid={testId}
/>
);
@@ -157,26 +177,7 @@ export const Code = memo(function Code({
return (
<code
ref={codeRef}
className={cn(
'block min-w-full font-mono whitespace-pre',
showLineNumbers && [
'[counter-reset:line]',
'[&>span]:relative [&>span]:pl-10',
'[&>span]:[counter-increment:line]',
'[&>span]:before:content-[counter(line)]',
'[&>span]:before:absolute [&>span]:before:left-0 [&>span]:before:top-[4px]',
'[&>span]:before:inline-block',
'[&>span]:before:w-8',
'[&>span]:before:text-right',
'[&>span]:before:text-[var(--rb-muted-text)]/50',
'[&>span]:before:select-none',
'[&>span]:before:border-r',
'[&>span]:before:border-[var(--rb-outline)]/10',
'[&>span]:before:pr-3',
'[&>span]:before:font-mono [&>span]:before:text-[10px]',
],
className
)}
className={cn(blockVariants({ showLineNumbers }), className)}
data-testid={testId}
>
{content}
@@ -189,17 +190,7 @@ export const Code = memo(function Code({
{...pressProps}
ref={codeRef}
aria-label="Copy code snippet"
className={cn(
'relative inline-block rounded-sm px-1.5 py-0.5 text-sm font-mono',
'text-[var(--rb-color-red-600)] dark:text-[var(--rb-color-red-400)]',
'cursor-pointer transition-all duration-200',
'hover:text-[var(--rb-color-red-500)] hover:bg-[var(--rb-color-red-500)]/10',
'active:scale-95',
isCopied
? 'bg-[var(--rb-color-red-500)]/20 ring-1 ring-[var(--rb-color-red-500)]/40'
: 'bg-transparent ring-1 ring-transparent',
className
)}
className={cn(inlineVariants({ isCopied }), className)}
data-testid={testId}
role="button"
tabIndex={0}
@@ -1,4 +1,5 @@
import { cn } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import { Check, Copy } from 'lucide-react';
import React, { memo, useCallback, useEffect, useRef, useState, useMemo } from 'react';
@@ -9,6 +10,36 @@ import { CodeContext, type CodeContextValue } from '../contexts/CodeContext/Code
import type { CodeLanguage } from '../lib/highlighter/highlighter';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
const copyButtonVariants = cva(
'h-7 w-7 p-0 bg-[var(--rb-container-base)]/80 backdrop-blur-sm transition-opacity',
{
variants: {
isCopied: {
true: '',
false: 'opacity-0 group-hover:opacity-100 focus:opacity-100',
},
},
defaultVariants: {
isCopied: false,
},
}
);
const preVariants = cva(
'overflow-auto p-4 text-sm font-mono leading-relaxed select-text whitespace-pre',
{
variants: {
hasLabel: {
true: 'pt-8',
false: '',
},
},
defaultVariants: {
hasLabel: false,
},
}
);
/**
* Props for the CodeBlock component.
*/
@@ -122,10 +153,7 @@ export const CodeBlock = memo(function CodeBlock({
<div className="absolute top-2 right-2 pointer-events-auto">
<Button
aria-label="Copy code"
className={cn(
'h-7 w-7 p-0 bg-[var(--rb-container-base)]/80 backdrop-blur-sm transition-opacity',
!isCopied && 'opacity-0 group-hover:opacity-100 focus:opacity-100'
)}
className={cn(copyButtonVariants({ isCopied }))}
color="neutral"
size="s"
variant="ghost"
@@ -143,10 +171,7 @@ export const CodeBlock = memo(function CodeBlock({
{/* Code Area */}
<pre
ref={preRef}
className={cn(
'overflow-auto p-4 text-sm font-mono leading-relaxed select-text whitespace-pre',
label && 'pt-8'
)}
className={cn(preVariants({ hasLabel: !!label }))}
style={{ maxHeight: maxHeight }}
>
<CodeContext.Provider value={contextValue}>{children}</CodeContext.Provider>
@@ -1,49 +1,12 @@
import { cn } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React, { memo } from 'react';
import { usePress } from 'react-aria';
import type { SpacingScale } from '../../lib/scale';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
import type { PressEvents } from 'react-aria';
/**
* Tailwind gap spacing scale values.
*/
type GapScale =
| 0
| 0.5
| 1
| 1.5
| 2
| 2.5
| 3
| 3.5
| 4
| 5
| 6
| 7
| 8
| 9
| 10
| 11
| 12
| 14
| 16
| 20
| 24
| 28
| 32
| 36
| 40
| 44
| 48
| 52
| 56
| 60
| 64
| 72
| 80
| 96;
/**
* Background variants for the Container.
*/
@@ -75,7 +38,7 @@ export interface ContainerProps extends ClassNameProps, TestIdProps {
/** Background color variant */
bg?: ContainerBackground;
/** Padding using Tailwind spacing scale */
padding?: GapScale;
padding?: SpacingScale;
/** Maximum width constraint */
maxWidth?: ContainerMaxWidth;
/** Border radius variant */
@@ -98,6 +61,91 @@ export interface ContainerProps extends ClassNameProps, TestIdProps {
as?: React.ElementType;
}
const containerVariants = cva('overflow-hidden', {
variants: {
bg: {
base: 'bg-[var(--rb-container-base)] text-[var(--rb-container-text)]',
primary: 'bg-[var(--rb-primary-base)] text-[var(--rb-primary-text)]',
secondary: 'bg-[var(--rb-secondary-base)] text-[var(--rb-secondary-text)]',
tertiary: 'bg-[var(--rb-tertiary-base)] text-[var(--rb-tertiary-text)]',
error: 'bg-[var(--rb-error-base)] text-[var(--rb-error-text)]',
muted: 'bg-[var(--rb-muted-base)] text-[var(--rb-muted-text)]',
transparent: 'bg-transparent',
},
maxWidth: {
sm: 'max-w-screen-sm',
md: 'max-w-screen-md',
lg: 'max-w-screen-lg',
xl: 'max-w-screen-xl',
none: 'max-w-none',
},
rounded: {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full',
},
padding: {
0: 'p-0',
0.5: 'p-0.5',
1: 'p-1',
1.5: 'p-1.5',
2: 'p-2',
2.5: 'p-2.5',
3: 'p-3',
3.5: 'p-3.5',
4: 'p-4',
5: 'p-5',
6: 'p-6',
7: 'p-7',
8: 'p-8',
9: 'p-9',
10: 'p-10',
11: 'p-11',
12: 'p-12',
14: 'p-14',
16: 'p-16',
20: 'p-20',
24: 'p-24',
28: 'p-28',
32: 'p-32',
36: 'p-36',
40: 'p-40',
44: 'p-44',
48: 'p-48',
52: 'p-52',
56: 'p-56',
60: 'p-60',
64: 'p-64',
72: 'p-72',
80: 'p-80',
96: 'p-96',
},
shadow: {
true: 'shadow-md',
false: '',
},
border: {
true: 'border border-[var(--rb-outline)]',
false: '',
},
interactive: {
true: 'cursor-pointer transition-all duration-200 hover:brightness-95 active:opacity-80',
false: '',
},
},
defaultVariants: {
bg: 'base',
maxWidth: 'none',
rounded: 'none',
padding: 0,
shadow: false,
border: false,
interactive: false,
},
});
/**
* A generic surface component for creating structural layouts.
*
@@ -134,83 +182,20 @@ export const Container = memo(function Container({
const interactionProps = isPressable ? pressProps : {};
const FinalComponent = isLink ? 'a' : Component;
const bgClasses: Record<ContainerBackground, string> = {
base: 'bg-[var(--rb-container-base)] text-[var(--rb-container-text)]',
primary: 'bg-[var(--rb-primary-base)] text-[var(--rb-primary-text)]',
secondary: 'bg-[var(--rb-secondary-base)] text-[var(--rb-secondary-text)]',
tertiary: 'bg-[var(--rb-tertiary-base)] text-[var(--rb-tertiary-text)]',
error: 'bg-[var(--rb-error-base)] text-[var(--rb-error-text)]',
muted: 'bg-[var(--rb-muted-base)] text-[var(--rb-muted-text)]',
transparent: 'bg-transparent',
};
const maxWidthClasses: Record<ContainerMaxWidth, string> = {
sm: 'max-w-screen-sm',
md: 'max-w-screen-md',
lg: 'max-w-screen-lg',
xl: 'max-w-screen-xl',
none: 'max-w-none',
};
const roundedClasses: Record<ContainerRounded, string> = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full',
};
const paddingClasses: Record<GapScale, string> = {
0: 'p-0',
0.5: 'p-0.5',
1: 'p-1',
1.5: 'p-1.5',
2: 'p-2',
2.5: 'p-2.5',
3: 'p-3',
3.5: 'p-3.5',
4: 'p-4',
5: 'p-5',
6: 'p-6',
7: 'p-7',
8: 'p-8',
9: 'p-9',
10: 'p-10',
11: 'p-11',
12: 'p-12',
14: 'p-14',
16: 'p-16',
20: 'p-20',
24: 'p-24',
28: 'p-28',
32: 'p-32',
36: 'p-36',
40: 'p-40',
44: 'p-44',
48: 'p-48',
52: 'p-52',
56: 'p-56',
60: 'p-60',
64: 'p-64',
72: 'p-72',
80: 'p-80',
96: 'p-96',
};
return (
<FinalComponent
{...interactionProps}
ref={ref}
className={cn(
'overflow-hidden',
bgClasses[bg],
maxWidthClasses[maxWidth],
roundedClasses[rounded],
paddingClasses[padding],
shadow && 'shadow-md',
border && 'border border-[var(--rb-outline)]',
(isPressable || isLink) &&
'cursor-pointer transition-all duration-200 hover:brightness-95 active:opacity-80',
containerVariants({
bg,
maxWidth,
rounded,
padding,
shadow,
border,
interactive: isPressable || isLink,
}),
className
)}
data-testid={testId}
+38 -28
View File
@@ -1,4 +1,5 @@
import { cn } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React, { useState } from 'react';
import { Stub } from '../Stub/Stub';
@@ -38,6 +39,41 @@ interface ImageBaseProps {
*/
export type ImageProps = ImageBaseProps & ImageDimensions;
const imageWrapperVariants = cva('relative overflow-hidden', {
variants: {
rounded: {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full',
},
shadow: {
none: '',
sm: 'shadow-sm',
md: 'shadow-md',
lg: 'shadow-lg',
},
},
defaultVariants: {
rounded: 'none',
shadow: 'none',
},
});
const imageVariants = cva('w-full h-full transition-opacity duration-300', {
variants: {
objectFit: {
cover: 'object-cover',
contain: 'object-contain',
fill: 'object-fill',
},
},
defaultVariants: {
objectFit: 'cover',
},
});
/**
* A performance-optimized Image component with built-in CLS prevention,
* loading states, and Rainby design system styles.
@@ -52,21 +88,6 @@ export const Image: React.FC<ImageProps> = (props) => {
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
const roundedClasses = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full',
};
const shadowClasses = {
none: '',
sm: 'shadow-sm',
md: 'shadow-md',
lg: 'shadow-lg',
};
const wrapperStyle: React.CSSProperties = {
width: 'width' in props ? props.width : undefined,
height: 'height' in props ? props.height : undefined,
@@ -75,12 +96,7 @@ export const Image: React.FC<ImageProps> = (props) => {
return (
<div
className={cn(
'relative overflow-hidden',
roundedClasses[rounded],
shadowClasses[shadow],
className
)}
className={cn(imageWrapperVariants({ rounded, shadow }), className)}
data-testid="image-wrapper"
style={wrapperStyle}
>
@@ -89,13 +105,7 @@ export const Image: React.FC<ImageProps> = (props) => {
)}
<img
alt={alt}
className={cn(
'w-full h-full transition-opacity duration-300',
objectFit === 'cover' && 'object-cover',
objectFit === 'contain' && 'object-contain',
objectFit === 'fill' && 'object-fill',
isLoaded ? 'opacity-100' : 'opacity-0'
)}
className={cn(imageVariants({ objectFit }), isLoaded ? 'opacity-100' : 'opacity-0')}
data-testid="image-element"
decoding="async"
loading="lazy"
+28 -19
View File
@@ -1,4 +1,5 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React, { forwardRef } from 'react';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
@@ -29,18 +30,32 @@ export interface ListProps extends ClassNameProps, TestIdProps, React.HTMLAttrib
as?: React.ElementType;
}
const spacingStyles: Record<ListSpacing, string> = {
none: 'gap-0',
s: 'gap-1',
m: 'gap-2',
l: 'gap-4',
};
const variantStyles: Record<ListVariant, string> = {
unordered: 'list-disc pl-5',
ordered: 'list-decimal pl-5',
plain: 'list-none pl-0',
};
const listVariants = cva('m-0 flex flex-col', {
variants: {
variant: {
unordered: 'list-disc pl-5',
ordered: 'list-decimal pl-5',
plain: 'list-none pl-0',
},
spacing: {
none: 'gap-0',
s: 'gap-1',
m: 'gap-2',
l: 'gap-4',
},
// Hide default markers if a global custom marker is provided.
hasCustomMarker: {
true: '',
false: '',
},
},
compoundVariants: [{ hasCustomMarker: true, className: 'list-none pl-0' }],
defaultVariants: {
variant: 'unordered',
spacing: 'none',
hasCustomMarker: false,
},
});
const ListContext = React.createContext<{ marker?: React.ReactNode }>({});
@@ -65,13 +80,7 @@ const ListComponent = forwardRef<HTMLElement, ListProps>(function List(
<Component
{...props}
ref={castRef<HTMLElement>(ref)}
className={cn(
'm-0 flex flex-col',
// Hide default markers if a global custom marker is provided
hasCustomMarker ? 'list-none pl-0' : variantStyles[variant],
spacingStyles[spacing],
className
)}
className={cn(listVariants({ variant, spacing, hasCustomMarker }), className)}
data-testid={testId}
>
{children}
@@ -1,4 +1,5 @@
import { cn } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React from 'react';
/**
@@ -30,6 +31,23 @@ export interface PageColumnProps {
as?: React.ElementType;
}
const pageColumnVariants = cva('w-full transition-all duration-200', {
variants: {
size: {
small: 'lg:w-[var(--rb-page-column-small,300px)] lg:flex-none',
full: 'flex-1',
},
sticky: {
true: 'lg:sticky lg:top-24 self-start',
false: '',
},
},
defaultVariants: {
size: 'full',
sticky: false,
},
});
/**
/**
* A single column unit within the PageColumns host.
@@ -49,18 +67,6 @@ export const PageColumn = ({
as: Component = 'div',
}: PageColumnProps) => {
return (
<Component
className={cn(
'w-full transition-all duration-200',
{
'lg:w-[var(--rb-page-column-small,300px)] lg:flex-none': size === 'small',
'flex-1': size === 'full',
'lg:sticky lg:top-24 self-start': sticky,
},
className
)}
>
{children}
</Component>
<Component className={cn(pageColumnVariants({ size, sticky }), className)}>{children}</Component>
);
};
@@ -1,4 +1,5 @@
import { cn } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React from 'react';
/**
@@ -18,6 +19,8 @@ export interface PageColumnsProps {
className?: string;
}
const pageColumnsVariants = cva('flex flex-col lg:flex-row w-full');
/**
/**
* Host component for a responsive column system.
@@ -34,18 +37,12 @@ export interface PageColumnsProps {
* ```
*/
export const PageColumns = ({ children, gap = 6, className }: PageColumnsProps) => {
// `gap` accepts either a Tailwind spacing-scale number (`gap-N`) or an
// arbitrary class string, so the value space is not enumerable as a cva
// variant. Compute the numeric class here and pass strings through verbatim.
const gapClass = typeof gap === 'number' ? `gap-${gap}` : gap;
return (
<div
className={cn(
'flex flex-col lg:flex-row w-full',
{
[`gap-${gap}`]: typeof gap === 'number',
[String(gap)]: typeof gap === 'string',
},
className
)}
>
{children}
</div>
<div className={cn(pageColumnsVariants(), gapClass, className)}>{children}</div>
);
};
@@ -1,4 +1,5 @@
import { cn } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React from 'react';
/**
@@ -57,6 +58,39 @@ export interface PageContainerProps {
as?: React.ElementType;
}
const pageContainerVariants = cva('w-full mx-auto', {
variants: {
width: {
slim: 'max-w-[var(--rb-page-width-slim,1100px)]',
default: 'max-w-[var(--rb-page-width-default,1600px)]',
wide: 'max-w-[var(--rb-page-width-wide,1920px)]',
full: 'max-w-full',
},
paddingX: {
none: 'px-0',
small: 'px-2 sm:px-4',
medium: 'px-4 sm:px-6 lg:px-8',
large: 'px-6 sm:px-8 lg:px-12',
},
paddingY: {
none: 'py-0',
small: 'py-4 sm:py-6',
medium: 'py-8 sm:py-12',
large: 'py-12 sm:py-24',
},
centerVertically: {
true: 'flex-1 flex flex-col justify-center',
false: '',
},
},
defaultVariants: {
width: 'default',
paddingX: 'medium',
paddingY: 'none',
centerVertically: false,
},
});
/**
/**
* Manages the horizontal and vertical constraints for page content.
@@ -79,37 +113,10 @@ export const PageContainer = ({
className,
as: Component = 'div',
}: PageContainerProps) => {
const widthClasses = {
slim: 'max-w-[var(--rb-page-width-slim,1100px)]',
default: 'max-w-[var(--rb-page-width-default,1600px)]',
wide: 'max-w-[var(--rb-page-width-wide,1920px)]',
full: 'max-w-full',
};
const pxClasses = {
none: 'px-0',
small: 'px-2 sm:px-4',
medium: 'px-4 sm:px-6 lg:px-8',
large: 'px-6 sm:px-8 lg:px-12',
};
const pyClasses = {
none: 'py-0',
small: 'py-4 sm:py-6',
medium: 'py-8 sm:py-12',
large: 'py-12 sm:py-24',
};
return (
<Component
className={cn(
'w-full mx-auto',
widthClasses[width],
pxClasses[paddingX],
pyClasses[paddingY],
{
'flex-1 flex flex-col justify-center': centerVertically,
},
pageContainerVariants({ width, paddingX, paddingY, centerVertically }),
className
)}
>
@@ -32,7 +32,21 @@ describe('PageHead', () => {
it('applies the correct classes for the strategy', () => {
render(<PageHead heading="Test" strategy="always-collapsed-fixed" />);
const header = screen.getByRole('banner');
expect(header).toHaveClass('sticky');
expect(header).toHaveClass('shadow-md');
expect(header).toHaveClass('fixed');
expect(header).not.toHaveClass('sticky');
});
it('reserves flow space below the out-of-flow bar for always-collapsed strategies', () => {
render(<PageHead heading="Test" strategy="always-collapsed-fixed" />);
const spacer = screen.getByRole('banner').nextElementSibling;
expect(spacer).not.toBeNull();
// The spacer is an invisible copy of the bar so it reserves the exact height.
expect(spacer).toHaveClass('invisible');
expect(spacer).toHaveAttribute('aria-hidden', 'true');
});
it('does not add a spacer for in-flow strategies', () => {
render(<PageHead heading="Test" strategy="collapsible-sticky" />);
expect(screen.getByRole('banner').nextElementSibling).toBeNull();
});
});
@@ -1,4 +1,5 @@
import { cn, useScroll } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React, { type ReactNode, useEffect, useState } from 'react';
import { Header } from '../../Header/Header';
@@ -42,6 +43,27 @@ export interface PageHeadProps {
className?: string;
}
// Root positioning is fully determined by `strategy`:
// - `collapsible-sticky` stays in-flow so expanded content scrolls away.
// - `*-sticky` / `*-fixed` strategies pin the root to the top.
const pageHeadRootVariants = cva(
'page-head w-full bg-[var(--rb-background)] border-b border-[var(--rb-outline)]/10',
{
variants: {
strategy: {
'always-collapsed-fixed': 'fixed top-0 z-10',
'always-collapsed-sticky': 'sticky top-0 z-10',
'always-expanded-fixed': 'fixed top-0 z-10',
'always-expanded-sticky': 'sticky top-0 z-10',
'collapsible-sticky': '',
},
},
defaultVariants: {
strategy: 'collapsible-sticky',
},
}
);
/**
* A universal page head component with multiple layout and behavior strategies.
*
@@ -67,21 +89,20 @@ export const PageHead = ({
className,
}: PageHeadProps) => {
const { y: scrollY } = useScroll();
const [isCollapsed, setIsCollapsed] = useState(strategy.includes('always-collapsed'));
const isCollapsible = strategy === 'collapsible-sticky';
const isAlwaysCollapsed = strategy.startsWith('always-collapsed');
const [isCollapsed, setIsCollapsed] = useState(isAlwaysCollapsed);
useEffect(() => {
if (strategy === 'collapsible-sticky') {
setIsCollapsed(scrollY > 10);
if (isCollapsible) {
// Trigger collapse once we've scrolled past the expanded header's height.
// Usually around 150-200px depending on caption length.
// 200px ensures it won't snap until it's mostly gone.
setIsCollapsed(scrollY > 200);
}
}, [scrollY, strategy]);
}, [scrollY, isCollapsible]);
const rootClasses = cn(
'page-head w-full transition-all duration-200 bg-[var(--rb-background)] sticky top-0 z-10 border-b border-[var(--rb-outline)]/10',
{
'shadow-md bg-[var(--rb-background)]/90 backdrop-blur-md': isCollapsed,
},
className
);
const rootClasses = cn(pageHeadRootVariants({ strategy }), className);
const expandedContent = (
<PageContainer paddingY="medium">
@@ -93,7 +114,7 @@ export const PageHead = ({
</HStack>
{addonRight && <div>{addonRight}</div>}
</HStack>
<VStack gap={2}>
<VStack gap={4}>
<Header level={1}>{heading}</Header>
{typeof caption === 'string' ? (
<Text color="muted" size="l" type="body">
@@ -109,38 +130,68 @@ export const PageHead = ({
const collapsedContent = (
<PageContainer paddingY="none">
<HStack align="center" className="h-14" justify="between">
<HStack align="center" className="min-w-0 flex-1" gap={4}>
{addonLeft && <div className="flex-shrink-0">{addonLeft}</div>}
<Header level={4} overflow="ellipsis">
{/*
* A single row on wide screens (title left, menu right). When the title
* and menu can't fit together, the row wraps the menu onto its own line
* beneath the title instead of overlapping. `whitespace-nowrap` keeps the
* title a single unit so it's the menu that wraps, not the title that
* shrinks. The invisible spacer copy below reserves whichever height this
* ends up being, so page content always starts clear of the bar.
*/}
<HStack align="center" className="py-2.5" gap={4} justify="between" wrap="wrap">
<HStack align="center" className="flex-1" gap={4}>
{addonLeft && <div className="flex-shrink-0 whitespace-nowrap">{addonLeft}</div>}
<Header className="min-w-0" level={4} overflow="ellipsis">
{heading}
</Header>
</HStack>
{addonRight && <div className="flex-shrink-0 ml-4">{addonRight}</div>}
{addonRight && <div className="flex-shrink-0">{addonRight}</div>}
</HStack>
</PageContainer>
);
return (
<header className={rootClasses}>
<div
className={cn(
'grid transition-[grid-template-rows] duration-300 ease-in-out',
isCollapsed ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]'
<>
<header className={rootClasses}>
{/* Expanded Content: In-flow for collapsible, or sticky/fixed for others */}
{!isAlwaysCollapsed && (
<div
className={cn(
'transition-opacity duration-300',
isCollapsible && isCollapsed ? 'opacity-0 pointer-events-none' : 'opacity-100'
)}
>
{expandedContent}
</div>
)}
inert={isCollapsed || undefined}
>
<div className="overflow-hidden">{expandedContent}</div>
</div>
<div
className={cn(
'grid transition-[grid-template-rows] duration-300 ease-in-out',
isCollapsed ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
)}
inert={!isCollapsed || undefined}
>
<div className="overflow-hidden">{collapsedContent}</div>
</div>
</header>
{/* Collapsed Content: Fixed overlay for collapsible, or absolute/relative within sticky/fixed root */}
<div
className={cn(
'z-20 transition-all duration-300 transform bg-[var(--rb-background)]/90 backdrop-blur-md border-b border-[var(--rb-outline)]/10 shadow-md',
isAlwaysCollapsed || (isCollapsible && isCollapsed)
? 'translate-y-0 opacity-100'
: '-translate-y-full opacity-0 pointer-events-none',
isCollapsible ? 'fixed top-0 left-0 right-0' : 'absolute top-0 left-0 right-0'
)}
>
{collapsedContent}
</div>
</header>
{/*
* Always-collapsed strategies show only the collapsed bar, which is taken
* out of normal flow (the root is fixed, and its collapsed bar is absolute),
* so the header reserves no height. Without this spacer, page content would
* start at the top of the viewport, underneath the bar. Rendering an
* invisible copy of the bar reserves exactly its height at every viewport —
* including when it wraps to two rows — with no measurement or layout shift.
*/}
{isAlwaysCollapsed && (
<div aria-hidden="true" className="invisible shrink-0">
{collapsedContent}
</div>
)}
</>
);
};
@@ -1,4 +1,5 @@
import { cn, genericMemo } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React, { forwardRef } from 'react';
import { Text } from '../Text/Text';
@@ -17,6 +18,28 @@ export interface ParagraphProps extends Omit<TextProps, 'as'> {
dropCap?: boolean;
}
const paragraphVariants = cva('', {
variants: {
gutterBottom: {
true: 'mb-4',
false: '',
},
indent: {
true: 'indent-8',
false: '',
},
dropCap: {
true: 'first-letter:text-7xl first-letter:font-bold first-letter:float-left first-letter:mr-3 first-letter:leading-none',
false: '',
},
},
defaultVariants: {
gutterBottom: false,
indent: false,
dropCap: false,
},
});
/**
* A semantic paragraph component that wraps the Text component.
*
@@ -32,13 +55,7 @@ const ParagraphComponent = forwardRef<HTMLElement, ParagraphProps>(function Para
<Text
ref={ref}
as="p"
className={cn(
gutterBottom && 'mb-4',
indent && 'indent-8',
dropCap &&
'first-letter:text-7xl first-letter:font-bold first-letter:float-left first-letter:mr-3 first-letter:leading-none',
className
)}
className={cn(paragraphVariants({ gutterBottom, indent, dropCap }), className)}
{...props}
>
{children}
@@ -0,0 +1,53 @@
import React from 'react';
import { Separator } from './Separator';
import { render, screen } from '../../lib/testing';
describe('Separator', () => {
it('renders a single horizontal separator by default', () => {
render(<Separator />);
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-orientation', 'horizontal');
expect(separator).toHaveClass('border-t');
});
it('renders a single vertical separator', () => {
render(<Separator direction="vertical" />);
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-orientation', 'vertical');
expect(separator).toHaveClass('border-l');
});
it('applies an explicit color override', () => {
render(<Separator color="black" />);
expect(screen.getByRole('separator')).toHaveClass('border-foreground');
});
it('applies the thinned gradient mask by default and omits it when disabled', () => {
const { rerender } = render(<Separator data-testid="sep" />);
expect(screen.getByTestId('sep').className).toContain('mask-image');
rerender(<Separator data-testid="sep" thinned="none" />);
expect(screen.getByTestId('sep').className).not.toContain('mask-image');
});
it('renders a double horizontal separator with two lines', () => {
const { container } = render(<Separator type="double" />);
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-orientation', 'horizontal');
expect(container.querySelectorAll('[aria-hidden="true"]')).toHaveLength(2);
});
it('renders a double vertical separator laid out in a row', () => {
const { container } = render(<Separator direction="vertical" type="double" />);
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-orientation', 'vertical');
expect(separator).toHaveClass('flex-row');
expect(container.querySelectorAll('[aria-hidden="true"]')).toHaveLength(2);
});
it('forwards data-testid and merges a custom className', () => {
render(<Separator className="my-custom-class" data-testid="sep" />);
expect(screen.getByTestId('sep')).toHaveClass('my-custom-class');
});
});
@@ -1,4 +1,5 @@
import { cn } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React, { memo } from 'react';
import { VStack } from '../VStack/VStack';
@@ -30,21 +31,61 @@ export interface SeparatorProps extends ClassNameProps, TestIdProps {
thinned?: SeparatorThinnedType;
}
const THINNED_TYPE_CLASSES: Record<
'horizontal' | 'vertical',
Record<Exclude<SeparatorThinnedType, 'none'>, string>
> = {
horizontal: {
thinned: '[mask-image:linear-gradient(to_right,transparent,black_20%,black_80%,transparent)]',
'thinned-start': '[mask-image:linear-gradient(to_right,transparent,black_20%,black)]',
'thinned-end': '[mask-image:linear-gradient(to_right,black,black_80%,transparent)]',
const separatorLineVariants = cva('border-0 border-solid', {
variants: {
color: {
black: 'border-foreground',
outline: 'border-[var(--rb-outline)]',
'outline-light': 'border-[var(--rb-outline)]/40',
},
direction: {
// 1px stroke for subtler appearance, plus size classes to fill the
// container (the border provides the visual line).
horizontal: 'border-t w-full h-0',
vertical: 'border-l h-full w-0',
},
thinned: {
thinned: '',
'thinned-start': '',
'thinned-end': '',
none: '',
},
},
vertical: {
thinned: '[mask-image:linear-gradient(to_bottom,transparent,black_20%,black_80%,transparent)]',
'thinned-start': '[mask-image:linear-gradient(to_bottom,transparent,black_20%,black)]',
'thinned-end': '[mask-image:linear-gradient(to_bottom,black,black_80%,transparent)]',
},
};
// Thinned effect using CSS mask; the gradient axis depends on direction.
compoundVariants: [
{
direction: 'horizontal',
thinned: 'thinned',
className: '[mask-image:linear-gradient(to_right,transparent,black_20%,black_80%,transparent)]',
},
{
direction: 'horizontal',
thinned: 'thinned-start',
className: '[mask-image:linear-gradient(to_right,transparent,black_20%,black)]',
},
{
direction: 'horizontal',
thinned: 'thinned-end',
className: '[mask-image:linear-gradient(to_right,black,black_80%,transparent)]',
},
{
direction: 'vertical',
thinned: 'thinned',
className:
'[mask-image:linear-gradient(to_bottom,transparent,black_20%,black_80%,transparent)]',
},
{
direction: 'vertical',
thinned: 'thinned-start',
className: '[mask-image:linear-gradient(to_bottom,transparent,black_20%,black)]',
},
{
direction: 'vertical',
thinned: 'thinned-end',
className: '[mask-image:linear-gradient(to_bottom,black,black_80%,transparent)]',
},
],
});
/**
* A separator component for dividing content sections.
@@ -72,34 +113,12 @@ export const Separator = memo(function Separator({
const defaultColor = type === 'single' ? 'outline-light' : 'outline';
const finalColor = color ?? defaultColor;
// Color mapping to CSS custom properties
const colorClasses = {
black: 'border-foreground',
outline: 'border-[var(--rb-outline)]',
'outline-light': 'border-[var(--rb-outline)]/40',
};
// Base line styles
const lineBaseClasses = [
'border-0',
'border-solid',
colorClasses[finalColor],
// 1px stroke for subtler appearance
direction === 'horizontal' ? 'border-t' : 'border-l',
];
// Thinned effect using CSS mask
const thinnedClasses = thinned !== 'none' ? [THINNED_TYPE_CLASSES[direction][thinned]] : [];
// Size classes - fill container by default
const sizeClasses =
direction === 'horizontal'
? ['w-full', 'h-0'] // Full width, no height (border provides the visual)
: ['h-full', 'w-0']; // Full height, no width (border provides the visual)
// Single line implementation
if (type === 'single') {
const lineClasses = cn(...lineBaseClasses, ...thinnedClasses, ...sizeClasses, className);
const lineClasses = cn(
separatorLineVariants({ color: finalColor, direction, thinned }),
className
);
return (
<div
@@ -112,7 +131,7 @@ export const Separator = memo(function Separator({
}
// Double line implementation
const singleLineClasses = cn(...lineBaseClasses, ...thinnedClasses, ...sizeClasses);
const singleLineClasses = separatorLineVariants({ color: finalColor, direction, thinned });
const containerClasses = cn(direction === 'horizontal' ? 'w-full' : 'h-full', className);
+100 -129
View File
@@ -1,47 +1,10 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React, { forwardRef } from 'react';
import type { SpacingScale } from '../../lib/scale';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
/**
* Tailwind gap spacing scale values.
*/
type GapScale =
| 0
| 0.5
| 1
| 1.5
| 2
| 2.5
| 3
| 3.5
| 4
| 5
| 6
| 7
| 8
| 9
| 10
| 11
| 12
| 14
| 16
| 20
| 24
| 28
| 32
| 36
| 40
| 44
| 48
| 52
| 56
| 60
| 64
| 72
| 80
| 96;
/**
* Flexbox direction values.
*/
@@ -77,13 +40,102 @@ export interface StackProps extends ClassNameProps, TestIdProps {
/** Whether items should wrap to new lines/columns */
wrap?: FlexWrap;
/** Spacing between items using Tailwind's spacing scale */
gap?: GapScale;
gap?: SpacingScale;
/** Whether to enable scrolling when content overflows */
scrollable?: boolean;
/** The HTML element to use for rendering */
as?: React.ElementType;
}
const stackVariants = cva('flex', {
variants: {
direction: {
row: 'flex-row',
col: 'flex-col',
'row-reverse': 'flex-row-reverse',
'col-reverse': 'flex-col-reverse',
},
justify: {
start: 'justify-start',
end: 'justify-end',
center: 'justify-center',
between: 'justify-between',
around: 'justify-around',
evenly: 'justify-evenly',
},
align: {
start: 'items-start',
end: 'items-end',
center: 'items-center',
baseline: 'items-baseline',
stretch: 'items-stretch',
},
wrap: {
nowrap: 'flex-nowrap',
wrap: 'flex-wrap',
'wrap-reverse': 'flex-wrap-reverse',
},
// Convert gap scale to Tailwind class using explicit mapping
// This ensures all gap classes are detected by Tailwind's static analysis
gap: {
0: '',
0.5: 'gap-0.5',
1: 'gap-1',
1.5: 'gap-1.5',
2: 'gap-2',
2.5: 'gap-2.5',
3: 'gap-3',
3.5: 'gap-3.5',
4: 'gap-4',
5: 'gap-5',
6: 'gap-6',
7: 'gap-7',
8: 'gap-8',
9: 'gap-9',
10: 'gap-10',
11: 'gap-11',
12: 'gap-12',
14: 'gap-14',
16: 'gap-16',
20: 'gap-20',
24: 'gap-24',
28: 'gap-28',
32: 'gap-32',
36: 'gap-36',
40: 'gap-40',
44: 'gap-44',
48: 'gap-48',
52: 'gap-52',
56: 'gap-56',
60: 'gap-60',
64: 'gap-64',
72: 'gap-72',
80: 'gap-80',
96: 'gap-96',
},
scrollable: {
true: '',
false: '',
},
},
// Scroll axis depends on both scrollable and direction:
// row directions overflow on the x-axis, column directions on the y-axis.
compoundVariants: [
{ scrollable: true, direction: 'row', className: 'overflow-x-auto' },
{ scrollable: true, direction: 'row-reverse', className: 'overflow-x-auto' },
{ scrollable: true, direction: 'col', className: 'overflow-y-auto' },
{ scrollable: true, direction: 'col-reverse', className: 'overflow-y-auto' },
],
defaultVariants: {
direction: 'row',
justify: 'start',
align: 'start',
wrap: 'nowrap',
gap: 0,
scrollable: false,
},
});
/**
* A flexible layout component that arranges children using CSS Flexbox.
*
@@ -127,96 +179,15 @@ const StackComponent = forwardRef<HTMLElement, StackProps>(function Stack(
},
ref
) {
const directionClasses = {
row: 'flex-row',
col: 'flex-col',
'row-reverse': 'flex-row-reverse',
'col-reverse': 'flex-col-reverse',
} as const;
const justifyClasses = {
start: 'justify-start',
end: 'justify-end',
center: 'justify-center',
between: 'justify-between',
around: 'justify-around',
evenly: 'justify-evenly',
} as const;
const alignClasses = {
start: 'items-start',
end: 'items-end',
center: 'items-center',
baseline: 'items-baseline',
stretch: 'items-stretch',
} as const;
const wrapClasses = {
nowrap: 'flex-nowrap',
wrap: 'flex-wrap',
'wrap-reverse': 'flex-wrap-reverse',
} as const;
// Convert gap scale to Tailwind class using explicit mapping
// This ensures all gap classes are detected by Tailwind's static analysis
const gapClasses = {
0: '',
0.5: 'gap-0.5',
1: 'gap-1',
1.5: 'gap-1.5',
2: 'gap-2',
2.5: 'gap-2.5',
3: 'gap-3',
3.5: 'gap-3.5',
4: 'gap-4',
5: 'gap-5',
6: 'gap-6',
7: 'gap-7',
8: 'gap-8',
9: 'gap-9',
10: 'gap-10',
11: 'gap-10',
12: 'gap-12',
14: 'gap-14',
16: 'gap-16',
20: 'gap-20',
24: 'gap-24',
28: 'gap-28',
32: 'gap-32',
36: 'gap-36',
40: 'gap-40',
44: 'gap-44',
48: 'gap-48',
52: 'gap-52',
56: 'gap-56',
60: 'gap-60',
64: 'gap-64',
72: 'gap-72',
80: 'gap-80',
96: 'gap-96',
} as const;
const gapClass = gapClasses[gap];
const scrollClasses = scrollable
? direction.includes('row')
? 'overflow-x-auto'
: 'overflow-y-auto'
: '';
const stackClasses = cn(
'flex',
directionClasses[direction],
justifyClasses[justify],
alignClasses[align],
wrapClasses[wrap],
gapClass,
scrollClasses,
className
);
return (
<Component ref={castRef<HTMLElement>(ref)} className={stackClasses} data-testid={testId}>
<Component
ref={castRef<HTMLElement>(ref)}
className={cn(
stackVariants({ direction, justify, align, wrap, gap, scrollable }),
className
)}
data-testid={testId}
>
{children}
</Component>
);
+22 -14
View File
@@ -1,4 +1,5 @@
import { cn } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React from 'react';
/**
@@ -17,6 +18,26 @@ export interface StubProps {
animate?: boolean;
}
const stubVariants = cva('bg-[var(--rb-color-neutral-100)] dark:bg-[var(--rb-color-neutral-800)]', {
variants: {
rounded: {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full',
},
animate: {
true: 'animate-shimmer',
false: '',
},
},
defaultVariants: {
rounded: 'md',
animate: true,
},
});
/**
* A skeleton placeholder component with a shimmering animation.
* Used to indicate loading states while maintaining layout stability.
@@ -38,22 +59,9 @@ export const Stub: React.FC<StubProps> = ({
className,
animate = true,
}) => {
const roundedClasses = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full',
};
return (
<div
className={cn(
'bg-[var(--rb-color-neutral-100)] dark:bg-[var(--rb-color-neutral-800)]',
animate && 'animate-shimmer',
roundedClasses[rounded],
className
)}
className={cn(stubVariants({ rounded, animate }), className)}
data-testid="stub"
style={{ width, height }}
/>
@@ -46,4 +46,25 @@ describe('components/Switch', () => {
await user.click(checkbox);
expect(handleChange).not.toHaveBeenCalled();
});
it('applies correct Tailwind classes to track and thumb when selected/unselected', async () => {
const user = userEvent.setup();
const { container } = render(<Switch color="primary" variant="solid">Switch</Switch>);
const track = container.querySelector('[aria-hidden="true"]');
const thumb = track?.firstElementChild;
// Unselected state
expect(track).toHaveClass('data-[selected=false]:bg-muted');
expect(thumb).toHaveClass('data-[selected=false]:bg-[var(--rb-text)]');
// Toggle
const checkbox = screen.getByRole('switch');
await user.click(checkbox);
// Selected state
expect(track).toHaveClass('data-[selected=true]:bg-primary');
expect(thumb).toHaveClass('data-[selected=true]:bg-primary-text');
});
});
+165 -80
View File
@@ -1,4 +1,5 @@
import { cn } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React, { useRef } from 'react';
import { useSwitch, useFocusRing, VisuallyHidden } from 'react-aria';
import { useToggleState } from 'react-stately';
@@ -41,6 +42,166 @@ export interface SwitchProps extends AriaSwitchProps {
color?: SwitchColor;
}
const trackVariants = cva(
'relative w-11 h-6 transition-all duration-200 ease-in-out rounded-full outline-none flex items-center',
{
variants: {
variant: {
solid: 'data-[selected=false]:bg-muted',
outline: 'border-2 data-[selected=false]:bg-transparent data-[selected=false]:border-muted',
ghost: 'data-[selected=false]:bg-transparent data-[selected=false]:border-transparent',
},
color: {
primary: '',
'primary-negative': '',
secondary: '',
'secondary-negative': '',
tertiary: '',
'tertiary-negative': '',
error: '',
'error-negative': '',
neutral: '',
'neutral-negative': '',
},
},
compoundVariants: [
// primary
{ variant: 'solid', color: 'primary', className: 'data-[selected=true]:bg-primary data-[selected=true]:border-primary' },
{ variant: 'outline', color: 'primary', className: 'data-[selected=true]:bg-primary data-[selected=true]:border-primary' },
{ variant: 'ghost', color: 'primary', className: 'data-[selected=true]:bg-primary/20 data-[selected=true]:border-transparent' },
// primary-negative
{ variant: 'solid', color: 'primary-negative', className: 'data-[selected=true]:bg-primary-text data-[selected=true]:border-primary-text' },
{ variant: 'outline', color: 'primary-negative', className: 'data-[selected=true]:bg-primary-text data-[selected=true]:border-primary-text' },
{ variant: 'ghost', color: 'primary-negative', className: 'data-[selected=true]:bg-primary-text/20 data-[selected=true]:border-transparent' },
// secondary
{ variant: 'solid', color: 'secondary', className: 'data-[selected=true]:bg-secondary data-[selected=true]:border-secondary' },
{ variant: 'outline', color: 'secondary', className: 'data-[selected=true]:bg-secondary data-[selected=true]:border-secondary' },
{ variant: 'ghost', color: 'secondary', className: 'data-[selected=true]:bg-secondary/20 data-[selected=true]:border-transparent' },
// secondary-negative
{ variant: 'solid', color: 'secondary-negative', className: 'data-[selected=true]:bg-secondary-text data-[selected=true]:border-secondary-text' },
{ variant: 'outline', color: 'secondary-negative', className: 'data-[selected=true]:bg-secondary-text data-[selected=true]:border-secondary-text' },
{ variant: 'ghost', color: 'secondary-negative', className: 'data-[selected=true]:bg-secondary-text/20 data-[selected=true]:border-transparent' },
// tertiary
{ variant: 'solid', color: 'tertiary', className: 'data-[selected=true]:bg-tertiary data-[selected=true]:border-tertiary' },
{ variant: 'outline', color: 'tertiary', className: 'data-[selected=true]:bg-tertiary data-[selected=true]:border-tertiary' },
{ variant: 'ghost', color: 'tertiary', className: 'data-[selected=true]:bg-tertiary/20 data-[selected=true]:border-transparent' },
// tertiary-negative
{ variant: 'solid', color: 'tertiary-negative', className: 'data-[selected=true]:bg-tertiary-text data-[selected=true]:border-tertiary-text' },
{ variant: 'outline', color: 'tertiary-negative', className: 'data-[selected=true]:bg-tertiary-text data-[selected=true]:border-tertiary-text' },
{ variant: 'ghost', color: 'tertiary-negative', className: 'data-[selected=true]:bg-tertiary-text/20 data-[selected=true]:border-transparent' },
// error
{ variant: 'solid', color: 'error', className: 'data-[selected=true]:bg-error data-[selected=true]:border-error' },
{ variant: 'outline', color: 'error', className: 'data-[selected=true]:bg-error data-[selected=true]:border-error' },
{ variant: 'ghost', color: 'error', className: 'data-[selected=true]:bg-error/20 data-[selected=true]:border-transparent' },
// error-negative
{ variant: 'solid', color: 'error-negative', className: 'data-[selected=true]:bg-error-text data-[selected=true]:border-error-text' },
{ variant: 'outline', color: 'error-negative', className: 'data-[selected=true]:bg-error-text data-[selected=true]:border-error-text' },
{ variant: 'ghost', color: 'error-negative', className: 'data-[selected=true]:bg-error-text/20 data-[selected=true]:border-transparent' },
// neutral
{ variant: 'solid', color: 'neutral', className: 'data-[selected=true]:bg-muted data-[selected=true]:border-muted' },
{ variant: 'outline', color: 'neutral', className: 'data-[selected=true]:bg-muted data-[selected=true]:border-muted' },
{ variant: 'ghost', color: 'neutral', className: 'data-[selected=true]:bg-muted/20 data-[selected=true]:border-transparent' },
// neutral-negative
{ variant: 'solid', color: 'neutral-negative', className: 'data-[selected=true]:bg-[var(--rb-text)] data-[selected=true]:border-[var(--rb-text)]' },
{ variant: 'outline', color: 'neutral-negative', className: 'data-[selected=true]:bg-[var(--rb-text)] data-[selected=true]:border-[var(--rb-text)]' },
{ variant: 'ghost', color: 'neutral-negative', className: 'data-[selected=true]:bg-[var(--rb-text)]/20 data-[selected=true]:border-transparent' },
],
defaultVariants: {
variant: 'solid',
color: 'primary',
},
}
);
const thumbVariants = cva(
'w-4 h-4 transition-all duration-200 ease-in-out transform rounded-full shadow-sm data-[selected=false]:bg-[var(--rb-text)] data-[selected=false]:translate-x-0',
{
variants: {
variant: {
solid: 'ml-1 data-[selected=true]:translate-x-5',
outline: 'ml-0.5 data-[selected=true]:translate-x-4.5',
ghost: 'ml-1 data-[selected=true]:translate-x-5',
},
color: {
primary: '',
'primary-negative': '',
secondary: '',
'secondary-negative': '',
tertiary: '',
'tertiary-negative': '',
error: '',
'error-negative': '',
neutral: '',
'neutral-negative': '',
},
},
compoundVariants: [
// primary
{ variant: 'solid', color: 'primary', className: 'data-[selected=true]:bg-primary-text' },
{ variant: 'outline', color: 'primary', className: 'data-[selected=true]:bg-primary-text' },
{ variant: 'ghost', color: 'primary', className: 'data-[selected=true]:bg-primary' },
// primary-negative
{ variant: 'solid', color: 'primary-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
{ variant: 'outline', color: 'primary-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
{ variant: 'ghost', color: 'primary-negative', className: 'data-[selected=true]:bg-[var(--rb-text)]' },
// secondary
{ variant: 'solid', color: 'secondary', className: 'data-[selected=true]:bg-secondary-text' },
{ variant: 'outline', color: 'secondary', className: 'data-[selected=true]:bg-secondary-text' },
{ variant: 'ghost', color: 'secondary', className: 'data-[selected=true]:bg-secondary' },
// secondary-negative
{ variant: 'solid', color: 'secondary-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
{ variant: 'outline', color: 'secondary-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
{ variant: 'ghost', color: 'secondary-negative', className: 'data-[selected=true]:bg-[var(--rb-text)]' },
// tertiary
{ variant: 'solid', color: 'tertiary', className: 'data-[selected=true]:bg-tertiary-text' },
{ variant: 'outline', color: 'tertiary', className: 'data-[selected=true]:bg-tertiary-text' },
{ variant: 'ghost', color: 'tertiary', className: 'data-[selected=true]:bg-tertiary' },
// tertiary-negative
{ variant: 'solid', color: 'tertiary-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
{ variant: 'outline', color: 'tertiary-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
{ variant: 'ghost', color: 'tertiary-negative', className: 'data-[selected=true]:bg-[var(--rb-text)]' },
// error
{ variant: 'solid', color: 'error', className: 'data-[selected=true]:bg-error-text' },
{ variant: 'outline', color: 'error', className: 'data-[selected=true]:bg-error-text' },
{ variant: 'ghost', color: 'error', className: 'data-[selected=true]:bg-error' },
// error-negative
{ variant: 'solid', color: 'error-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
{ variant: 'outline', color: 'error-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
{ variant: 'ghost', color: 'error-negative', className: 'data-[selected=true]:bg-[var(--rb-text)]' },
// neutral
{ variant: 'solid', color: 'neutral', className: 'data-[selected=true]:bg-[var(--rb-text)]' },
{ variant: 'outline', color: 'neutral', className: 'data-[selected=true]:bg-[var(--rb-text)]' },
{ variant: 'ghost', color: 'neutral', className: 'data-[selected=true]:bg-muted' },
// neutral-negative
{ variant: 'solid', color: 'neutral-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
{ variant: 'outline', color: 'neutral-negative', className: 'data-[selected=true]:bg-[var(--rb-base)]' },
{ variant: 'ghost', color: 'neutral-negative', className: 'data-[selected=true]:bg-[var(--rb-text)]' },
],
defaultVariants: {
variant: 'solid',
color: 'primary',
},
}
);
/**
* A highly accessible toggle switch component.
*
@@ -60,66 +221,6 @@ export function Switch(props: SwitchProps): React.JSX.Element {
const { inputProps } = useSwitch(props, state, ref);
const { isFocusVisible, focusProps } = useFocusRing();
const getVariantStyles = (): React.CSSProperties => {
const isNegative = color.endsWith('-negative');
const baseColor = color.replace('-negative', '');
const colors: Record<string, { base: string; text: string }> = {
primary: { base: 'var(--rb-primary-base)', text: 'var(--rb-primary-text)' },
secondary: { base: 'var(--rb-secondary-base)', text: 'var(--rb-secondary-text)' },
tertiary: { base: 'var(--rb-tertiary-base)', text: 'var(--rb-tertiary-text)' },
error: { base: 'var(--rb-error-base)', text: 'var(--rb-error-text)' },
neutral: { base: 'var(--rb-muted-base)', text: 'var(--rb-text)' },
};
const colorValues = colors[baseColor];
const backgroundColor = isNegative ? colorValues.text : colorValues.base;
if (!state.isSelected) {
return {};
}
switch (variant) {
case 'solid':
return { backgroundColor };
case 'outline':
return { borderColor: backgroundColor };
case 'ghost':
return { backgroundColor: `${backgroundColor}33`, borderColor: 'transparent' }; // 33 is 20% opacity in hex
default:
return { backgroundColor };
}
};
const getThumbStyles = (): React.CSSProperties => {
const isNegative = color.endsWith('-negative');
const baseColor = color.replace('-negative', '');
const colors: Record<string, { text: string }> = {
primary: { text: 'var(--rb-primary-text)' },
secondary: { text: 'var(--rb-secondary-text)' },
tertiary: { text: 'var(--rb-tertiary-text)' },
error: { text: 'var(--rb-error-text)' },
neutral: { text: 'var(--rb-text)' },
};
const thumbColor = isNegative ? 'var(--rb-base)' : colors[baseColor].text;
if (!state.isSelected) {
return {};
}
switch (variant) {
case 'solid':
return { backgroundColor: thumbColor };
case 'outline':
case 'ghost':
return { backgroundColor: isNegative ? 'var(--rb-text)' : `var(--rb-${baseColor}-base)` };
default:
return { backgroundColor: thumbColor };
}
};
return (
<label
className={cn(
@@ -134,31 +235,15 @@ export function Switch(props: SwitchProps): React.JSX.Element {
<div
aria-hidden="true"
className={cn(
'relative w-11 h-6 transition-all duration-200 ease-in-out rounded-full outline-none flex items-center',
!state.isSelected &&
(variant === 'solid'
? 'bg-[var(--rb-muted-base)]'
: variant === 'outline'
? 'bg-transparent border-[var(--rb-muted-base)] border-2'
: 'bg-transparent border-transparent'),
state.isSelected && variant === 'outline' && 'bg-transparent border-2',
trackVariants({ variant, color }),
isFocusVisible && 'ring-2 ring-offset-2 ring-[var(--rb-ring)]',
props.isDisabled && 'grayscale-[0.5]'
)}
style={getVariantStyles()}
data-selected={state.isSelected}
>
<div
className={cn(
'w-4 h-4 transition-all duration-200 ease-in-out transform rounded-full shadow-sm',
!state.isSelected && 'bg-[var(--rb-text)]',
variant === 'outline' ? 'ml-0.5' : 'ml-1',
state.isSelected
? variant === 'outline'
? 'translate-x-4.5'
: 'translate-x-5'
: 'translate-x-0'
)}
style={getThumbStyles()}
className={thumbVariants({ variant, color })}
data-selected={state.isSelected}
/>
</div>
{children && (
+30 -11
View File
@@ -1,4 +1,5 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React, { forwardRef } from 'react';
import { TableContext } from './contexts/TableContext/TableContext';
@@ -26,6 +27,34 @@ export interface TableProps
stickyHeader?: boolean;
}
const tableVariants = cva(
cn(
'w-full border-collapse caption-bottom text-sm',
// Mask borders at the edges to prevent doubling with container borders
'[&_td:first-child]:border-l-0 [&_th:first-child]:border-l-0',
'[&_td:last-child]:border-r-0 [&_th:last-child]:border-r-0',
'[&_tr:first-child_td]:border-t-0 [&_tr:first-child_th]:border-t-0',
'[&_tr:last-child_td]:border-b-0'
),
{
variants: {
variant: {
surface: '',
outline: '',
ghost: 'border-none',
},
stickyHeader: {
true: 'relative',
false: '',
},
},
defaultVariants: {
variant: 'surface',
stickyHeader: false,
},
}
);
const TableComponent = forwardRef<HTMLTableElement, TableProps>(function Table(
{
className,
@@ -47,17 +76,7 @@ const TableComponent = forwardRef<HTMLTableElement, TableProps>(function Table(
<table
{...props}
ref={castRef<HTMLTableElement>(ref)}
className={cn(
'w-full border-collapse caption-bottom text-sm',
// Mask borders at the edges to prevent doubling with container borders
'[&_td:first-child]:border-l-0 [&_th:first-child]:border-l-0',
'[&_td:last-child]:border-r-0 [&_th:last-child]:border-r-0',
'[&_tr:first-child_td]:border-t-0 [&_tr:first-child_th]:border-t-0',
'[&_tr:last-child_td]:border-b-0',
variant === 'ghost' && 'border-none',
stickyHeader && 'relative',
className
)}
className={cn(tableVariants({ variant, stickyHeader }), className)}
>
{children}
</table>
@@ -1,4 +1,5 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React, { forwardRef } from 'react';
import { paddingClasses, borderClasses } from '../constants';
@@ -23,6 +24,18 @@ export interface TableCellProps extends React.TdHTMLAttributes<HTMLTableCellElem
border?: TableCellBorder;
}
const cellVariants = cva('align-middle', {
variants: {
padding: paddingClasses,
border: borderClasses,
align: {
left: 'text-left',
center: 'text-center',
right: 'text-right',
},
},
});
/**
* A standard table data cell (td) with density and alignment control.
*
@@ -65,12 +78,7 @@ export const TableCell = genericMemo(
{...props}
ref={castRef<HTMLTableCellElement>(ref)}
className={cn(
'align-middle',
paddingClasses[finalPadding],
borderClasses[resolvedBorder],
align === 'left' && 'text-left',
align === 'center' && 'text-center',
align === 'right' && 'text-right',
cellVariants({ padding: finalPadding, border: resolvedBorder, align }),
className
)}
>
@@ -1,4 +1,5 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React, { forwardRef } from 'react';
/**
@@ -36,18 +37,49 @@ export interface TableContentProps extends React.HTMLAttributes<HTMLDivElement>
color?: 'default' | 'muted' | 'error' | 'success';
}
const gapClasses: Record<string, string> = {
s: 'gap-1.5',
m: 'gap-3',
l: 'gap-4',
};
const colorClasses: Record<string, string> = {
default: 'text-[var(--rb-text)]',
muted: 'text-[var(--rb-muted-text)]',
error: 'text-[var(--rb-error-text)]',
success: 'text-[var(--rb-success-text)]',
};
const contentVariants = cva('flex', {
variants: {
layout: {
flex: 'flex-row items-center',
stack: 'flex-col',
},
align: {
start: '',
center: '',
end: '',
between: '',
},
gap: {
s: 'gap-1.5',
m: 'gap-3',
l: 'gap-4',
},
color: {
default: 'text-[var(--rb-text)]',
muted: 'text-[var(--rb-muted-text)]',
error: 'text-[var(--rb-error-text)]',
success: 'text-[var(--rb-success-text)]',
},
variant: {
text: '',
numeric: 'tabular-nums',
icon: '',
checkbox: 'w-min mx-auto',
},
},
compoundVariants: [
// Flex layout maps alignment to justify-*
{ layout: 'flex', align: 'start', className: 'justify-start' },
{ layout: 'flex', align: 'center', className: 'justify-center' },
{ layout: 'flex', align: 'end', className: 'justify-end' },
{ layout: 'flex', align: 'between', className: 'justify-between' },
// Stack layout maps alignment to items-*
{ layout: 'stack', align: 'start', className: 'items-start' },
{ layout: 'stack', align: 'center', className: 'items-center' },
{ layout: 'stack', align: 'end', className: 'items-end' },
{ layout: 'stack', align: 'between', className: 'items-stretch' },
],
});
/**
* A polymorphic layout primitive for content within table cells.
@@ -85,20 +117,12 @@ export const TableContent = genericMemo(
},
ref
) {
const alignClasses = {
start: layout === 'flex' ? 'justify-start' : 'items-start',
center: layout === 'flex' ? 'justify-center' : 'items-center',
end: layout === 'flex' ? 'justify-end' : 'items-end',
between: layout === 'flex' ? 'justify-between' : 'items-stretch',
};
// Determine default alignment based on variant if not explicitly provided
let defaultAlign: keyof typeof alignClasses = 'start';
let defaultAlign: NonNullable<TableContentProps['align']> = 'start';
if (variant === 'numeric') defaultAlign = 'end';
else if (variant === 'icon' || variant === 'checkbox') defaultAlign = 'center';
const finalAlign = align ?? defaultAlign;
const alignmentClass = alignClasses[finalAlign];
// Truncation logic
const isTruncated = truncate || maxLines !== undefined;
@@ -109,16 +133,10 @@ export const TableContent = genericMemo(
{...props}
ref={castRef<HTMLDivElement>(ref)}
className={cn(
'flex',
layout === 'flex' ? 'flex-row items-center' : 'flex-col',
alignmentClass,
gap && gapClasses[gap],
colorClasses[color],
contentVariants({ layout, align: finalAlign, gap, color, variant }),
// When truncating, we need to handle block layout and min-width
isTruncated && (lineCount === 1 ? 'truncate block' : 'line-clamp'),
isTruncated && 'min-w-0',
variant === 'numeric' && 'tabular-nums',
variant === 'checkbox' && 'w-min mx-auto',
className
)}
style={{
@@ -1,4 +1,5 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import { cva } from 'class-variance-authority';
import React, { forwardRef } from 'react';
import { paddingClasses, borderClasses } from '../constants';
@@ -23,6 +24,18 @@ export interface TableHeadProps extends React.ThHTMLAttributes<HTMLTableCellElem
border?: TableCellBorder;
}
const headVariants = cva('align-middle font-medium text-[var(--rb-muted-text)]', {
variants: {
padding: paddingClasses,
border: borderClasses,
align: {
left: 'text-left',
center: 'text-center',
right: 'text-right',
},
},
});
/**
* A table header cell (th) with density and alignment control.
*
@@ -60,12 +73,7 @@ export const TableHead = genericMemo(
{...props}
ref={castRef<HTMLTableCellElement>(ref)}
className={cn(
'align-middle font-medium text-[var(--rb-muted-text)]',
paddingClasses[finalPadding],
borderClasses[resolvedBorder],
align === 'left' && 'text-left',
align === 'center' && 'text-center',
align === 'right' && 'text-right',
headVariants({ padding: finalPadding, border: resolvedBorder, align }),
className
)}
>
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff. Show more