Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90bf68c4c8 | ||
|
|
cd40139eea | ||
|
|
f5e901c131 | ||
|
|
b84ef775b8 | ||
|
|
ac9c455e5a | ||
|
|
ea35a3d9f5 | ||
|
|
49b545f1b4 | ||
|
|
f3a3a52aa8
|
||
|
|
6aa3a03319
|
||
|
|
d2903a3715
|
||
|
|
7b574a1a66
|
||
|
|
56d0d65971
|
||
|
|
6784a28701
|
||
|
|
a95723c4f1
|
||
|
|
4e36e800d4
|
||
|
|
8d57d6ba1b
|
||
|
|
30fd52b8e9
|
||
|
|
e9f27f2437
|
||
|
|
52ada4eba6
|
||
|
|
9c8e57a7b1
|
||
|
|
a571b343c8
|
||
|
|
90e8fad496
|
||
|
|
0784e0b305
|
No files matched your search
@@ -257,9 +257,9 @@ Use for large screenshots, detailed logs, or deep-dive explanations that disrupt
|
||||
Use when the user must choose _one_ path (e.g., OS specific, Language specific).
|
||||
|
||||
```markdown
|
||||
{{< tabs items="JSON,YAML" >}}
|
||||
{{< tab >}} **JSON** content here... {{< /tab >}}
|
||||
{{< tab >}} **YAML** content here... {{< /tab >}}
|
||||
{{< tabs >}}
|
||||
{{< tab name="JSON" >}} **JSON** content here... {{< /tab >}}
|
||||
{{< tab name="YAML" >}} **YAML** content here... {{< /tab >}}
|
||||
{{< /tabs >}}
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
name: packages/stl - pr/required
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [next]
|
||||
paths:
|
||||
- 'packages/stl/**'
|
||||
- '.github/workflows/packages-stl-pr-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ci-pr-stl-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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-stl:
|
||||
name: Lint - STL
|
||||
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 STL package
|
||||
run: pnpm --filter @ermnvldmr/stl lint
|
||||
|
||||
typecheck-stl:
|
||||
name: Typecheck - STL
|
||||
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 STL package
|
||||
run: pnpm --filter @ermnvldmr/stl typecheck
|
||||
|
||||
test-stl:
|
||||
name: Test - STL
|
||||
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: Test STL package
|
||||
run: pnpm --filter @ermnvldmr/stl test:coverage
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: ./packages/stl/coverage/lcov.info
|
||||
flags: stl
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
build-stl:
|
||||
name: Build - STL
|
||||
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 STL package
|
||||
run: pnpm --filter @ermnvldmr/stl build
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/packages-ui-next-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
@@ -64,6 +65,8 @@ jobs:
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Build Storybook config
|
||||
run: pnpm --filter @ermnvldmr/storybook-config build
|
||||
- name: Build i18n
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload Rsbuild Config
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -76,6 +79,12 @@ jobs:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
retention-days: 1
|
||||
- name: Upload i18n dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-ui:
|
||||
name: Lint - UI
|
||||
@@ -103,6 +112,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Lint UI package
|
||||
run: pnpm --filter @ermnvldmr/ui lint
|
||||
|
||||
@@ -132,6 +146,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Typecheck UI package
|
||||
run: pnpm --filter @ermnvldmr/ui typecheck
|
||||
|
||||
@@ -161,6 +180,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Test UI package
|
||||
run: pnpm --filter @ermnvldmr/ui test:coverage
|
||||
- name: Upload coverage to Codecov
|
||||
@@ -197,6 +221,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Build UI Storybook
|
||||
run: pnpm --filter @ermnvldmr/ui build-storybook
|
||||
|
||||
@@ -226,6 +255,11 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm --filter @ermnvldmr/ui exec playwright install --with-deps chromium
|
||||
- name: Build UI Storybook
|
||||
@@ -266,5 +300,10 @@ jobs:
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Build UI package
|
||||
run: pnpm --filter @ermnvldmr/ui build
|
||||
@@ -0,0 +1,318 @@
|
||||
name: packages/ui - pr/required
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [next]
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/packages-ui-pr-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ci-pr-ui-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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: Build Storybook config
|
||||
run: pnpm --filter @ermnvldmr/storybook-config build
|
||||
- name: Build i18n
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload Rsbuild Config
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
retention-days: 1
|
||||
- name: Upload Storybook Config
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
retention-days: 1
|
||||
- name: Upload i18n dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-ui:
|
||||
name: Lint - UI
|
||||
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 Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Lint UI package
|
||||
run: pnpm --filter @ermnvldmr/ui lint
|
||||
|
||||
typecheck-ui:
|
||||
name: Typecheck - UI
|
||||
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 Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Typecheck UI package
|
||||
run: pnpm --filter @ermnvldmr/ui typecheck
|
||||
|
||||
test-ui:
|
||||
name: Test - Unit
|
||||
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 Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Test UI package
|
||||
run: pnpm --filter @ermnvldmr/ui test:coverage
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: ./packages/ui/coverage/lcov.info
|
||||
flags: ui
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
build-storybook:
|
||||
name: Build - Storybook
|
||||
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 Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Build UI Storybook
|
||||
run: pnpm --filter @ermnvldmr/ui build-storybook
|
||||
- name: Upload Storybook build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ui-storybook-static-${{ github.run_id }}
|
||||
path: packages/ui/storybook-static/
|
||||
retention-days: 1
|
||||
|
||||
test-storybook:
|
||||
name: Test - Storybook
|
||||
needs: build-storybook
|
||||
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 Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm --filter @ermnvldmr/ui exec playwright install --with-deps chromium
|
||||
- name: Download Storybook build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ui-storybook-static-${{ github.run_id }}
|
||||
path: packages/ui/storybook-static/
|
||||
- name: Test Storybook
|
||||
run: pnpm --filter @ermnvldmr/ui test:storybook:ci
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: ./packages/ui/coverage/storybook/lcov.info
|
||||
flags: ui-storybook
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
build-ui:
|
||||
name: Build - UI
|
||||
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 Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Build UI package
|
||||
run: pnpm --filter @ermnvldmr/ui build
|
||||
@@ -0,0 +1,50 @@
|
||||
name: services/docs - pr/required
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [next]
|
||||
paths:
|
||||
- 'services/docs/**'
|
||||
- '.github/workflows/services-docs-pr-required.yml'
|
||||
|
||||
concurrency:
|
||||
group: ci-pr-docs-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
setup-hugo:
|
||||
name: Setup - Hugo
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Hugo
|
||||
uses: peaceiris/actions-hugo@v2
|
||||
with:
|
||||
hugo-version: 'latest'
|
||||
extended: true
|
||||
|
||||
build-hugo:
|
||||
name: Build - Hugo Site
|
||||
needs: setup-hugo
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Hugo
|
||||
uses: peaceiris/actions-hugo@v2
|
||||
with:
|
||||
hugo-version: 'latest'
|
||||
extended: true
|
||||
|
||||
- name: Build Hugo site
|
||||
working-directory: services/docs
|
||||
run: hugo --gc --minify
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
- 'services/static/**'
|
||||
- 'packages/ui/**'
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/services-static-next-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
@@ -63,12 +64,20 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build Rsbuild config
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Build i18n
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload Config Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
retention-days: 1
|
||||
- name: Upload i18n dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-static:
|
||||
name: Lint - Static
|
||||
@@ -91,6 +100,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Lint static service
|
||||
run: pnpm --filter @ermnvldmr/static lint
|
||||
|
||||
@@ -115,6 +129,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Typecheck static service
|
||||
run: pnpm --filter @ermnvldmr/static typecheck
|
||||
|
||||
@@ -139,6 +158,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Build static service
|
||||
run: pnpm --filter @ermnvldmr/static build
|
||||
- name: Upload build artifacts
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
name: services/static - pr/required
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [next]
|
||||
paths:
|
||||
- 'services/static/**'
|
||||
- 'packages/ui/**'
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/services-static-pr-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ci-pr-static-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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: Build i18n
|
||||
run: pnpm --filter @ermnvldmr/i18n 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
|
||||
- name: Upload i18n dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-static:
|
||||
name: Lint - Static
|
||||
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: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Lint static service
|
||||
run: pnpm --filter @ermnvldmr/static lint
|
||||
|
||||
typecheck-static:
|
||||
name: Typecheck - Static
|
||||
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: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Typecheck static service
|
||||
run: pnpm --filter @ermnvldmr/static typecheck
|
||||
|
||||
build-static:
|
||||
name: Build - Static Site
|
||||
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: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Build static service
|
||||
run: pnpm --filter @ermnvldmr/static build
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: static-dist-${{ github.run_id }}
|
||||
path: services/static/dist/
|
||||
retention-days: 1
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
- 'services/www/**'
|
||||
- 'packages/ui/**'
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/services-www-next-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
@@ -77,6 +78,14 @@ jobs:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
retention-days: 1
|
||||
- name: Build i18n package
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload i18n dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-www:
|
||||
name: Lint - WWW
|
||||
@@ -99,6 +108,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -128,6 +142,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -157,6 +176,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -192,6 +216,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -228,6 +257,11 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -271,13 +305,26 @@ jobs:
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Build WWW service
|
||||
run: pnpm --filter @ermnvldmr/www build
|
||||
- name: Build WWW service (all locales)
|
||||
run: pnpm --filter @ermnvldmr/www build:all
|
||||
env:
|
||||
RSDOCTOR: 'true'
|
||||
- name: Upload Rsdoctor report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rsdoctor-report
|
||||
path: services/www/rsdoctor/
|
||||
retention-days: 30
|
||||
|
||||
release-docker:
|
||||
name: Release - Docker Image
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
name: services/www - pr/required
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [next]
|
||||
paths:
|
||||
- 'services/www/**'
|
||||
- 'packages/ui/**'
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/services-www-pr-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ci-pr-www-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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: Build Storybook config
|
||||
run: pnpm --filter @ermnvldmr/storybook-config build
|
||||
- name: Upload Rsbuild Config
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
retention-days: 1
|
||||
- name: Upload Storybook Config
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
retention-days: 1
|
||||
- name: Build i18n package
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload i18n dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-www:
|
||||
name: Lint - WWW
|
||||
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 Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Lint WWW service
|
||||
run: pnpm --filter @ermnvldmr/www lint
|
||||
|
||||
typecheck-www:
|
||||
name: Typecheck - WWW
|
||||
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 Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Typecheck WWW service
|
||||
run: pnpm --filter @ermnvldmr/www typecheck
|
||||
|
||||
build-storybook:
|
||||
name: Build - Storybook
|
||||
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 Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Build WWW Storybook
|
||||
run: pnpm --filter @ermnvldmr/www build-storybook
|
||||
- name: Upload Storybook build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: www-storybook-static-${{ github.run_id }}
|
||||
path: services/www/storybook-static/
|
||||
retention-days: 1
|
||||
|
||||
test-www:
|
||||
name: Test - Unit
|
||||
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 Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Test WWW service
|
||||
run: pnpm --filter @ermnvldmr/www test:coverage
|
||||
- name: Upload Unit coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: ./services/www/coverage/lcov.info
|
||||
flags: www
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
test-storybook:
|
||||
name: Test - Storybook
|
||||
needs: build-storybook
|
||||
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 Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm --filter @ermnvldmr/www exec playwright install --with-deps chromium
|
||||
- name: Download Storybook build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: www-storybook-static-${{ github.run_id }}
|
||||
path: services/www/storybook-static/
|
||||
- name: Serve Storybook and run tests with coverage
|
||||
run: pnpm --filter @ermnvldmr/www test:storybook:ci
|
||||
- name: Upload Storybook coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: ./services/www/coverage/storybook/lcov.info
|
||||
flags: www-storybook
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
build-www:
|
||||
name: Build - WWW Service
|
||||
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 Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Build WWW service (all locales)
|
||||
run: pnpm --filter @ermnvldmr/www build:all
|
||||
env:
|
||||
RSDOCTOR: 'true'
|
||||
- name: Upload Rsdoctor report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rsdoctor-report-${{ github.run_id }}
|
||||
path: services/www/rsdoctor/
|
||||
retention-days: 7
|
||||
@@ -54,6 +54,8 @@ jobs:
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Build Storybook config
|
||||
run: pnpm --filter @ermnvldmr/storybook-config build
|
||||
- name: Build i18n
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload Config Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -61,10 +63,11 @@ jobs:
|
||||
path: |
|
||||
packages/rsbuild-config/dist
|
||||
packages/storybook-config/dist
|
||||
packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-www:
|
||||
name: Lint - WWW & Deps
|
||||
name: Lint - WWW
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -84,15 +87,11 @@ jobs:
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
- name: Lint STL package
|
||||
run: pnpm --filter @ermnvldmr/stl lint
|
||||
- name: Lint UI package
|
||||
run: pnpm --filter @ermnvldmr/ui lint
|
||||
- name: Lint WWW service
|
||||
run: pnpm --filter @ermnvldmr/www lint
|
||||
|
||||
typecheck-www:
|
||||
name: Typecheck - WWW & Deps
|
||||
name: Typecheck - WWW
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -112,10 +111,6 @@ jobs:
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
- name: Typecheck STL package
|
||||
run: pnpm --filter @ermnvldmr/stl typecheck
|
||||
- name: Typecheck UI package
|
||||
run: pnpm --filter @ermnvldmr/ui typecheck
|
||||
- name: Typecheck WWW service
|
||||
run: pnpm --filter @ermnvldmr/www typecheck
|
||||
|
||||
@@ -170,10 +165,6 @@ jobs:
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
- name: Test STL package
|
||||
run: pnpm --filter @ermnvldmr/stl test:coverage
|
||||
- name: Test UI package
|
||||
run: pnpm --filter @ermnvldmr/ui test:coverage
|
||||
- name: Test WWW service
|
||||
run: pnpm --filter @ermnvldmr/www test:coverage
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ jobs:
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Build Storybook config
|
||||
run: pnpm --filter @ermnvldmr/storybook-config build
|
||||
- name: Build i18n
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload Config Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -67,10 +69,11 @@ jobs:
|
||||
path: |
|
||||
packages/rsbuild-config/dist
|
||||
packages/storybook-config/dist
|
||||
packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-www:
|
||||
name: Lint - WWW & Deps
|
||||
name: Lint - WWW
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -90,15 +93,11 @@ jobs:
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
- name: Lint STL package
|
||||
run: pnpm --filter @ermnvldmr/stl lint
|
||||
- name: Lint UI package
|
||||
run: pnpm --filter @ermnvldmr/ui lint
|
||||
- name: Lint WWW service
|
||||
run: pnpm --filter @ermnvldmr/www lint
|
||||
|
||||
typecheck-www:
|
||||
name: Typecheck - WWW & Deps
|
||||
name: Typecheck - WWW
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -118,10 +117,6 @@ jobs:
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
- name: Typecheck STL package
|
||||
run: pnpm --filter @ermnvldmr/stl typecheck
|
||||
- name: Typecheck UI package
|
||||
run: pnpm --filter @ermnvldmr/ui typecheck
|
||||
- name: Typecheck WWW service
|
||||
run: pnpm --filter @ermnvldmr/www typecheck
|
||||
|
||||
@@ -176,10 +171,6 @@ jobs:
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
- name: Test STL package
|
||||
run: pnpm --filter @ermnvldmr/stl test:coverage
|
||||
- name: Test UI package
|
||||
run: pnpm --filter @ermnvldmr/ui test:coverage
|
||||
- name: Test WWW service
|
||||
run: pnpm --filter @ermnvldmr/www test:coverage
|
||||
|
||||
|
||||
@@ -30,3 +30,4 @@ playwright-report/
|
||||
|
||||
# agents plans and designs
|
||||
docs/plans/
|
||||
docs/superpowers/
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "services/docs/themes/hextra"]
|
||||
path = services/docs/themes/hextra
|
||||
url = https://github.com/imfing/hextra
|
||||
@@ -12,20 +12,38 @@ server {
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
|
||||
|
||||
# Clean URLs (remove .html from URI)
|
||||
location / {
|
||||
try_files $uri $uri/ $uri.html /index.html;
|
||||
# Root redirect: detect browser language, fallback to /en/
|
||||
location = / {
|
||||
set $locale "en";
|
||||
if ($http_accept_language ~* "(^|,\s*)ru") {
|
||||
set $locale "ru";
|
||||
}
|
||||
return 302 /$locale/;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
# Cache hashed static assets (JS, CSS, fonts) — matched before locale prefix blocks
|
||||
# root is inherited from server block, so /en/static/js/file.js resolves correctly
|
||||
location ~* \.(js|css|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Cache images
|
||||
location ~* \.(png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 404 /404.html;
|
||||
location = /404.html {
|
||||
internal;
|
||||
# English locale — SPA fallback for non-asset routes
|
||||
location /en/ {
|
||||
try_files $uri $uri/ $uri.html /en/index.html;
|
||||
}
|
||||
|
||||
# Russian locale — SPA fallback for non-asset routes
|
||||
location /ru/ {
|
||||
try_files $uri $uri/ $uri.html /ru/index.html;
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 404 /en/index.html;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ COPY packages/rsbuild-config/package.json ./packages/rsbuild-config/
|
||||
COPY packages/stl/package.json ./packages/stl/
|
||||
COPY packages/storybook-config/package.json ./packages/storybook-config/
|
||||
COPY packages/ui/package.json ./packages/ui/
|
||||
COPY packages/i18n/package.json ./packages/i18n/
|
||||
COPY services/www/package.json ./services/www/
|
||||
|
||||
# Install dependencies
|
||||
@@ -27,10 +28,11 @@ COPY . .
|
||||
# Build dependencies
|
||||
RUN pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
RUN pnpm --filter @ermnvldmr/stl build
|
||||
RUN pnpm --filter @ermnvldmr/i18n build
|
||||
RUN pnpm --filter @ermnvldmr/ui build
|
||||
|
||||
# Build the website
|
||||
RUN pnpm --filter @ermnvldmr/www build
|
||||
# Build the website (all locales)
|
||||
RUN pnpm --filter @ermnvldmr/www build:all
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM nginx:alpine
|
||||
|
||||
+12
-6
@@ -2,30 +2,36 @@ codecov:
|
||||
notify:
|
||||
wait_for_ci: true
|
||||
|
||||
ignore:
|
||||
- 'packages/eslint-config'
|
||||
- 'packages/rsbuild-config'
|
||||
- 'packages/storybook-config'
|
||||
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: auto
|
||||
target: 80%
|
||||
threshold: 1%
|
||||
range: 10%
|
||||
stl:
|
||||
target: auto
|
||||
target: 80%
|
||||
flags:
|
||||
- stl
|
||||
ui:
|
||||
target: auto
|
||||
target: 80%
|
||||
flags:
|
||||
- ui
|
||||
ui-storybook:
|
||||
target: auto
|
||||
target: 80%
|
||||
flags:
|
||||
- ui-storybook
|
||||
www:
|
||||
target: auto
|
||||
target: 80%
|
||||
flags:
|
||||
- www
|
||||
www-storybook:
|
||||
target: auto
|
||||
target: 80%
|
||||
flags:
|
||||
- www-storybook
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ export const jsdocConfig = [
|
||||
},
|
||||
],
|
||||
'jsdoc/require-description': 'error',
|
||||
'jsdoc/require-param': 'error',
|
||||
'jsdoc/require-returns': 'error',
|
||||
'jsdoc/require-param': 'off',
|
||||
'jsdoc/require-returns': 'off',
|
||||
'jsdoc/require-example': 'warn',
|
||||
'jsdoc/check-alignment': 'error',
|
||||
'jsdoc/check-indentation': 'error',
|
||||
|
||||
@@ -44,10 +44,7 @@ export function getTypescriptConfig(rootDir) {
|
||||
assertionStyle: 'never',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/explicit-function-return-type': [
|
||||
'error',
|
||||
{ allowExpressions: true, allowTypedFunctionExpressions: true },
|
||||
],
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
},
|
||||
},
|
||||
// Allow triple-slash references in `*.d.ts` files.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@ermnvldmr/i18n",
|
||||
"version": "1.0.0",
|
||||
"description": "Internationalization utilities for ermnvldmr projects",
|
||||
"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"
|
||||
},
|
||||
"./testing": {
|
||||
"import": "./dist/testing.js",
|
||||
"types": "./dist/testing.d.ts",
|
||||
"default": "./src/testing.ts"
|
||||
}
|
||||
},
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "rsbuild build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rm -rf dist coverage"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ermnvldmr/eslint-config": "workspace:*",
|
||||
"@ermnvldmr/rsbuild-config": "workspace:*",
|
||||
"@rsbuild/core": "^1.2.11",
|
||||
"@types/react": "^19.1.2",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"react": "^19.1.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { definePackageConfig } from '@ermnvldmr/rsbuild-config/dev';
|
||||
|
||||
export default definePackageConfig({
|
||||
source: {
|
||||
entry: {
|
||||
index: './src/index.ts',
|
||||
dev: './src/dev.ts',
|
||||
testing: './src/testing.ts',
|
||||
},
|
||||
alias: { '@': './src' },
|
||||
},
|
||||
output: {
|
||||
target: 'web',
|
||||
distPath: { root: 'dist', js: '' },
|
||||
filename: { js: '[name].js' },
|
||||
cleanDistPath: true,
|
||||
},
|
||||
tools: {
|
||||
htmlPlugin: false,
|
||||
rspack: {
|
||||
output: { library: { type: 'module' } },
|
||||
experiments: { outputModule: true },
|
||||
externals: ['react', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
|
||||
externalsType: 'module',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { DEFAULT_LOCALE, isLocale, type Locale } from './lib/locale.js';
|
||||
|
||||
import type { RsbuildConfig, RsbuildPlugin } from '@rsbuild/core';
|
||||
|
||||
/**
|
||||
* Returns a partial Rsbuild config that enables locale-aware builds.
|
||||
*
|
||||
* The locale MDX replacement is provided as an Rsbuild plugin (rather than a
|
||||
* `tools.rspack` function) so that `mergeConfig` can safely concatenate it
|
||||
* with the service's own `plugins` array. A `tools.rspack` function would be
|
||||
* silently dropped by `mergeConfig` when the service config also defines
|
||||
* `tools.rspack` as a plain object.
|
||||
*
|
||||
* Designed to be merged with defineServiceConfig() in each service's rsbuild.config.ts:
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { localeRsbuildConfig } from '@ermnvldmr/i18n/dev';
|
||||
* import { defineServiceConfig, mergeConfig } from '@ermnvldmr/rsbuild-config/dev';
|
||||
*
|
||||
* export default defineServiceConfig(mergeConfig(localeRsbuildConfig(), { ... }));
|
||||
* ```
|
||||
*/
|
||||
export function localeRsbuildConfig(): RsbuildConfig {
|
||||
const rawLocale = process.env.LOCALE ?? '';
|
||||
const locale: Locale = isLocale(rawLocale) ? rawLocale : DEFAULT_LOCALE;
|
||||
|
||||
const localePlugins: RsbuildPlugin[] = [];
|
||||
|
||||
if (locale !== DEFAULT_LOCALE) {
|
||||
localePlugins.push({
|
||||
name: 'ermnvldmr:locale-mdx-replacement',
|
||||
setup(api) {
|
||||
// Resolve .en.mdx imports to the locale-specific file when LOCALE != 'en'.
|
||||
// The canonical import is always `.en.mdx`; other locales are resolved here.
|
||||
api.modifyRspackConfig((config, { rspack }) => {
|
||||
config.plugins = [
|
||||
...(config.plugins ?? []),
|
||||
new rspack.NormalModuleReplacementPlugin(
|
||||
/\.en\.mdx$/,
|
||||
(resource: { request: string }) => {
|
||||
resource.request = resource.request.replace(/\.en\.mdx$/, `.${locale}.mdx`);
|
||||
}
|
||||
),
|
||||
];
|
||||
return config;
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
source: {
|
||||
define: {
|
||||
'process.env.LOCALE': JSON.stringify(locale),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// Dev server serves pages under the locale base so localePath() links
|
||||
// resolve correctly in dev without any NODE_ENV branching.
|
||||
base: `/${locale}`,
|
||||
},
|
||||
output: {
|
||||
distPath: {
|
||||
root: `dist/${locale}`,
|
||||
},
|
||||
assetPrefix: `/${locale}/`,
|
||||
},
|
||||
plugins: localePlugins,
|
||||
};
|
||||
}
|
||||
|
||||
export { LOCALES, DEFAULT_LOCALE, type Locale } from './lib/locale.js';
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './lib/locale.js';
|
||||
export * from './lib/pluralize.js';
|
||||
export * from './lib/keyset.js';
|
||||
export * from './lib/path.js';
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { createKeyset } from './keyset.js';
|
||||
|
||||
const { t, tRaw } = createKeyset({
|
||||
greeting: { en: 'Hello', ru: 'Привет' },
|
||||
'count {value}': { en: 'count {value}', ru: 'счёт {value}' },
|
||||
items: {
|
||||
en: { none: 'no items', one: 'one item', some: '{count} items', many: '{count} items' },
|
||||
ru: { none: 'нет элементов', one: 'один элемент', some: '{count} элемента', many: '{count} элементов' },
|
||||
},
|
||||
});
|
||||
|
||||
describe('t', () => {
|
||||
it('returns plain translation', () => expect(t('greeting')).toBe('Hello'));
|
||||
it('interpolates {value}', () => expect(t('count {value}', { value: '42' })).toBe('count 42'));
|
||||
it('pluralizes none', () => expect(t('items', { count: 0 })).toBe('no items'));
|
||||
it('pluralizes one', () => expect(t('items', { count: 1 })).toBe('one item'));
|
||||
it('pluralizes many with interpolation', () =>
|
||||
expect(t('items', { count: 5 })).toBe('5 items'));
|
||||
});
|
||||
|
||||
describe('tRaw', () => {
|
||||
it('returns string node for plain key', () => expect(tRaw('greeting')).toBe('Hello'));
|
||||
it('interpolates ReactNode', () => {
|
||||
const node = tRaw('count {value}', { value: React.createElement('strong', null, '42') });
|
||||
expect(Array.isArray(node)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { LOCALE } from './locale.js';
|
||||
import { pluralize, type PluralForms } from './pluralize.js';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/** A translation entry with a plain string per locale. */
|
||||
interface PlainTranslation { en: string; ru: string }
|
||||
|
||||
/** A translation entry with plural forms per locale. */
|
||||
interface PluralTranslation { en: PluralForms; ru: PluralForms }
|
||||
|
||||
/** Union of all supported translation shapes. */
|
||||
type Translation = PlainTranslation | PluralTranslation;
|
||||
|
||||
/** A map of translation keys to their translation entries. */
|
||||
type Keyset = Record<string, Translation>;
|
||||
|
||||
/**
|
||||
* Returns `true` when the translation entry uses plural forms.
|
||||
*
|
||||
* @param t - The translation entry to inspect.
|
||||
* @returns Type predicate narrowing `t` to {@link PluralTranslation}.
|
||||
* @example
|
||||
* isPluralTranslation({ en: 'hello', ru: 'привет' }) // → false
|
||||
*/
|
||||
function isPluralTranslation(t: Translation): t is PluralTranslation {
|
||||
return typeof t.en === 'object';
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces `{key}` placeholders in `template` with values from `params`.
|
||||
*
|
||||
* @param template - String containing `{key}` placeholders.
|
||||
* @param params - Map of placeholder names to string/number values.
|
||||
* @returns The interpolated string.
|
||||
* @example
|
||||
* interpolate('Hello {name}!', { name: 'World' }) // → 'Hello World!'
|
||||
*/
|
||||
function interpolate(template: string, params: Record<string, string | number>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (_, key: string) =>
|
||||
key in params ? String(params[key]) : `{${key}}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits `template` on `{key}` placeholders and substitutes ReactNode values.
|
||||
*
|
||||
* @param template - String containing `{key}` placeholders.
|
||||
* @param params - Map of placeholder names to ReactNode values.
|
||||
* @returns A single string when all parts are strings, otherwise a ReactNode array.
|
||||
* @example
|
||||
* interpolateRaw('Click {link}', { link: <a href="/">here</a> })
|
||||
*/
|
||||
function interpolateRaw(template: string, params: Record<string, ReactNode>): ReactNode {
|
||||
const parts = template.split(/(\{[^}]+\})/g);
|
||||
const result = parts.map((part) => {
|
||||
const match = /^\{(\w+)\}$/.exec(part);
|
||||
if (match) {
|
||||
const key = match[1];
|
||||
return key in params ? params[key] : part;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
if (result.every((p): p is string => typeof p === 'string')) {
|
||||
return result.join('');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a typed `t` and `tRaw` pair bound to `keyset`.
|
||||
*
|
||||
* @param keyset - An object mapping translation keys to their locale entries.
|
||||
* @returns `{ t, tRaw }` translation helpers.
|
||||
* @example
|
||||
* const { t } = createKeyset({ greeting: { en: 'Hello', ru: 'Привет' } });
|
||||
* t('greeting') // → 'Hello' (in EN build)
|
||||
*/
|
||||
export function createKeyset<const K extends Keyset>(keyset: K) {
|
||||
/** String union of all keys defined in `keyset`. */
|
||||
type Keys = keyof K & string;
|
||||
|
||||
/**
|
||||
* Returns the translated string for `key`, with optional interpolation and pluralisation.
|
||||
*
|
||||
* @param key - A key defined in the keyset.
|
||||
* @param params - Optional interpolation/pluralisation params.
|
||||
* @returns The translated string for the current build locale.
|
||||
* @example
|
||||
* t('greeting') // → 'Hello'
|
||||
* t('items {count}', { count: 3 }) // → '3 items'
|
||||
*/
|
||||
function t(key: Keys, params?: Record<string, string | number>): string {
|
||||
const entry = keyset[key];
|
||||
|
||||
if (isPluralTranslation(entry)) {
|
||||
const forms = entry[LOCALE];
|
||||
const count = typeof params?.count === 'number' ? params.count : 0;
|
||||
const template = pluralize(count, forms);
|
||||
return params ? interpolate(template, params) : template;
|
||||
}
|
||||
|
||||
const template = entry[LOCALE];
|
||||
return params ? interpolate(template, params) : template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the translated ReactNode for `key`, allowing ReactNode interpolation values.
|
||||
*
|
||||
* @param key - A key defined in the keyset.
|
||||
* @param params - Optional interpolation params; values may be any ReactNode.
|
||||
* @returns The translated string or ReactNode array for the current build locale.
|
||||
* @example
|
||||
* tRaw('click {link}', { link: <a href="/">here</a> })
|
||||
*/
|
||||
function tRaw(key: Keys, params?: Record<string, ReactNode>): ReactNode {
|
||||
const entry = keyset[key];
|
||||
|
||||
if (isPluralTranslation(entry)) {
|
||||
const forms = entry[LOCALE];
|
||||
const countParam = params?.count;
|
||||
const count = typeof countParam === 'number' ? countParam : 0;
|
||||
const template = pluralize(count, forms);
|
||||
return params ? interpolateRaw(template, params) : template;
|
||||
}
|
||||
|
||||
const template = entry[LOCALE];
|
||||
return params ? interpolateRaw(template, params) : template;
|
||||
}
|
||||
|
||||
return { t, tRaw };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/** The supported locale identifiers. */
|
||||
export type Locale = 'en' | 'ru';
|
||||
|
||||
export const LOCALES: readonly Locale[] = ['en', 'ru'] as const;
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = 'en';
|
||||
|
||||
// Widened once so inclusion checks need no type assertions.
|
||||
const LOCALES_STRINGS: readonly string[] = LOCALES;
|
||||
|
||||
/**
|
||||
* Returns `true` when `value` is a known {@link Locale} identifier.
|
||||
*
|
||||
* @param value - The string to test.
|
||||
* @returns Type predicate narrowing `value` to `Locale`.
|
||||
* @example
|
||||
* isLocale('en') // → true
|
||||
* isLocale('fr') // → false
|
||||
*/
|
||||
export function isLocale(value: string): value is Locale {
|
||||
return LOCALES_STRINGS.includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The active locale for the current build.
|
||||
* Injected at compile time via source.define → process.env.LOCALE.
|
||||
* Falls back to DEFAULT_LOCALE in non-locale-aware builds (tests, Storybook).
|
||||
*/
|
||||
const rawLocale = process.env.LOCALE ?? '';
|
||||
export const LOCALE: Locale = isLocale(rawLocale) ? rawLocale : DEFAULT_LOCALE;
|
||||
|
||||
/**
|
||||
* The other (non-active) locale for the current build.
|
||||
* Constant-folded at build time.
|
||||
*/
|
||||
export const OTHER_LOCALE: Locale = LOCALES.find((l) => l !== LOCALE) ?? DEFAULT_LOCALE;
|
||||
|
||||
/**
|
||||
* Human-readable label for switching to the other locale,
|
||||
* expressed in the CURRENT locale's language.
|
||||
* - EN build → "In Russian"
|
||||
* - RU build → "На Английском"
|
||||
*/
|
||||
const SWITCH_LABELS: Record<Locale, Partial<Record<Locale, string>>> = {
|
||||
en: { ru: 'In Russian' },
|
||||
ru: { en: 'На Английском' },
|
||||
};
|
||||
|
||||
export const OTHER_LOCALE_LABEL: string =
|
||||
SWITCH_LABELS[LOCALE][OTHER_LOCALE] ?? OTHER_LOCALE.toUpperCase();
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { localePath, localeHref } from './path.js';
|
||||
|
||||
describe('localePath', () => {
|
||||
it('prepends LOCALE to absolute path', () =>
|
||||
expect(localePath('/articles')).toBe('/en/articles'));
|
||||
it('handles root path', () =>
|
||||
expect(localePath('/')).toBe('/en/'));
|
||||
it('handles nested path', () =>
|
||||
expect(localePath('/articles/2026/test')).toBe('/en/articles/2026/test'));
|
||||
});
|
||||
|
||||
describe('localeHref', () => {
|
||||
it('replaces locale prefix in URL', () =>
|
||||
expect(localeHref('/en/articles', 'ru')).toBe('/ru/articles'));
|
||||
it('replaces locale at root', () =>
|
||||
expect(localeHref('/en/', 'ru')).toBe('/ru/'));
|
||||
it('falls back to path if no prefix found', () =>
|
||||
expect(localeHref('/articles', 'ru')).toBe('/ru/articles'));
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { LOCALE, LOCALES, type Locale } from './locale.js';
|
||||
|
||||
/**
|
||||
* Resolves any href value to its locale-aware form.
|
||||
*
|
||||
* Only absolute internal paths (starting with `/`) are rewritten; everything else
|
||||
* (external URLs, mailto/tel, relative paths, hash anchors) passes through unchanged.
|
||||
* The function is idempotent: paths already carrying any known locale prefix are returned as-is.
|
||||
*
|
||||
* Works identically in dev and production: the dev server is configured with
|
||||
* `server.base = /${LOCALE}` via `localeRsbuildConfig`.
|
||||
*
|
||||
* @param href - The href value to resolve.
|
||||
* @returns The locale-prefixed href, or the original value when no rewrite is needed.
|
||||
* @example
|
||||
* // LOCALE = 'en'
|
||||
* localePath('/articles') // → '/en/articles'
|
||||
* localePath('/') // → '/en/'
|
||||
* localePath('/en/articles') // → '/en/articles' (idempotent)
|
||||
* localePath('https://github.com') // → 'https://github.com' (external)
|
||||
* localePath('mailto:a@b.com') // → 'mailto:a@b.com'
|
||||
* localePath('#section') // → '#section'
|
||||
*/
|
||||
export function localePath(href: string): string;
|
||||
export function localePath(href: null): null;
|
||||
export function localePath(href: undefined): undefined;
|
||||
export function localePath(href: string | undefined): string | undefined;
|
||||
export function localePath(href: string | null): string | null;
|
||||
export function localePath(href: string | null | undefined): string | null | undefined;
|
||||
export function localePath(href: string | null | undefined): string | null | undefined {
|
||||
if (href == null) return href;
|
||||
if (!href.startsWith('/')) return href;
|
||||
if (LOCALES.some((l) => href === `/${l}` || href === `/${l}/` || href.startsWith(`/${l}/`))) {
|
||||
return href;
|
||||
}
|
||||
if (href === '/') return `/${LOCALE}/`;
|
||||
return `/${LOCALE}${href}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a URL for a specific locale given the current locale-prefixed URL.
|
||||
*
|
||||
* @param currentPath - The current page path, e.g. `/en/articles`.
|
||||
* @param locale - The target locale.
|
||||
* @returns The equivalent path for `locale`, e.g. `/ru/articles`.
|
||||
* @example
|
||||
* localeHref('/en/articles', 'ru') // → '/ru/articles'
|
||||
* localeHref('/en/', 'ru') // → '/ru/'
|
||||
*/
|
||||
export function localeHref(currentPath: string, locale: Locale): string {
|
||||
const localePrefix = new RegExp(`^\\/(${LOCALES.join('|')})(.*)`);
|
||||
const match = localePrefix.exec(currentPath);
|
||||
if (match) {
|
||||
return `/${locale}${match[2] || '/'}`;
|
||||
}
|
||||
return `/${locale}${currentPath}`;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { pluralize } from './pluralize.js';
|
||||
|
||||
const forms = { none: 'none', one: 'one', some: 'some', many: 'many' };
|
||||
|
||||
describe('pluralize', () => {
|
||||
it('returns none for 0', () => expect(pluralize(0, forms)).toBe('none'));
|
||||
it('returns one for 1', () => expect(pluralize(1, forms)).toBe('one'));
|
||||
it('returns some for 2', () => expect(pluralize(2, forms)).toBe('some'));
|
||||
it('returns some for 4', () => expect(pluralize(4, forms)).toBe('some'));
|
||||
it('returns many for 5', () => expect(pluralize(5, forms)).toBe('many'));
|
||||
it('returns many for 11', () => expect(pluralize(11, forms)).toBe('many'));
|
||||
it('returns many for 12', () => expect(pluralize(12, forms)).toBe('many'));
|
||||
it('returns many for 100', () => expect(pluralize(100, forms)).toBe('many'));
|
||||
it('returns one for 21', () => expect(pluralize(21, forms)).toBe('one'));
|
||||
it('returns some for 22', () => expect(pluralize(22, forms)).toBe('some'));
|
||||
it('returns many for 111', () => expect(pluralize(111, forms)).toBe('many'));
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/** The four plural categories used across EN and RU grammar rules. */
|
||||
export interface PluralForms {
|
||||
none: string;
|
||||
one: string;
|
||||
some: string;
|
||||
many: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the correct plural form for the given count.
|
||||
* Covers both English (none/one/many) and Russian (none/one/some/many) rules.
|
||||
*
|
||||
* @param count - The numeric value to pluralise.
|
||||
* @param forms - The set of plural forms to select from.
|
||||
* @returns The appropriate plural form string.
|
||||
* @example
|
||||
* const forms = { none: 'no items', one: 'one item', some: '{n} items', many: '{n} items' };
|
||||
* pluralize(0, forms) // → 'no items'
|
||||
* pluralize(1, forms) // → 'one item'
|
||||
* pluralize(3, forms) // → '{n} items' (some)
|
||||
* pluralize(21, forms) // → 'one item' (Russian rule: 21 ends in 1)
|
||||
*/
|
||||
export function pluralize(count: number, forms: PluralForms): string {
|
||||
if (count === 0) return forms.none;
|
||||
const mod10 = count % 10;
|
||||
const mod100 = count % 100;
|
||||
if (mod10 === 1 && mod100 !== 11) return forms.one;
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return forms.some;
|
||||
return forms.many;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { LOCALE, DEFAULT_LOCALE, isLocale } from './lib/locale.js';
|
||||
export { localePath, localeHref } from './lib/path.js';
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -12,10 +12,18 @@
|
||||
"dependencies": {
|
||||
"@rsbuild/plugin-react": "^1.1.1",
|
||||
"@rsbuild/plugin-type-check": "^1.1.1",
|
||||
"critters": "^0.0.25",
|
||||
"fast-glob": "^3.3.3",
|
||||
"jsdom": "^28.0.0",
|
||||
"react": "^19.1.0",
|
||||
"rsbuild-plugin-dts": "^0.19.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rsbuild/core": "^1.7.3",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"react-dom": "^19.1.0",
|
||||
"typescript": "^5.8.2",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"vitest": "^4.0.18"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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,2 +1,3 @@
|
||||
export * from './rsbuild.js';
|
||||
export * from './vitest.js';
|
||||
export * from './entries.js';
|
||||
@@ -3,6 +3,9 @@ import { pluginReact } from '@rsbuild/plugin-react';
|
||||
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).
|
||||
*
|
||||
@@ -18,13 +21,25 @@ import { pluginDts } from 'rsbuild-plugin-dts';
|
||||
* ```
|
||||
*/
|
||||
export function defineServiceConfig(config: RsbuildConfig = {}): RsbuildConfig {
|
||||
return defineConfig({
|
||||
plugins: [pluginReact(), pluginTypeCheck()],
|
||||
const defaultConfig: RsbuildConfig = {
|
||||
plugins: [pluginReact(), pluginTypeCheck(), ssgPlugin()],
|
||||
output: {
|
||||
distPath: {
|
||||
font: 'static/font',
|
||||
},
|
||||
filename: {
|
||||
font: '[name][ext]',
|
||||
},
|
||||
},
|
||||
html: {
|
||||
template: './src/static/index.html',
|
||||
meta: {
|
||||
description: 'Thoughts and creations of mine',
|
||||
},
|
||||
},
|
||||
...config,
|
||||
});
|
||||
};
|
||||
|
||||
return defineConfig(mergeConfig(defaultConfig, config));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,7 +57,7 @@ export function defineServiceConfig(config: RsbuildConfig = {}): RsbuildConfig {
|
||||
* ```
|
||||
*/
|
||||
export function definePackageConfig(config: RsbuildConfig = {}): RsbuildConfig {
|
||||
return defineConfig({
|
||||
const defaultConfig: RsbuildConfig = {
|
||||
plugins: [pluginReact(), pluginTypeCheck(), pluginDts()],
|
||||
output: {
|
||||
distPath: {
|
||||
@@ -54,6 +69,7 @@ export function definePackageConfig(config: RsbuildConfig = {}): RsbuildConfig {
|
||||
tools: {
|
||||
htmlPlugin: false,
|
||||
},
|
||||
...config,
|
||||
});
|
||||
};
|
||||
|
||||
return defineConfig(mergeConfig(defaultConfig, config));
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
|
||||
import React from 'react';
|
||||
import type { RsbuildPlugin } from '@rsbuild/core';
|
||||
|
||||
/**
|
||||
* Rsbuild plugin for build-time Static Site Generation.
|
||||
*
|
||||
* After the web build completes:
|
||||
* 1. Builds each entry as a Node.js CJS bundle (dist-ssr/)
|
||||
* 2. Renders each entry with react-dom/server renderToString
|
||||
* 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/
|
||||
*/
|
||||
export function ssgPlugin(): RsbuildPlugin {
|
||||
return {
|
||||
name: 'ermnvldmr:ssg-plugin',
|
||||
setup(api) {
|
||||
api.onAfterBuild(async ({ stats }) => {
|
||||
if (!stats) {
|
||||
console.warn('[ssg-plugin] No stats available, skipping SSG.');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = api.getRsbuildConfig();
|
||||
const rawEntries = config.source?.entry;
|
||||
|
||||
if (!rawEntries || typeof rawEntries !== 'object' || Array.isArray(rawEntries)) {
|
||||
console.warn('[ssg-plugin] source.entry is not an object map, skipping SSG.');
|
||||
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) {
|
||||
const imp = (value as { import: string | string[] }).import;
|
||||
entries[name] = Array.isArray(imp) ? imp[0] : imp;
|
||||
}
|
||||
}
|
||||
|
||||
const entryNames = Object.keys(entries);
|
||||
if (entryNames.length === 0) {
|
||||
console.warn('[ssg-plugin] No entries to process, skipping SSG.');
|
||||
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 =
|
||||
(api.context as { distPath?: string }).distPath ??
|
||||
path.join(
|
||||
api.context.rootPath,
|
||||
(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;
|
||||
const name = (p as { name?: string }).name;
|
||||
return name !== SSG_PLUGIN_NAME && name !== TYPE_CHECK_PLUGIN_NAME;
|
||||
});
|
||||
|
||||
const ssrBuild = await createRsbuild({
|
||||
rsbuildConfig: {
|
||||
plugins: ssrPlugins,
|
||||
source: { entry: entries },
|
||||
tools: config.tools,
|
||||
output: {
|
||||
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',
|
||||
'react/jsx-runtime': 'commonjs react/jsx-runtime',
|
||||
'react/jsx-dev-runtime': 'commonjs react/jsx-dev-runtime',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const Critters = _require('critters') as new (opts?: {
|
||||
path?: string;
|
||||
publicPath?: string;
|
||||
preload?: string;
|
||||
inlineFonts?: boolean;
|
||||
pruneSource?: boolean;
|
||||
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 =
|
||||
(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)
|
||||
});
|
||||
|
||||
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];
|
||||
|
||||
const mod = _require(ssrBundlePath) as { default?: React.ComponentType };
|
||||
const Component = mod.default;
|
||||
|
||||
if (!Component) {
|
||||
console.warn(`[ssg-plugin] Entry "${entryName}" has no default export — skipping.`);
|
||||
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>/,
|
||||
`<div id="root">${renderedHtml}</div>`
|
||||
);
|
||||
|
||||
if (injected === source) {
|
||||
console.warn(
|
||||
`[ssg-plugin] Could not find <div id="root"> in ${webHtmlPath} — injection skipped.`
|
||||
);
|
||||
}
|
||||
|
||||
// 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="[^"]*"/,
|
||||
`<html$1 lang="${locale}"`
|
||||
);
|
||||
|
||||
await fs.writeFile(webHtmlPath, localized, 'utf8');
|
||||
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.');
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from './config/index.js';
|
||||
export { mergeConfig } from './utils/merge.js';
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Minimal deep merge utility for Rsbuild configurations.
|
||||
* Concatenates arrays and recursively merges objects.
|
||||
*
|
||||
* @param source - The source object
|
||||
* @param target - The target object to merge into source
|
||||
* @returns The merged object
|
||||
*/
|
||||
export function mergeConfig<T>(source: any, target: any): T {
|
||||
if (target === undefined) return source;
|
||||
if (source === undefined) return target;
|
||||
|
||||
if (Array.isArray(source) && Array.isArray(target)) {
|
||||
return [...source, ...target] as any;
|
||||
}
|
||||
|
||||
if (
|
||||
source !== null &&
|
||||
typeof source === 'object' &&
|
||||
!Array.isArray(source) &&
|
||||
target !== null &&
|
||||
typeof target === 'object' &&
|
||||
!Array.isArray(target)
|
||||
) {
|
||||
const result = { ...source };
|
||||
for (const key in target) {
|
||||
if (Object.prototype.hasOwnProperty.call(target, key)) {
|
||||
result[key] = mergeConfig(source[key], target[key]);
|
||||
}
|
||||
}
|
||||
return result as any;
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
@@ -8,15 +8,18 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./testing": {
|
||||
"import": "./dist/testing.js",
|
||||
"types": "./dist/testing.d.ts"
|
||||
"types": "./dist/testing.d.ts",
|
||||
"default": "./src/testing.ts"
|
||||
},
|
||||
"./dev": {
|
||||
"import": "./dist/dev.js",
|
||||
"types": "./dist/dev.d.ts"
|
||||
"types": "./dist/dev.d.ts",
|
||||
"default": "./src/dev.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { extendTailwindMerge } from 'tailwind-merge';
|
||||
|
||||
const RB_TYPES = ['display', 'headline', 'title', 'body', 'label'] as const;
|
||||
const RB_SIZES = ['l', 'm', 's'] as const;
|
||||
|
||||
const rbFontSizeClasses = RB_TYPES.flatMap((type) =>
|
||||
RB_SIZES.map((size) => `${type}-${size}`)
|
||||
);
|
||||
|
||||
const twMerge = extendTailwindMerge({
|
||||
extend: {
|
||||
classGroups: {
|
||||
'font-size': [{ 'text-rb': rbFontSizeClasses }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Combines and merges CSS class names using clsx and tailwind-merge.
|
||||
@@ -7,6 +22,10 @@ import { twMerge } from 'tailwind-merge';
|
||||
* This utility function combines the power of clsx for conditional class handling
|
||||
* with tailwind-merge for proper Tailwind CSS class deduplication and conflict resolution.
|
||||
*
|
||||
* Tailwind-merge is configured to recognise `text-rb-{type}-{size}` classes
|
||||
* (e.g. `text-rb-body-m`) as font-size utilities so they do not conflict with
|
||||
* text-color classes such as `text-[var(--rb-text)]`.
|
||||
*
|
||||
* @param inputs - Class values to combine (strings, objects, arrays, etc.)
|
||||
* @returns Merged class string with conflicts resolved
|
||||
*
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './types/index.js';
|
||||
export * from './functions/index.js';
|
||||
export * from './object/index.js';
|
||||
export * from './window/localStorage.js';
|
||||
export * from './react/index.js';
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Object utilities for common operations on objects.
|
||||
*/
|
||||
|
||||
export { merge } from './merge/merge';
|
||||
export type { MergeOptions } from './merge/merge';
|
||||
@@ -0,0 +1,108 @@
|
||||
import { merge } from './merge';
|
||||
|
||||
/**
|
||||
* Test suite for the merge utility function.
|
||||
*/
|
||||
describe('shared/helpers/object/merge', () => {
|
||||
it('deeply merges two objects, preserving source values by default', () => {
|
||||
const source = { a: 1, b: { c: 2 } };
|
||||
const target = { a: 3, b: { c: 4, d: 5 }, e: 6 };
|
||||
const result = merge(source, target);
|
||||
|
||||
expect(result).toEqual({
|
||||
a: 1, // force is false, so 1 is preserved
|
||||
b: {
|
||||
c: 2, // force is false, so 2 is preserved
|
||||
d: 5, // added because it was not in source
|
||||
},
|
||||
e: 6, // added because it was not in source
|
||||
});
|
||||
});
|
||||
|
||||
it('deeply merges two objects and overrides values when force is true', () => {
|
||||
const source = { a: 1, b: { c: 2 } };
|
||||
const target = { a: 3, b: { c: 4, d: 5 }, e: 6 };
|
||||
const result = merge(source, target, { force: true });
|
||||
|
||||
expect(result).toEqual({
|
||||
a: 3,
|
||||
b: {
|
||||
c: 4,
|
||||
d: 5,
|
||||
},
|
||||
e: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('concatenates arrays when both are present at the same key', () => {
|
||||
const source = { a: [1, 2] };
|
||||
const target = { a: [3, 4] };
|
||||
const result = merge(source, target);
|
||||
|
||||
expect(result).toEqual({ a: [1, 2, 3, 4] });
|
||||
});
|
||||
|
||||
it('handles primitive values at the root', () => {
|
||||
expect(merge(1, 2)).toBe(1);
|
||||
expect(merge(1, 2, { force: true })).toBe(2);
|
||||
});
|
||||
|
||||
it('handles mixed types between source and target', () => {
|
||||
const source = { a: { b: 1 } };
|
||||
const target = { a: [1, 2] };
|
||||
|
||||
// Source wins because force is false
|
||||
expect(merge(source, target)).toEqual({ a: { b: 1 } });
|
||||
|
||||
// Target wins because force is true
|
||||
expect(merge(source, target, { force: true })).toEqual({ a: [1, 2] });
|
||||
});
|
||||
|
||||
it('handles deep recursion with arrays and objects', () => {
|
||||
const source = {
|
||||
users: [
|
||||
{ id: 1, name: 'John' },
|
||||
{ id: 2, name: 'Jane' },
|
||||
],
|
||||
config: {
|
||||
retries: 3,
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
const target = {
|
||||
users: [{ id: 3, name: 'Doe' }],
|
||||
config: {
|
||||
retries: 5,
|
||||
timeout: 1000,
|
||||
},
|
||||
};
|
||||
|
||||
const result = merge(source, target);
|
||||
|
||||
expect(result).toEqual({
|
||||
users: [
|
||||
{ id: 1, name: 'John' },
|
||||
{ id: 2, name: 'Jane' },
|
||||
{ id: 3, name: 'Doe' },
|
||||
],
|
||||
config: {
|
||||
retries: 3,
|
||||
enabled: true,
|
||||
timeout: 1000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the source when target has undefined values', () => {
|
||||
const source = { a: 1 };
|
||||
const target = { a: undefined };
|
||||
expect(merge(source, target)).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('replaces undefined source with target', () => {
|
||||
const source = { a: undefined };
|
||||
const target = { a: 1 };
|
||||
expect(merge(source, target)).toEqual({ a: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-unnecessary-type-assertion */
|
||||
/**
|
||||
* Options for the merge utility.
|
||||
*/
|
||||
export interface MergeOptions {
|
||||
/**
|
||||
* Whether to override existing primitive values in the source with values from the target.
|
||||
* If false, existing primitive values are preserved.
|
||||
* @default false
|
||||
*/
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deeply merges two values (objects, arrays, or primitives).
|
||||
*
|
||||
* Behavior:
|
||||
* - If both values are arrays, they are concatenated.
|
||||
* - If both values are objects, they are merged recursively.
|
||||
* - If encounter a primitive value, it is overridden only if `force` is set to true.
|
||||
* - If a key exists in target but not in source, it is added to the result.
|
||||
*
|
||||
* @param source - The source value to merge from
|
||||
* @param target - The target value to merge with
|
||||
* @param options - Merge configuration options
|
||||
* @returns The merged result
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* merge({ a: 1 }, { a: 2 }) // { a: 1 }
|
||||
* merge({ a: 1 }, { a: 2 }, { force: true }) // { a: 2 }
|
||||
* merge({ a: [1] }, { a: [2] }) // { a: [1, 2] }
|
||||
* merge({ a: { b: 1 } }, { a: { c: 2 } }) // { a: { b: 1, c: 2 } }
|
||||
* ```
|
||||
*/
|
||||
export function merge<T = any>(source: any, target: any, options: MergeOptions = {}): T {
|
||||
const { force = false } = options;
|
||||
|
||||
// If target is undefined, source wins
|
||||
if (target === undefined) {
|
||||
return source;
|
||||
}
|
||||
// If source is undefined, target wins
|
||||
if (source === undefined) {
|
||||
return target;
|
||||
}
|
||||
|
||||
const sourceIsArray = Array.isArray(source);
|
||||
const targetIsArray = Array.isArray(target);
|
||||
|
||||
// If both are arrays, concatenate
|
||||
if (sourceIsArray && targetIsArray) {
|
||||
return [...source, ...target] as any;
|
||||
}
|
||||
|
||||
const sourceIsObject = source !== null && typeof source === 'object' && !sourceIsArray;
|
||||
const targetIsObject = target !== null && typeof target === 'object' && !targetIsArray;
|
||||
|
||||
// If both are objects, deep merge
|
||||
if (sourceIsObject && targetIsObject) {
|
||||
const result = { ...source };
|
||||
for (const key in target) {
|
||||
if (Object.prototype.hasOwnProperty.call(target, key)) {
|
||||
if (key in source) {
|
||||
result[key] = merge(source[key], target[key], options);
|
||||
} else {
|
||||
result[key] = target[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return result as any;
|
||||
}
|
||||
|
||||
// If we encounter primitive values or mixed types, override only if force is true
|
||||
return force ? target : source;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type React from 'react';
|
||||
|
||||
/**
|
||||
* Safely casts a React ref to a mutable ref object.
|
||||
* This is a controlled escape hatch for situations where TypeScript's
|
||||
* built-in Ref types are too restrictive (e.g., when syncing refs manually).
|
||||
*
|
||||
* @param ref - The ref to cast
|
||||
* @returns The casted mutable ref
|
||||
* @example
|
||||
* ```tsx
|
||||
* const mutableRef = castMutableRef<HTMLDivElement>(ref);
|
||||
* mutableRef.current = someElement;
|
||||
* ```
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function castMutableRef<T>(ref: any): React.MutableRefObject<T | null> {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return ref as React.MutableRefObject<T | null>;
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
export { castRef } from './castRef/castRef';
|
||||
export { castMutableRef } from './castRef/castMutableRef';
|
||||
export { genericMemo } from './genericMemo/genericMemo';
|
||||
export { useResizeObserver } from './useResizeObserver/useResizeObserver';
|
||||
export { useIntersectionObserver } from './useIntersectionObserver/useIntersectionObserver';
|
||||
export { useScroll } from './useScroll/useScroll';
|
||||
export { useLocalStorage } from './useLocalStorage/useLocalStorage';
|
||||
|
||||
export type { ReactElementProps } from './types/ReactElementProps';
|
||||
@@ -0,0 +1,139 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { useIntersectionObserver } from './useIntersectionObserver';
|
||||
|
||||
describe('useIntersectionObserver', () => {
|
||||
let observerCallback: IntersectionObserverCallback;
|
||||
let observeMock = vi.fn();
|
||||
let unobserveMock = vi.fn();
|
||||
let disconnectMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
observeMock = vi.fn();
|
||||
unobserveMock = vi.fn();
|
||||
disconnectMock = vi.fn();
|
||||
|
||||
class MockIntersectionObserver {
|
||||
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
|
||||
observerCallback = callback;
|
||||
this.root = options?.root ?? null;
|
||||
this.rootMargin = options?.rootMargin ?? '';
|
||||
this.thresholds = options?.threshold
|
||||
? Array.isArray(options.threshold)
|
||||
? options.threshold
|
||||
: [options.threshold]
|
||||
: [0];
|
||||
}
|
||||
root: Element | Document | null;
|
||||
rootMargin: string;
|
||||
thresholds: readonly number[];
|
||||
takeRecords = vi.fn();
|
||||
observe = observeMock;
|
||||
unobserve = unobserveMock;
|
||||
disconnect = disconnectMock;
|
||||
}
|
||||
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return initial state as not intersecting', () => {
|
||||
const ref = { current: document.createElement('div') };
|
||||
const { result } = renderHook(() => useIntersectionObserver(ref));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should call observe on mount', () => {
|
||||
const element = document.createElement('div');
|
||||
const ref = { current: element };
|
||||
renderHook(() => useIntersectionObserver(ref));
|
||||
|
||||
expect(observeMock).toHaveBeenCalledWith(element);
|
||||
});
|
||||
|
||||
it('should update isIntersecting when observer triggers', () => {
|
||||
const element = document.createElement('div');
|
||||
const ref = { current: element };
|
||||
const { result } = renderHook(() => useIntersectionObserver(ref));
|
||||
|
||||
const entries = [
|
||||
{
|
||||
isIntersecting: true,
|
||||
target: element,
|
||||
},
|
||||
] as unknown as IntersectionObserverEntry[];
|
||||
|
||||
act(() => {
|
||||
observerCallback(entries, {} as IntersectionObserver);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should disconnect after first intersection if once is true', () => {
|
||||
const element = document.createElement('div');
|
||||
const ref = { current: element };
|
||||
renderHook(() => useIntersectionObserver(ref, { once: true }));
|
||||
|
||||
const entries = [
|
||||
{
|
||||
isIntersecting: true,
|
||||
target: element,
|
||||
},
|
||||
] as unknown as IntersectionObserverEntry[];
|
||||
|
||||
act(() => {
|
||||
observerCallback(entries, {} as IntersectionObserver);
|
||||
});
|
||||
|
||||
expect(disconnectMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not disconnect after first intersection if once is false', () => {
|
||||
const element = document.createElement('div');
|
||||
const ref = { current: element };
|
||||
renderHook(() => useIntersectionObserver(ref, { once: false }));
|
||||
|
||||
const entries = [
|
||||
{
|
||||
isIntersecting: true,
|
||||
target: element,
|
||||
},
|
||||
] as unknown as IntersectionObserverEntry[];
|
||||
|
||||
act(() => {
|
||||
observerCallback(entries, {} as IntersectionObserver);
|
||||
});
|
||||
|
||||
expect(disconnectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call disconnect on unmount', () => {
|
||||
const element = document.createElement('div');
|
||||
const ref = { current: element };
|
||||
const { unmount } = renderHook(() => useIntersectionObserver(ref));
|
||||
|
||||
unmount();
|
||||
|
||||
expect(disconnectMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not observe if ref is null', () => {
|
||||
const ref = { current: null };
|
||||
renderHook(() => useIntersectionObserver(ref));
|
||||
|
||||
expect(observeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return true immediately if IntersectionObserver is not available', () => {
|
||||
vi.stubGlobal('IntersectionObserver', undefined);
|
||||
const ref = { current: document.createElement('div') };
|
||||
const { result } = renderHook(() => useIntersectionObserver(ref));
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** Options for useIntersectionObserver */
|
||||
export interface UseIntersectionObserverOptions extends IntersectionObserverInit {
|
||||
/** Whether to disconnect after the first intersection */
|
||||
once?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook that observes if an element is intersecting with its root.
|
||||
*
|
||||
* @param ref - A React ref to the element to observe.
|
||||
* @param options - IntersectionObserver configuration.
|
||||
* @returns Boolean indicating if the element is currently intersecting.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const MyComponent = () => {
|
||||
* const ref = useRef<HTMLDivElement>(null);
|
||||
* const isVisible = useIntersectionObserver(ref);
|
||||
*
|
||||
* return <div ref={ref}>{isVisible ? 'I am visible!' : 'Scroll down...'}</div>;
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export const useIntersectionObserver = <T extends HTMLElement>(
|
||||
ref: React.RefObject<T | null>,
|
||||
{ once = true, ...options }: UseIntersectionObserverOptions = {}
|
||||
): boolean => {
|
||||
const [isIntersecting, setIntersecting] = useState(false);
|
||||
const { root, rootMargin, threshold } = options;
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
setIntersecting(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observeTarget = ref.current;
|
||||
if (!observeTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
setIntersecting(entry.isIntersecting);
|
||||
if (entry.isIntersecting && once) {
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ root, rootMargin, threshold }
|
||||
);
|
||||
|
||||
observer.observe(observeTarget);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [ref, once, root, rootMargin, threshold]);
|
||||
|
||||
return isIntersecting;
|
||||
};
|
||||
@@ -25,11 +25,11 @@ describe('lib/react/useLocalStorage', () => {
|
||||
it('should update state when setter is called', () => {
|
||||
const manager = createLocalStorage(KEY, DEFAULT_VALUE);
|
||||
const { result } = renderHook(() => useLocalStorage(manager));
|
||||
|
||||
|
||||
act(() => {
|
||||
result.current[1]('new value');
|
||||
});
|
||||
|
||||
|
||||
expect(result.current[0]).toBe('new value');
|
||||
expect(window.localStorage.getItem(KEY)).toBe(JSON.stringify('new value'));
|
||||
});
|
||||
|
||||
@@ -39,9 +39,8 @@ export function useLocalStorage<T>(
|
||||
const getSnapshot = useCallback(() => manager.get(), [manager]);
|
||||
|
||||
const value = useSyncExternalStore(subscribe, getSnapshot, () => {
|
||||
// During SSR, we can't access localStorage.
|
||||
// However, createLocalStorage is designed to throw if accessed during SSR.
|
||||
// This hook is intended to be used in client-side components.
|
||||
// SSR snapshot: createLocalStorage returns a no-op manager in Node context,
|
||||
// so manager.get() safely returns defaultValue without accessing localStorage.
|
||||
return manager.get();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { useResizeObserver } from './useResizeObserver';
|
||||
|
||||
describe('useResizeObserver', () => {
|
||||
let observerCallback: ResizeObserverCallback;
|
||||
const observeMock = vi.fn();
|
||||
const unobserveMock = vi.fn();
|
||||
const disconnectMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
class MockResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
observerCallback = callback;
|
||||
}
|
||||
observe = observeMock;
|
||||
unobserve = unobserveMock;
|
||||
disconnect = disconnectMock;
|
||||
}
|
||||
|
||||
vi.stubGlobal('ResizeObserver', MockResizeObserver);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return initial dimensions', () => {
|
||||
const ref = { current: document.createElement('div') };
|
||||
const { result } = renderHook(() => useResizeObserver(ref));
|
||||
expect(result.current).toEqual({ width: 0, height: 0 });
|
||||
});
|
||||
|
||||
it('should call observe on mount', () => {
|
||||
const element = document.createElement('div');
|
||||
const ref = { current: element };
|
||||
renderHook(() => useResizeObserver(ref));
|
||||
|
||||
expect(observeMock).toHaveBeenCalledWith(element);
|
||||
});
|
||||
|
||||
it('should update dimensions when ResizeObserver triggers', () => {
|
||||
const element = document.createElement('div');
|
||||
const ref = { current: element };
|
||||
const { result } = renderHook(() => useResizeObserver(ref));
|
||||
|
||||
const entries = [
|
||||
{
|
||||
contentRect: { width: 100, height: 200 },
|
||||
target: element,
|
||||
},
|
||||
] as unknown as ResizeObserverEntry[];
|
||||
|
||||
act(() => {
|
||||
observerCallback(entries, {} as ResizeObserver);
|
||||
});
|
||||
|
||||
expect(result.current).toEqual({ width: 100, height: 200 });
|
||||
});
|
||||
|
||||
it('should call disconnect on unmount', () => {
|
||||
const element = document.createElement('div');
|
||||
const ref = { current: element };
|
||||
const { unmount } = renderHook(() => useResizeObserver(ref));
|
||||
|
||||
unmount();
|
||||
|
||||
expect(disconnectMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not observe if ref is null', () => {
|
||||
const ref = { current: null };
|
||||
renderHook(() => useResizeObserver(ref));
|
||||
|
||||
expect(observeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** Result of useResizeObserver */
|
||||
export interface UseResizeObserverResult {
|
||||
/** Observed width */
|
||||
width: number;
|
||||
/** Observed height */
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook that observes the size of an element and returns its dimensions.
|
||||
*
|
||||
* @param ref - A React ref to the element to observe.
|
||||
* @returns The dimensions of the observed element.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const MyComponent = () => {
|
||||
* const ref = useRef<HTMLDivElement>(null);
|
||||
* const { width, height } = useResizeObserver(ref);
|
||||
*
|
||||
* return <div ref={ref}>Width: {width}, Height: {height}</div>;
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export const useResizeObserver = <T extends HTMLElement>(
|
||||
ref: React.RefObject<T | null>
|
||||
): UseResizeObserverResult => {
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const observeTarget = ref.current;
|
||||
if (!observeTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
if (!Array.isArray(entries) || !entries.length) {
|
||||
return;
|
||||
}
|
||||
const entry = entries[0];
|
||||
setDimensions({
|
||||
width: entry.contentRect.width,
|
||||
height: entry.contentRect.height,
|
||||
});
|
||||
});
|
||||
|
||||
resizeObserver.observe(observeTarget);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
return dimensions;
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { useScroll } from './useScroll';
|
||||
|
||||
describe('useScroll', () => {
|
||||
const originalScrollX = window.scrollX;
|
||||
const originalScrollY = window.scrollY;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
return setTimeout(callback, 0);
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
window.scrollX = originalScrollX;
|
||||
window.scrollY = originalScrollY;
|
||||
});
|
||||
|
||||
it('should return initial scroll position', () => {
|
||||
window.scrollX = 50;
|
||||
window.scrollY = 150;
|
||||
const { result } = renderHook(() => useScroll());
|
||||
expect(result.current).toEqual({ x: 50, y: 150 });
|
||||
});
|
||||
|
||||
it('should update scroll position on scroll', () => {
|
||||
const { result } = renderHook(() => useScroll());
|
||||
|
||||
act(() => {
|
||||
window.scrollX = 100;
|
||||
window.scrollY = 200;
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
});
|
||||
|
||||
// Wait for requestAnimationFrame (mocked via setTimeout)
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current).toEqual({ x: 100, y: 200 });
|
||||
});
|
||||
|
||||
it('should throttle updates using requestAnimationFrame', () => {
|
||||
const { result } = renderHook(() => useScroll());
|
||||
|
||||
act(() => {
|
||||
window.scrollX = 100;
|
||||
window.scrollY = 200;
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
|
||||
window.scrollX = 300;
|
||||
window.scrollY = 400;
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
});
|
||||
|
||||
// Before timers run, it should still be initial or first set if not throttled correctly
|
||||
// But since we trigger twice in same tick, ticking.current prevents second RAF.
|
||||
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current).toEqual({ x: 300, y: 400 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
/** Result of useScroll */
|
||||
export interface UseScrollResult {
|
||||
/** x scroll position */
|
||||
x: number;
|
||||
/** y scroll position */
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook to track window scroll position.
|
||||
*
|
||||
* @returns An object containing the x and y scroll position.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { x, y } = useScroll();
|
||||
* ```
|
||||
*/
|
||||
export const useScroll = (): UseScrollResult => {
|
||||
const [scroll, setScroll] = useState(() => ({
|
||||
x: typeof window !== 'undefined' ? window.scrollX : 0,
|
||||
y: typeof window !== 'undefined' ? window.scrollY : 0,
|
||||
}));
|
||||
const ticking = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (!ticking.current) {
|
||||
window.requestAnimationFrame(() => {
|
||||
setScroll({ x: window.scrollX, y: window.scrollY });
|
||||
ticking.current = false;
|
||||
});
|
||||
ticking.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
return scroll;
|
||||
};
|
||||
@@ -29,11 +29,14 @@ describe('lib/window/localStorage', () => {
|
||||
},
|
||||
dispatchEvent: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal('StorageEvent', class {
|
||||
constructor(type: string, init: any) {
|
||||
Object.assign(this, { type, ...init });
|
||||
vi.stubGlobal(
|
||||
'StorageEvent',
|
||||
class {
|
||||
constructor(type: string, init: any) {
|
||||
Object.assign(this, { type, ...init });
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -41,9 +44,15 @@ describe('lib/window/localStorage', () => {
|
||||
for (const key in mockStorage) delete mockStorage[key];
|
||||
});
|
||||
|
||||
it('should throw if window is undefined', () => {
|
||||
it('returns a no-op manager when window is undefined (SSR/SSG context)', () => {
|
||||
vi.stubGlobal('window', undefined);
|
||||
expect(() => createLocalStorage(KEY, DEFAULT_VALUE)).toThrow();
|
||||
const manager = createLocalStorage(KEY, DEFAULT_VALUE);
|
||||
expect(manager.key).toBe(KEY);
|
||||
expect(manager.get()).toEqual(DEFAULT_VALUE);
|
||||
expect(() => manager.set({ foo: 'x' })).not.toThrow();
|
||||
expect(() => manager.remove()).not.toThrow();
|
||||
// get() still returns defaultValue after set (no-op in SSR)
|
||||
expect(manager.get()).toEqual(DEFAULT_VALUE);
|
||||
});
|
||||
|
||||
it('should get default value if empty', () => {
|
||||
|
||||
@@ -17,7 +17,8 @@ export interface StorageManager<T> {
|
||||
/**
|
||||
* Creates a type-safe manager for a localStorage key.
|
||||
*
|
||||
* This utility is browser-only and will throw if accessed during SSR.
|
||||
* In SSR/SSG context (no window), returns a no-op manager that always
|
||||
* returns the default value. In the browser, uses localStorage as usual.
|
||||
* Ensure you use a strict namespace for the key to avoid monorepo collisions.
|
||||
* Recommended format: `ermnvldmr/{package}/{path}/{module}`
|
||||
* Example: `ermnvldmr/ui/lib/theme`
|
||||
@@ -31,15 +32,15 @@ export interface StorageManager<T> {
|
||||
* const theme = createLocalStorage('ermnvldmr/ui/lib/theme', 'system');
|
||||
* console.log(theme.get());
|
||||
* ```
|
||||
*
|
||||
* @throws Error if window or localStorage is undefined.
|
||||
*/
|
||||
export function createLocalStorage<T>(key: string, defaultValue: T): StorageManager<T> {
|
||||
if (typeof window === 'undefined' || typeof window.localStorage === 'undefined') {
|
||||
throw new Error(
|
||||
`[createLocalStorage] Attempted to access storage for key "${key}" outside of a browser environment. ` +
|
||||
'This utility is strictly for client-side use.'
|
||||
);
|
||||
return {
|
||||
key,
|
||||
get: () => defaultValue,
|
||||
set: () => { /* no-op in SSR/SSG context */ },
|
||||
remove: () => { /* no-op in SSR/SSG context */ },
|
||||
};
|
||||
}
|
||||
|
||||
const manager: StorageManager<T> = {
|
||||
@@ -51,7 +52,10 @@ export function createLocalStorage<T>(key: string, defaultValue: T): StorageMana
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return JSON.parse(item) as T;
|
||||
} catch (error) {
|
||||
console.error(`[createLocalStorage] Failed to parse key "${key}", falling back to default.`, error);
|
||||
console.error(
|
||||
`[createLocalStorage] Failed to parse key "${key}", falling back to default.`,
|
||||
error
|
||||
);
|
||||
return defaultValue;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1 +1,40 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
class ResizeObserverMock {
|
||||
observe = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
}
|
||||
|
||||
global.ResizeObserver = ResizeObserverMock;
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // Deprecated
|
||||
removeListener: vi.fn(), // Deprecated
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// jsdom sometimes does not expose localStorage.clear(); provide a functional polyfill
|
||||
if (typeof window.localStorage.clear !== 'function') {
|
||||
const store = new Map<string, string>();
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string): string | null => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string): void => { store.set(key, value); },
|
||||
removeItem: (key: string): void => { store.delete(key); },
|
||||
clear: (): void => { store.clear(); },
|
||||
key: (index: number): string | null => [...store.keys()][index] ?? null,
|
||||
get length(): number { return store.size; },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -8,15 +8,19 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/ui/src/index.d.ts"
|
||||
"style": "./dist/styles/*.css",
|
||||
"types": "./dist/ui/src/index.d.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./testing": {
|
||||
"import": "./dist/testing.js",
|
||||
"types": "./dist/ui/src/testing.d.ts"
|
||||
"types": "./dist/ui/src/testing.d.ts",
|
||||
"default": "./src/testing.ts"
|
||||
},
|
||||
"./dev": {
|
||||
"import": "./dist/dev.js",
|
||||
"types": "./dist/ui/src/dev.d.ts"
|
||||
"types": "./dist/ui/src/dev.d.ts",
|
||||
"default": "./src/dev.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
@@ -37,6 +41,7 @@
|
||||
"clean": "rm -rf dist coverage storybook-static"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ermnvldmr/i18n": "workspace:*",
|
||||
"@ermnvldmr/stl": "workspace:*",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||
@@ -47,6 +52,7 @@
|
||||
"react-aria": "^3.45.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-stately": "^3.44.0",
|
||||
"shiki": "^4.0.1",
|
||||
"tw-animate-css": "^1.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -77,7 +83,7 @@
|
||||
"jsdom": "^28.0.0",
|
||||
"playwright": "^1.57.0",
|
||||
"postcss": "^8.5.6",
|
||||
"storybook": "^8.6.15",
|
||||
"storybook": "^8.6.17",
|
||||
"storybook-react-rsbuild": "^1.0.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^4.0.18",
|
||||
|
||||
@@ -8,6 +8,7 @@ const meta: Meta<typeof Article> = {
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
onPress: { action: 'pressed' },
|
||||
href: { control: 'text' },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,9 +36,9 @@ export const Full: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
export const Linked: Story = {
|
||||
args: {
|
||||
...Full.args,
|
||||
onPress: () => alert('Article clicked!'),
|
||||
href: '#',
|
||||
},
|
||||
};
|
||||
@@ -19,17 +19,31 @@ describe('components/Article', () => {
|
||||
expect(screen.getByText('Reporting by John Doe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders separators only when appropriate', () => {
|
||||
it('renders separators correctly based on subHeadline presence', () => {
|
||||
const { rerender } = render(<Article headline="Title">Body</Article>);
|
||||
// Should have 1 separator (between title and body)
|
||||
expect(screen.getAllByRole('separator')).toHaveLength(1);
|
||||
let separators = screen.getAllByRole('separator');
|
||||
expect(separators).toHaveLength(1);
|
||||
expect(separators[0].className).toContain('mask-image');
|
||||
|
||||
rerender(
|
||||
<Article headline="Title" subHeadline="Sub">
|
||||
Body
|
||||
</Article>
|
||||
);
|
||||
// Should have 2 separators (Title-Sub and Sub-Body)
|
||||
expect(screen.getAllByRole('separator')).toHaveLength(2);
|
||||
separators = screen.getAllByRole('separator');
|
||||
expect(separators).toHaveLength(1);
|
||||
expect(separators[0].className).toContain('mask-image');
|
||||
});
|
||||
|
||||
it('renders as a link when href is provided', () => {
|
||||
render(
|
||||
<Article {...props} href="/test-article">
|
||||
Article body content
|
||||
</Article>
|
||||
);
|
||||
|
||||
const articleContainer = screen.getByText('Breaking News').closest('a');
|
||||
expect(articleContainer).toBeInTheDocument();
|
||||
expect(articleContainer).toHaveAttribute('href', '/test-article');
|
||||
});
|
||||
});
|
||||
@@ -7,19 +7,28 @@ import { Text } from '../Text/Text';
|
||||
import { VStack } from '../VStack/VStack';
|
||||
|
||||
import type { ContainerProps } from '../Container/Container';
|
||||
import type { ClassNameProps } from '@ermnvldmr/stl';
|
||||
|
||||
/**
|
||||
* Props for the Article component.
|
||||
*/
|
||||
export interface ArticleProps {
|
||||
export interface ArticleProps extends ClassNameProps {
|
||||
/** The main article headline */
|
||||
headline: string;
|
||||
/** Optional sub-headline displayed between separators */
|
||||
subHeadline?: string;
|
||||
/** Optional additional text (e.g., author) displayed at the bottom in gray italic label */
|
||||
additionalText?: string;
|
||||
/** Press interaction handler propagated to the wrapping Container */
|
||||
/**
|
||||
* Press interaction handler.
|
||||
* Cannot be used with `href`.
|
||||
*/
|
||||
onPress?: ContainerProps['onPress'];
|
||||
/**
|
||||
* If provided, the container will be rendered as an `<a>` tag for navigation.
|
||||
* Cannot be used with `onPress`.
|
||||
*/
|
||||
href?: ContainerProps['href'];
|
||||
/** The main article body content */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@@ -28,45 +37,43 @@ export interface ArticleProps {
|
||||
* A article component with a structured layout.
|
||||
*
|
||||
* Features:
|
||||
* - Centered medium headline (headline-m)
|
||||
* - Single-line separators
|
||||
* - Optional centered italic sub-headline (label-m)
|
||||
* - Left-aligned medium headline (headline-m)
|
||||
* - Single-line separators with optional end fade
|
||||
* - Optional italic sub-headline (label-m)
|
||||
* - Main body text (body-m)
|
||||
* - Optional bottom gray italic label (label-s)
|
||||
* - Interactive wrapper via Container
|
||||
* - Interactive wrapper via Container, supporting both `onPress` and `href`
|
||||
*/
|
||||
export const Article = memo(function Article({
|
||||
headline,
|
||||
subHeadline,
|
||||
additionalText,
|
||||
onPress,
|
||||
href,
|
||||
children,
|
||||
className,
|
||||
}: ArticleProps) {
|
||||
return (
|
||||
<Container padding={4} onPress={onPress}>
|
||||
<Container border className={className} href={href} padding={4} rounded="md" onPress={onPress}>
|
||||
<VStack align="stretch" gap={4}>
|
||||
<Header className="text-center" level={4}>
|
||||
{headline}
|
||||
</Header>
|
||||
|
||||
<VStack align="center" gap={2}>
|
||||
{subHeadline ? (
|
||||
<>
|
||||
<Separator className="w-3/4" type="single" />
|
||||
<Text italic className="text-center" size="m" type="label">
|
||||
{subHeadline}
|
||||
</Text>
|
||||
<Separator className="w-3/4" type="single" />
|
||||
</>
|
||||
) : (
|
||||
<Separator className="w-3/4" type="single" />
|
||||
)}
|
||||
<VStack align="stretch" gap={2}>
|
||||
<Header level={4}>{headline}</Header>
|
||||
<VStack align="stretch" gap={2}>
|
||||
{subHeadline ? (
|
||||
<>
|
||||
<Text italic size="m" type="label">
|
||||
{subHeadline}
|
||||
</Text>
|
||||
<Separator thinned="thinned-end" type="single" />
|
||||
</>
|
||||
) : (
|
||||
<Separator thinned="thinned-end" type="single" />
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
<Text size="m" type="body">
|
||||
{children}
|
||||
</Text>
|
||||
|
||||
{additionalText && (
|
||||
<VStack align="end">
|
||||
<Text italic color="muted" size="s" type="label">
|
||||
|
||||
@@ -33,6 +33,18 @@ export const Default: Story = {
|
||||
),
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
render: () => (
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item href="/">Home</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Separator variant="arrow" />
|
||||
<Breadcrumbs.Item onClick={() => alert('Clicked!')}>Click Me</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Separator variant="arrow" />
|
||||
<Breadcrumbs.Item isCurrent>Current Page</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithArrows: Story = {
|
||||
render: () => (
|
||||
<Breadcrumbs>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Breadcrumbs } from './Breadcrumbs';
|
||||
import { render, screen } from '../../lib/testing';
|
||||
import { render, screen, fireEvent } from '../../lib/testing';
|
||||
import { Link } from '../Link/Link';
|
||||
|
||||
describe('components/Breadcrumbs', () => {
|
||||
@@ -12,14 +12,58 @@ describe('components/Breadcrumbs', () => {
|
||||
<Link href="/">Home</Link>
|
||||
</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Separator variant="slash" />
|
||||
<Breadcrumbs.Item>Current</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Item isCurrent>Current</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('navigation')).toHaveAttribute('aria-label', 'Breadcrumb');
|
||||
expect(screen.getByRole('link', { name: 'Home' })).toBeInTheDocument();
|
||||
expect(screen.getByText('/')).toBeInTheDocument();
|
||||
expect(screen.getByText('Current')).toBeInTheDocument();
|
||||
|
||||
const currentItem = screen.getByText('Current').parentElement;
|
||||
expect(currentItem).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
it('supports isCurrent state', () => {
|
||||
render(
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item isCurrent>Current Page</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
);
|
||||
const item = screen.getByText('Current Page').parentElement;
|
||||
expect(item).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
it('supports href directly on Item', () => {
|
||||
render(
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item href="/test-url">Test Link</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
);
|
||||
const link = screen.getByRole('link', { name: 'Test Link' });
|
||||
expect(link).toHaveAttribute('href', '/en/test-url');
|
||||
});
|
||||
|
||||
it('supports onClick on Item', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item onClick={handleClick}>Clickable</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
);
|
||||
const button = screen.getByRole('button', { name: 'Clickable' });
|
||||
fireEvent.click(button);
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders as a non-submit button when onClick is provided', () => {
|
||||
render(
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item onClick={vi.fn()}>Clickable</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
);
|
||||
const button = screen.getByRole('button', { name: 'Clickable' });
|
||||
expect(button).toHaveAttribute('type', 'button');
|
||||
});
|
||||
|
||||
it('supports different separator variants', () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { localePath } from '@ermnvldmr/i18n';
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import React, { memo } from 'react';
|
||||
@@ -29,20 +30,58 @@ const BreadcrumbsRoot = memo(function Breadcrumbs({ children, ...props }: Breadc
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Props for the individual Breadcrumbs item.
|
||||
*/
|
||||
export interface BreadcrumbsItemProps extends TestIdProps {
|
||||
/** Content to be rendered inside the item */
|
||||
children: React.ReactNode;
|
||||
/** Optional className for the list item */
|
||||
className?: string;
|
||||
/** Whether this is the current page */
|
||||
isCurrent?: boolean;
|
||||
/** Optional href for the item (renders as a link) */
|
||||
href?: string;
|
||||
/** Optional click handler for the item */
|
||||
onClick?: React.MouseEventHandler<HTMLElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual step in the breadcrumb trail.
|
||||
*/
|
||||
const BreadcrumbsItem = memo(function BreadcrumbsItem({
|
||||
children,
|
||||
className,
|
||||
isCurrent,
|
||||
href,
|
||||
onClick,
|
||||
'data-testid': testId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
} & TestIdProps) {
|
||||
}: BreadcrumbsItemProps) {
|
||||
const resolvedHref = localePath(href);
|
||||
const isClickable = !!(resolvedHref ?? onClick);
|
||||
|
||||
return (
|
||||
<li className={className} data-testid={testId}>
|
||||
<Text color="muted" size="m" type="label">
|
||||
<li
|
||||
aria-current={isCurrent ? 'page' : undefined}
|
||||
className={cn(
|
||||
'group flex items-center transition-all duration-200',
|
||||
!isCurrent && isClickable && 'cursor-pointer',
|
||||
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'
|
||||
)}
|
||||
color={isCurrent ? 'default' : 'muted'}
|
||||
href={resolvedHref}
|
||||
size="m"
|
||||
type="label"
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
</li>
|
||||
|
||||
@@ -90,6 +90,24 @@ export const LongWithScroll: StoryObj<typeof CodeBlock> = {
|
||||
},
|
||||
};
|
||||
|
||||
export const ShikiHighlight: StoryObj<typeof CodeBlock> = {
|
||||
render: (args) => (
|
||||
<CodeBlock {...args}>
|
||||
<Code>
|
||||
{`import React from 'react';
|
||||
|
||||
export const MyComponent = () => {
|
||||
return <div>Hello World</div>;
|
||||
};`}
|
||||
</Code>
|
||||
</CodeBlock>
|
||||
),
|
||||
args: {
|
||||
label: 'ShikiHighlight.tsx',
|
||||
language: 'tsx',
|
||||
},
|
||||
};
|
||||
|
||||
export const PlainText: StoryObj<typeof CodeBlock> = {
|
||||
render: (args) => (
|
||||
<CodeBlock {...args}>
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import { Code } from './Code';
|
||||
import { CodeBlock } from './CodeBlock/CodeBlock';
|
||||
|
||||
// Mock shiki
|
||||
vi.mock('shiki', () => ({
|
||||
createHighlighter: vi.fn().mockResolvedValue({
|
||||
codeToHtml: vi
|
||||
.fn()
|
||||
.mockReturnValue('<pre><code><span class="line">highlighted code</span></code></pre>'),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock clipboard API
|
||||
const mockWriteText = vi.fn().mockImplementation(() => Promise.resolve());
|
||||
Object.assign(navigator, {
|
||||
@@ -15,6 +25,7 @@ Object.assign(navigator, {
|
||||
describe('Code Component', () => {
|
||||
beforeEach(() => {
|
||||
mockWriteText.mockClear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders inline code correctly', () => {
|
||||
@@ -40,19 +51,18 @@ describe('Code Component', () => {
|
||||
expect(screen.getByText('test.ts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies content on click in inline mode', async () => {
|
||||
it('copies content on click in inline mode', () => {
|
||||
render(<Code copyValue="copied!">Display Text</Code>);
|
||||
const codeElement = screen.getByText('Display Text');
|
||||
|
||||
await act(async () => {
|
||||
act(() => {
|
||||
fireEvent.click(codeElement);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('copied!');
|
||||
});
|
||||
|
||||
it('copies content via button in block mode', async () => {
|
||||
it('copies content via button in block mode', () => {
|
||||
render(
|
||||
<CodeBlock copyValue="block copy">
|
||||
<Code>
|
||||
@@ -62,23 +72,35 @@ describe('Code Component', () => {
|
||||
);
|
||||
|
||||
const copyButton = screen.getByLabelText('Copy code');
|
||||
await act(async () => {
|
||||
act(() => {
|
||||
fireEvent.click(copyButton);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('block copy');
|
||||
});
|
||||
|
||||
it('extracts text from children if copyValue is not provided', async () => {
|
||||
it('extracts text from children if copyValue is not provided', () => {
|
||||
render(<Code>extracted</Code>);
|
||||
|
||||
const codeElement = screen.getByText('extracted');
|
||||
await act(async () => {
|
||||
act(() => {
|
||||
fireEvent.click(codeElement);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('extracted');
|
||||
});
|
||||
|
||||
it('uses shiki for highlighting when language is provided in block mode', async () => {
|
||||
act(() => {
|
||||
render(
|
||||
<CodeBlock language="typescript">
|
||||
<Code>const x: number = 1;</Code>
|
||||
</CodeBlock>
|
||||
);
|
||||
});
|
||||
|
||||
// Wait for shiki async highlighting
|
||||
const highlighted = await screen.findByText('highlighted code');
|
||||
expect(highlighted).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,13 @@ import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { usePress } from 'react-aria';
|
||||
|
||||
import { useCodeContext } from './contexts/CodeContext/CodeContext';
|
||||
import { codeHighlighter } from './lib/highlighter/highlighter';
|
||||
|
||||
import type { CodeLanguage } from './lib/highlighter/highlighter';
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
|
||||
export type { CodeLanguage };
|
||||
|
||||
/**
|
||||
* Props for the Code component.
|
||||
*/
|
||||
@@ -18,6 +22,12 @@ export interface CodeProps extends ClassNameProps, TestIdProps {
|
||||
* If omitted in inline mode, the component will attempt to extract text from `children`.
|
||||
*/
|
||||
copyValue?: string;
|
||||
|
||||
/**
|
||||
* The language of the code. Only used when rendered inside a CodeBlock.
|
||||
* If provided, shiki will be used for syntax highlighting.
|
||||
*/
|
||||
language?: CodeLanguage;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,20 +40,28 @@ export interface CodeProps extends ClassNameProps, TestIdProps {
|
||||
export const Code = memo(function Code({
|
||||
children,
|
||||
copyValue,
|
||||
language: propsLanguage,
|
||||
className,
|
||||
'data-testid': testId,
|
||||
}: CodeProps) {
|
||||
const context = useCodeContext();
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const codeRef = useRef<HTMLElement>(null);
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null);
|
||||
|
||||
const language = propsLanguage ?? context?.language;
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
let textToCopy = copyValue;
|
||||
if (!textToCopy && codeRef.current) {
|
||||
textToCopy = codeRef.current.innerText;
|
||||
if (!textToCopy) {
|
||||
textToCopy = codeRef.current.textContent ?? undefined;
|
||||
if (!textToCopy) {
|
||||
const ref = codeRef.current ?? divRef.current;
|
||||
if (ref) {
|
||||
textToCopy = ref.innerText;
|
||||
if (!textToCopy) {
|
||||
textToCopy = ref.textContent ?? undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +85,22 @@ export const Code = memo(function Code({
|
||||
}
|
||||
}, [isCopied]);
|
||||
|
||||
useEffect(() => {
|
||||
if (context?.isInCodeBlock && language && typeof children === 'string') {
|
||||
const highlight = async () => {
|
||||
try {
|
||||
const html = await codeHighlighter.highlight(children, language);
|
||||
setHighlightedHtml(html);
|
||||
} catch (err) {
|
||||
console.error('Failed to highlight code:', err);
|
||||
}
|
||||
};
|
||||
void highlight();
|
||||
} else {
|
||||
setHighlightedHtml(null);
|
||||
}
|
||||
}, [children, language, context?.isInCodeBlock]);
|
||||
|
||||
const { pressProps } = usePress({
|
||||
onPress: onCopyPress,
|
||||
});
|
||||
@@ -74,7 +108,40 @@ export const Code = memo(function Code({
|
||||
if (context?.isInCodeBlock) {
|
||||
const { showLineNumbers } = context;
|
||||
|
||||
let content = children;
|
||||
let content: React.ReactNode = children;
|
||||
|
||||
if (highlightedHtml) {
|
||||
return (
|
||||
<div
|
||||
// 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
|
||||
)}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof children === 'string') {
|
||||
content = children
|
||||
.trimEnd()
|
||||
@@ -124,11 +191,13 @@ export const Code = memo(function Code({
|
||||
aria-label="Copy code snippet"
|
||||
className={cn(
|
||||
'relative inline-block rounded-sm px-1.5 py-0.5 text-sm font-mono',
|
||||
'bg-[var(--rb-muted-base)]/50 text-[var(--rb-text)] border border-[var(--rb-outline)]/20',
|
||||
'text-[var(--rb-color-red-600)] dark:text-[var(--rb-color-red-400)]',
|
||||
'cursor-pointer transition-all duration-200',
|
||||
'hover:bg-[var(--rb-muted-base)]/80',
|
||||
'hover:text-[var(--rb-color-red-500)] hover:bg-[var(--rb-color-red-500)]/10',
|
||||
'active:scale-95',
|
||||
isCopied && 'ring-1 ring-[var(--rb-secondary-base)]',
|
||||
isCopied
|
||||
? 'bg-[var(--rb-color-red-500)]/20 ring-1 ring-[var(--rb-color-red-500)]/40'
|
||||
: 'bg-transparent ring-1 ring-transparent',
|
||||
className
|
||||
)}
|
||||
data-testid={testId}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from '../../Button/Button';
|
||||
import { Container } from '../../Container/Container';
|
||||
import { CodeContext, type CodeContextValue } from '../contexts/CodeContext/CodeContext';
|
||||
|
||||
import type { CodeLanguage } from '../lib/highlighter/highlighter';
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
|
||||
/**
|
||||
@@ -21,6 +22,11 @@ export interface CodeBlockProps extends ClassNameProps, TestIdProps {
|
||||
*/
|
||||
label?: string;
|
||||
|
||||
/**
|
||||
* The language of the code block.
|
||||
*/
|
||||
language?: CodeLanguage;
|
||||
|
||||
/**
|
||||
* Whether to render line number gutters.
|
||||
* Requires children (Code) to be rendered as block-level elements.
|
||||
@@ -49,6 +55,7 @@ export interface CodeBlockProps extends ClassNameProps, TestIdProps {
|
||||
export const CodeBlock = memo(function CodeBlock({
|
||||
children,
|
||||
label,
|
||||
language,
|
||||
showLineNumbers = true,
|
||||
copyValue,
|
||||
maxHeight,
|
||||
@@ -92,8 +99,9 @@ export const CodeBlock = memo(function CodeBlock({
|
||||
() => ({
|
||||
isInCodeBlock: true,
|
||||
showLineNumbers,
|
||||
language,
|
||||
}),
|
||||
[showLineNumbers]
|
||||
[showLineNumbers, language]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { CodeLanguage } from '../../lib/highlighter/highlighter';
|
||||
|
||||
/**
|
||||
* Value for the Code context.
|
||||
*/
|
||||
@@ -8,6 +10,8 @@ export interface CodeContextValue {
|
||||
isInCodeBlock: boolean;
|
||||
/** Whether to show line numbers in the block. */
|
||||
showLineNumbers?: boolean;
|
||||
/** The language of the code in the block. */
|
||||
language?: CodeLanguage;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { createHighlighter } from 'shiki';
|
||||
|
||||
import type { Highlighter } from 'shiki';
|
||||
|
||||
/**
|
||||
* Supported languages for syntax highlighting.
|
||||
*/
|
||||
export type CodeLanguage =
|
||||
| 'typescript'
|
||||
| 'javascript'
|
||||
| 'tsx'
|
||||
| 'jsx'
|
||||
| 'bash'
|
||||
| 'shell'
|
||||
| 'json'
|
||||
| 'css'
|
||||
| 'html'
|
||||
| 'markdown'
|
||||
| 'yaml'
|
||||
| 'dockerfile'
|
||||
| 'toml'
|
||||
| 'rust'
|
||||
| 'go'
|
||||
| 'python';
|
||||
|
||||
export const SUPPORTED_LANGUAGES: CodeLanguage[] = [
|
||||
'typescript',
|
||||
'javascript',
|
||||
'tsx',
|
||||
'jsx',
|
||||
'bash',
|
||||
'shell',
|
||||
'json',
|
||||
'css',
|
||||
'html',
|
||||
'markdown',
|
||||
'yaml',
|
||||
'dockerfile',
|
||||
'toml',
|
||||
'rust',
|
||||
'go',
|
||||
'python',
|
||||
];
|
||||
|
||||
/**
|
||||
* A singleton highlighter service for code blocks.
|
||||
*/
|
||||
class CodeHighlighter {
|
||||
private static instance: CodeHighlighter | null = null;
|
||||
private highlighter: Highlighter | null = null;
|
||||
private initializingPromise: Promise<Highlighter> | null = null;
|
||||
|
||||
/**
|
||||
* Gets the singleton instance of the highlighter.
|
||||
* @example
|
||||
* ```typescript
|
||||
* const highlighter = CodeHighlighter.getInstance();
|
||||
* ```
|
||||
*/
|
||||
public static getInstance(): CodeHighlighter {
|
||||
return (CodeHighlighter.instance ??= new CodeHighlighter());
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the highlighter if it hasn't been already.
|
||||
* @example
|
||||
* ```typescript
|
||||
* const highlighter = await this.getHighlighter();
|
||||
* ```
|
||||
*/
|
||||
private async getHighlighter(): Promise<Highlighter> {
|
||||
if (this.highlighter) {
|
||||
return this.highlighter;
|
||||
}
|
||||
|
||||
if (this.initializingPromise) {
|
||||
return this.initializingPromise;
|
||||
}
|
||||
|
||||
this.initializingPromise = createHighlighter({
|
||||
themes: ['github-dark', 'github-light'],
|
||||
langs: SUPPORTED_LANGUAGES,
|
||||
}).then((highlighter) => {
|
||||
this.highlighter = highlighter;
|
||||
this.initializingPromise = null;
|
||||
return highlighter;
|
||||
});
|
||||
|
||||
return this.initializingPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlights the given code.
|
||||
*
|
||||
* @param code - The raw source code to highlight
|
||||
* @param lang - The language of the code
|
||||
* @returns A promise resolving to the highlighted HTML
|
||||
* @example
|
||||
* ```typescript
|
||||
* const html = await highlighter.highlight('const a = 1;', 'typescript');
|
||||
* ```
|
||||
*/
|
||||
public async highlight(code: string, lang: CodeLanguage): Promise<string> {
|
||||
try {
|
||||
const highlighter = await this.getHighlighter();
|
||||
return highlighter.codeToHtml(code, {
|
||||
lang,
|
||||
themes: {
|
||||
light: 'github-light',
|
||||
dark: 'github-dark',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to highlight code:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const codeHighlighter = CodeHighlighter.getInstance();
|
||||
@@ -26,6 +26,9 @@ const meta: Meta<typeof Container> = {
|
||||
control: 'select',
|
||||
options: ['none', 'sm', 'md', 'lg', 'full'],
|
||||
},
|
||||
href: {
|
||||
control: 'text',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -45,6 +48,14 @@ export const Default: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const AsLink: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
href: '#',
|
||||
children: 'This container is a link.',
|
||||
},
|
||||
};
|
||||
|
||||
export const SemanticColors: Story = {
|
||||
render: () => (
|
||||
<VStack gap={4}>
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('components/Container', () => {
|
||||
expect(handlePress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('applies cursor-pointer and active styles when onPress is present', () => {
|
||||
it('applies interactive styles when onPress is present', () => {
|
||||
const { rerender } = render(<Container>Content</Container>);
|
||||
let container = screen.getByText('Content');
|
||||
expect(container).not.toHaveClass('cursor-pointer');
|
||||
@@ -23,6 +23,7 @@ describe('components/Container', () => {
|
||||
rerender(<Container onPress={() => {}}>Content</Container>);
|
||||
container = screen.getByText('Content');
|
||||
expect(container).toHaveClass('cursor-pointer');
|
||||
expect(container).toHaveClass('hover:brightness-95');
|
||||
expect(container).toHaveClass('active:opacity-80');
|
||||
});
|
||||
|
||||
@@ -36,4 +37,38 @@ describe('components/Container', () => {
|
||||
rerender(<Container as="section">Section Content</Container>);
|
||||
expect(screen.getByText('Section Content').tagName).toBe('SECTION');
|
||||
});
|
||||
|
||||
it('renders as an anchor tag with href when href prop is provided', () => {
|
||||
render(<Container href="/test-path">Link Content</Container>);
|
||||
const linkElement = screen.getByText('Link Content');
|
||||
expect(linkElement.tagName).toBe('A');
|
||||
expect(linkElement).toHaveAttribute('href', '/test-path');
|
||||
});
|
||||
|
||||
it('gives precedence to href over onPress', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handlePress = vi.fn();
|
||||
render(
|
||||
<Container href="/test-path" onPress={handlePress}>
|
||||
Link Content
|
||||
</Container>
|
||||
);
|
||||
const linkElement = screen.getByText('Link Content');
|
||||
await user.click(linkElement);
|
||||
expect(handlePress).not.toHaveBeenCalled();
|
||||
expect(linkElement.tagName).toBe('A');
|
||||
});
|
||||
|
||||
it('warns when both href and onPress are provided', () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
render(
|
||||
<Container href="/test-path" onPress={() => {}}>
|
||||
Content
|
||||
</Container>
|
||||
);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'[@ermnvldmr/ui] Container cannot have both `onPress` and `href` props.'
|
||||
);
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -84,9 +84,17 @@ export interface ContainerProps extends ClassNameProps, TestIdProps {
|
||||
shadow?: boolean;
|
||||
/** Whether to show a border */
|
||||
border?: boolean;
|
||||
/** Press interaction handler */
|
||||
/**
|
||||
* Press interaction handler.
|
||||
* Cannot be used with `href`.
|
||||
*/
|
||||
onPress?: PressEvents['onPress'];
|
||||
/** The HTML element to use for rendering */
|
||||
/**
|
||||
* If provided, the container will be rendered as an `<a>` tag.
|
||||
* Cannot be used with `onPress`.
|
||||
*/
|
||||
href?: string;
|
||||
/** The HTML element to use for rendering. Overridden to `a` if `href` is provided. */
|
||||
as?: React.ElementType;
|
||||
}
|
||||
|
||||
@@ -105,17 +113,26 @@ export const Container = memo(function Container({
|
||||
shadow = false,
|
||||
border = false,
|
||||
onPress,
|
||||
href,
|
||||
as: Component = 'div',
|
||||
className,
|
||||
'data-testid': testId,
|
||||
}: ContainerProps) {
|
||||
if (process.env.NODE_ENV !== 'production' && onPress && href) {
|
||||
console.warn('[@ermnvldmr/ui] Container cannot have both `onPress` and `href` props.');
|
||||
}
|
||||
|
||||
const isLink = href != null;
|
||||
const isPressable = !!onPress && !isLink;
|
||||
|
||||
const ref = React.useRef<HTMLElement>(null);
|
||||
const { pressProps } = usePress({
|
||||
onPress,
|
||||
isDisabled: !onPress,
|
||||
isDisabled: !isPressable,
|
||||
});
|
||||
|
||||
const interactionProps = onPress ? pressProps : {};
|
||||
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)]',
|
||||
@@ -181,7 +198,7 @@ export const Container = memo(function Container({
|
||||
};
|
||||
|
||||
return (
|
||||
<Component
|
||||
<FinalComponent
|
||||
{...interactionProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
@@ -192,12 +209,14 @@ export const Container = memo(function Container({
|
||||
paddingClasses[padding],
|
||||
shadow && 'shadow-md',
|
||||
border && 'border border-[var(--rb-outline)]',
|
||||
onPress && 'cursor-pointer active:opacity-80 transition-opacity',
|
||||
(isPressable || isLink) &&
|
||||
'cursor-pointer transition-all duration-200 hover:brightness-95 active:opacity-80',
|
||||
className
|
||||
)}
|
||||
data-testid={testId}
|
||||
href={href}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
</FinalComponent>
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { Separator } from '../Separator/Separator';
|
||||
import { Text } from '../Text/Text';
|
||||
import { VStack } from '../VStack/VStack';
|
||||
|
||||
import type { TextProps, TextType, TextSize } from '../Text/Text';
|
||||
|
||||
@@ -19,7 +21,14 @@ export interface HeaderProps extends Omit<TextProps, 'as'> {
|
||||
* typography variants in the design system, ensuring accessibility
|
||||
* and visual consistency.
|
||||
*/
|
||||
export const Header = memo(function Header({ level, type, size, children, ...props }: HeaderProps) {
|
||||
export const Header = memo(function Header({
|
||||
level,
|
||||
type,
|
||||
size,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HeaderProps) {
|
||||
// Default mappings from level to type and size
|
||||
const levelMapping: Record<number, { type: TextType; size: TextSize }> = {
|
||||
1: { type: 'display', size: 'l' },
|
||||
@@ -41,9 +50,26 @@ export const Header = memo(function Header({ level, type, size, children, ...pro
|
||||
} as const;
|
||||
const Tag = tags[level];
|
||||
|
||||
return (
|
||||
<Text as={Tag} size={size ?? defaultStyles.size} type={type ?? defaultStyles.type} {...props}>
|
||||
const content = (
|
||||
<Text
|
||||
as={Tag}
|
||||
className={className}
|
||||
size={size ?? defaultStyles.size}
|
||||
type={type ?? defaultStyles.type}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
|
||||
if (level <= 2) {
|
||||
return (
|
||||
<VStack className="w-fit" gap={0.5}>
|
||||
{content}
|
||||
<Separator thinned="thinned-end" />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
});
|
||||
@@ -1,9 +1,23 @@
|
||||
import React from 'react';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { Link } from './Link';
|
||||
import { render, screen } from '../../lib/testing';
|
||||
|
||||
describe('components/Link', () => {
|
||||
beforeEach(() => {
|
||||
// Mock IntersectionObserver to avoid errors in Text component
|
||||
class MockIntersectionObserver {
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
}
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders correctly as a link when href is provided', () => {
|
||||
render(<Link href="https://example.com">Click me</Link>);
|
||||
const link = screen.getByRole('link', { name: 'Click me' });
|
||||
@@ -44,4 +58,14 @@ describe('components/Link', () => {
|
||||
render(<Link onPress={() => {}}>Click me</Link>);
|
||||
expect(screen.getByText('Click me')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes delay prop to the underlying Text component', () => {
|
||||
render(
|
||||
<Link delay={500} href="/test">
|
||||
Delayed Link
|
||||
</Link>
|
||||
);
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveClass('opacity-0');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { localePath } from '@ermnvldmr/i18n';
|
||||
import { castRef, cn } from '@ermnvldmr/stl';
|
||||
import React, { memo, useRef } from 'react';
|
||||
import { useLink } from 'react-aria';
|
||||
@@ -24,6 +25,8 @@ export interface LinkProps extends AriaLinkOptions, ClassNameProps {
|
||||
size?: TextSize;
|
||||
/** Text color variant */
|
||||
color?: TextColor;
|
||||
/** Delay in milliseconds before the link appears after entering the viewport. */
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,7 +36,7 @@ export interface LinkProps extends AriaLinkOptions, ClassNameProps {
|
||||
* It uses standard design system colors (--rb-ring) and provides responsive hover/focus states.
|
||||
*/
|
||||
export const Link = memo(function Link(props: LinkProps) {
|
||||
const { children, className, href, isExternal, type, size, color, ...otherProps } = props;
|
||||
const { children, className, href, isExternal, type, size, color, delay, ...otherProps } = props;
|
||||
|
||||
// We use a specific ref type that useLink expects.
|
||||
// Since Link can be an 'a' or a 'span', we use HTMLElement as common denominator.
|
||||
@@ -42,12 +45,13 @@ export const Link = memo(function Link(props: LinkProps) {
|
||||
|
||||
const sharedClasses = cn(
|
||||
!color && 'text-[var(--rb-ring)]',
|
||||
'cursor-pointer hover:underline underline-offset-4 transition-colors',
|
||||
'cursor-pointer underline hover:no-underline underline-offset-4 transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className
|
||||
);
|
||||
|
||||
if (href) {
|
||||
const resolvedHref = localePath(href);
|
||||
const isAutoExternal = isExternal ?? href.startsWith('http');
|
||||
const externalProps = isAutoExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {};
|
||||
|
||||
@@ -59,7 +63,8 @@ export const Link = memo(function Link(props: LinkProps) {
|
||||
as="a"
|
||||
className={sharedClasses}
|
||||
color={color}
|
||||
href={href}
|
||||
delay={delay}
|
||||
href={resolvedHref}
|
||||
size={size}
|
||||
type={type}
|
||||
>
|
||||
@@ -75,6 +80,7 @@ export const Link = memo(function Link(props: LinkProps) {
|
||||
as="span"
|
||||
className={sharedClasses}
|
||||
color={color}
|
||||
delay={delay}
|
||||
size={size}
|
||||
type={type}
|
||||
>
|
||||
|
||||
@@ -161,3 +161,66 @@ export const ListStyles: Story = {
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Showcases code blocks with language-specific syntax highlighting.
|
||||
*/
|
||||
export const CodeLanguages: Story = {
|
||||
render: () => (
|
||||
<div className="max-w-[800px] mx-auto space-y-8">
|
||||
<div>
|
||||
<Markdown.H3 className="mb-4">TypeScript</Markdown.H3>
|
||||
<Markdown.Pre label="example.ts">
|
||||
<Markdown.Code className="language-typescript">
|
||||
{`interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const greet = (user: User) => {
|
||||
console.log(\`Hello, \${user.name}!\`);
|
||||
};`}
|
||||
</Markdown.Code>
|
||||
</Markdown.Pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Markdown.H3 className="mb-4">Rust</Markdown.H3>
|
||||
<Markdown.Pre label="main.rs">
|
||||
<Markdown.Code className="language-rust">
|
||||
{`fn main() {
|
||||
let name = "World";
|
||||
println!("Hello, {}!", name);
|
||||
}`}
|
||||
</Markdown.Code>
|
||||
</Markdown.Pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Markdown.H3 className="mb-4">Python</Markdown.H3>
|
||||
<Markdown.Pre label="script.py">
|
||||
<Markdown.Code className="language-python">
|
||||
{`def greet(name: str):
|
||||
print(f"Hello, {name}!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
greet("World")`}
|
||||
</Markdown.Code>
|
||||
</Markdown.Pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Markdown.H3 className="mb-4">JSON</Markdown.H3>
|
||||
<Markdown.Pre label="data.json">
|
||||
<Markdown.Code className="language-json">
|
||||
{`{
|
||||
"project": "ermnvldmr.com",
|
||||
"version": "1.0.0",
|
||||
"private": true
|
||||
}`}
|
||||
</Markdown.Code>
|
||||
</Markdown.Pre>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -29,7 +29,7 @@ describe('Markdown Components', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText('Heading 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('This is a paragraph.')).toBeInTheDocument();
|
||||
expect(screen.getByText('This is a paragraph.')).toHaveClass('mb-4');
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://example.com');
|
||||
expect(screen.getByRole('list')).toBeInTheDocument();
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
|
||||
@@ -1,12 +1,43 @@
|
||||
import React, { memo } from 'react';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
|
||||
import { Code } from '../../Code/Code';
|
||||
import { SUPPORTED_LANGUAGES } from '../../Code/lib/highlighter/highlighter';
|
||||
|
||||
import type { CodeProps } from '../../Code/Code';
|
||||
import type { CodeLanguage } from '../../Code/lib/highlighter/highlighter';
|
||||
|
||||
/**
|
||||
* Checks if a string is a valid code language.
|
||||
* @param lang The string to check.
|
||||
* @returns True if the string is a valid code language.
|
||||
* @example
|
||||
* ```typescript
|
||||
* isCodeLanguage('typescript'); // true
|
||||
* isCodeLanguage('foo'); // false
|
||||
* ```
|
||||
*/
|
||||
function isCodeLanguage(lang: string): lang is CodeLanguage {
|
||||
return SUPPORTED_LANGUAGES.some((l) => l === lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* A Markdown code component for inline snippets.
|
||||
*/
|
||||
export const MarkdownCode = memo(function MarkdownCode(props: CodeProps) {
|
||||
return <Code {...props} />;
|
||||
const { className, ...rest } = props;
|
||||
|
||||
const language = useMemo(() => {
|
||||
if (className) {
|
||||
const match = /language-(\w+)/.exec(className);
|
||||
if (match) {
|
||||
const lang = match[1];
|
||||
if (isCodeLanguage(lang)) {
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}, [className]);
|
||||
|
||||
return <Code language={language} {...rest} />;
|
||||
});
|
||||
@@ -1,12 +1,17 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { List } from '../../List/List';
|
||||
import { MarkdownP } from '../MarkdownP/MarkdownP';
|
||||
|
||||
import type { ListItemProps } from '../../List/List';
|
||||
|
||||
/**
|
||||
* A Markdown list item component.
|
||||
*/
|
||||
export const MarkdownLI = memo(function MarkdownLI(props: ListItemProps) {
|
||||
return <List.Item {...props} />;
|
||||
export const MarkdownLI = memo(function MarkdownLI({ children, ...props }: ListItemProps) {
|
||||
return (
|
||||
<List.Item {...props}>
|
||||
<MarkdownP>{children}</MarkdownP>
|
||||
</List.Item>
|
||||
);
|
||||
});
|
||||
@@ -1,8 +1,24 @@
|
||||
import React, { memo } from 'react';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
|
||||
import { CodeBlock } from '../../Code/CodeBlock/CodeBlock';
|
||||
import { SUPPORTED_LANGUAGES } from '../../Code/lib/highlighter/highlighter';
|
||||
|
||||
import type { CodeBlockProps } from '../../Code/CodeBlock/CodeBlock';
|
||||
import type { CodeLanguage } from '../../Code/lib/highlighter/highlighter';
|
||||
|
||||
/**
|
||||
* Checks if a string is a valid code language.
|
||||
* @param lang The string to check.
|
||||
* @returns True if the string is a valid code language.
|
||||
* @example
|
||||
* ```typescript
|
||||
* isCodeLanguage('typescript'); // true
|
||||
* isCodeLanguage('foo'); // false
|
||||
* ```
|
||||
*/
|
||||
function isCodeLanguage(lang: string): lang is CodeLanguage {
|
||||
return SUPPORTED_LANGUAGES.some((l) => l === lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* A Markdown pre component for code blocks.
|
||||
@@ -10,5 +26,27 @@ import type { CodeBlockProps } from '../../Code/CodeBlock/CodeBlock';
|
||||
* Wraps content in the design system CodeBlock.
|
||||
*/
|
||||
export const MarkdownPre = memo(function MarkdownPre(props: CodeBlockProps) {
|
||||
return <CodeBlock {...props} />;
|
||||
const { children, ...rest } = props;
|
||||
|
||||
// MDX often passes a <code> element as the only child of <pre>.
|
||||
// That <code> element usually has the language class (e.g., "language-typescript").
|
||||
const language = useMemo(() => {
|
||||
if (React.isValidElement<{ className?: string }>(children) && children.props.className) {
|
||||
const className = children.props.className;
|
||||
const match = /language-(\w+)/.exec(className);
|
||||
if (match) {
|
||||
const lang = match[1];
|
||||
if (isCodeLanguage(lang)) {
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}, [children]);
|
||||
|
||||
return (
|
||||
<CodeBlock language={language} {...rest}>
|
||||
{children}
|
||||
</CodeBlock>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { PageColumn } from './PageColumn';
|
||||
|
||||
describe('PageColumn', () => {
|
||||
it('renders children correctly', () => {
|
||||
render(
|
||||
<PageColumn>
|
||||
<div data-testid="child">Test Content</div>
|
||||
</PageColumn>
|
||||
);
|
||||
expect(screen.getByTestId('child')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies size classes', () => {
|
||||
const { container: small } = render(<PageColumn size="small">Content</PageColumn>);
|
||||
expect(small.firstChild).toHaveClass('lg:w-[var(--rb-page-column-small,300px)]');
|
||||
|
||||
const { container: full } = render(<PageColumn size="full">Content</PageColumn>);
|
||||
expect(full.firstChild).toHaveClass('flex-1');
|
||||
});
|
||||
|
||||
it('handles sticky prop', () => {
|
||||
const { container } = render(<PageColumn sticky>Content</PageColumn>);
|
||||
expect(container.firstChild).toHaveClass('lg:sticky');
|
||||
expect(container.firstChild).toHaveClass('lg:top-24');
|
||||
});
|
||||
|
||||
it('renders as different element', () => {
|
||||
render(<PageColumn as="aside">Content</PageColumn>);
|
||||
expect(screen.getByRole('complementary')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Props for the PageColumn component.
|
||||
*/
|
||||
export interface PageColumnProps {
|
||||
/** The content of the column. */
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* The size of the column on desktop.
|
||||
* - `small`: Fixed width (300px)
|
||||
* - `full`: Takes up remaining space (flex-1)
|
||||
* @default 'full'
|
||||
*/
|
||||
size?: 'small' | 'full';
|
||||
/**
|
||||
* If true, the column will stick to the top when scrolling on desktop.
|
||||
* @default false
|
||||
*/
|
||||
sticky?: boolean;
|
||||
/**
|
||||
* Optional additional class names.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* The HTML element to use for the root of the component.
|
||||
* @default 'div'
|
||||
*/
|
||||
as?: React.ElementType;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* A single column unit within the PageColumns host.
|
||||
*
|
||||
* @param props - The component props.
|
||||
* @returns A PageColumn component.
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PageColumn size="small">Sidebar</PageColumn>
|
||||
* ```
|
||||
*/
|
||||
export const PageColumn = ({
|
||||
children,
|
||||
size = 'full',
|
||||
sticky = false,
|
||||
className,
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
|
||||
import { PageColumns } from './PageColumns';
|
||||
import { PageColumn } from '../PageColumn/PageColumn';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
const meta: Meta<typeof PageColumns> = {
|
||||
title: 'components/Page/PageColumns',
|
||||
component: PageColumns,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PageColumns>;
|
||||
|
||||
const Box = ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div
|
||||
className={`p-6 bg-slate-100 dark:bg-slate-900 border-2 border-slate-300 dark:border-slate-700 rounded-xl h-full flex items-center justify-center font-bold text-center ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* A standard two-column layout: small sidebar and full main area.
|
||||
*/
|
||||
export const TwoColumnsSidebarLeft: Story = {
|
||||
args: {
|
||||
gap: 8,
|
||||
children: (
|
||||
<>
|
||||
<PageColumn size="small">
|
||||
<Box className="bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
|
||||
Small Sidebar
|
||||
</Box>
|
||||
</PageColumn>
|
||||
<PageColumn size="full">
|
||||
<Box className="min-h-[200px]">Main Full Content</Box>
|
||||
</PageColumn>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A standard two-column layout with sidebar on the right.
|
||||
*/
|
||||
export const TwoColumnsSidebarRight: Story = {
|
||||
args: {
|
||||
gap: 8,
|
||||
children: (
|
||||
<>
|
||||
<PageColumn size="full">
|
||||
<Box className="min-h-[200px]">Main Full Content</Box>
|
||||
</PageColumn>
|
||||
<PageColumn size="small">
|
||||
<Box className="bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
|
||||
Small Sidebar
|
||||
</Box>
|
||||
</PageColumn>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Three columns: small sidebar on both sides.
|
||||
*/
|
||||
export const ThreeColumns: Story = {
|
||||
args: {
|
||||
gap: 6,
|
||||
children: (
|
||||
<>
|
||||
<PageColumn size="small">
|
||||
<Box className="bg-blue-50 dark:bg-blue-950 border-blue-200">Left Sidebar</Box>
|
||||
</PageColumn>
|
||||
<PageColumn size="full">
|
||||
<Box className="min-h-[200px]">Primary Content</Box>
|
||||
</PageColumn>
|
||||
<PageColumn size="small">
|
||||
<Box className="bg-blue-50 dark:bg-blue-950 border-blue-200">Right Sidebar</Box>
|
||||
</PageColumn>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Two full-width columns sharing space equally.
|
||||
*/
|
||||
export const TwoFullColumns: Story = {
|
||||
args: {
|
||||
gap: 8,
|
||||
children: (
|
||||
<>
|
||||
<PageColumn size="full">
|
||||
<Box className="min-h-[200px]">Column A (full)</Box>
|
||||
</PageColumn>
|
||||
<PageColumn size="full">
|
||||
<Box className="min-h-[200px]">Column B (full)</Box>
|
||||
</PageColumn>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A sidebar that sticks to the top when scrolling.
|
||||
*/
|
||||
export const StickySidebar: Story = {
|
||||
render: (args) => (
|
||||
<div className="h-[800px] overflow-y-auto border-2 border-dashed p-4 rounded-lg">
|
||||
<p className="mb-4 text-center italic text-slate-500">Scroll down to see the sidebar stick</p>
|
||||
<PageColumns {...args} gap={8}>
|
||||
<PageColumn size="full">
|
||||
<div className="flex flex-col gap-4">
|
||||
{Array.from({ length: 15 }).map((_, i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<Box key={i} className="min-h-[100px]">
|
||||
Main Content Block {i + 1}
|
||||
</Box>
|
||||
))}
|
||||
</div>
|
||||
</PageColumn>
|
||||
<PageColumn sticky size="small">
|
||||
<Box className="bg-yellow-50 dark:bg-yellow-950 border-yellow-200 min-h-[300px]">
|
||||
I am a Sticky Sidebar!
|
||||
</Box>
|
||||
</PageColumn>
|
||||
</PageColumns>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { PageColumns } from './PageColumns';
|
||||
|
||||
describe('PageColumns', () => {
|
||||
it('renders children correctly', () => {
|
||||
render(
|
||||
<PageColumns>
|
||||
<div data-testid="child">Test Content</div>
|
||||
</PageColumns>
|
||||
);
|
||||
expect(screen.getByTestId('child')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies gap class', () => {
|
||||
const { container: gap8 } = render(<PageColumns gap={8}>Content</PageColumns>);
|
||||
expect(gap8.firstChild).toHaveClass('gap-8');
|
||||
|
||||
const { container: gapCustom } = render(<PageColumns gap="gap-[20px]">Content</PageColumns>);
|
||||
expect(gapCustom.firstChild).toHaveClass('gap-[20px]');
|
||||
});
|
||||
|
||||
it('has base responsive classes', () => {
|
||||
const { container } = render(<PageColumns>Content</PageColumns>);
|
||||
expect(container.firstChild).toHaveClass('flex');
|
||||
expect(container.firstChild).toHaveClass('flex-col');
|
||||
expect(container.firstChild).toHaveClass('lg:flex-row');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Props for the PageColumns component.
|
||||
*/
|
||||
export interface PageColumnsProps {
|
||||
/** The columns to be displayed within the grid. */
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Spacing between columns.
|
||||
* @default 6
|
||||
*/
|
||||
gap?: number | string;
|
||||
/**
|
||||
* Optional additional class names.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Host component for a responsive column system.
|
||||
* It handles the flex/grid layout for page columns.
|
||||
*
|
||||
* @param props - The component props.
|
||||
* @returns A PageColumns component.
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PageColumns gap={8}>
|
||||
* <PageColumn size="small">Sidebar</PageColumn>
|
||||
* <PageColumn size="full">Main Content</PageColumn>
|
||||
* </PageColumns>
|
||||
* ```
|
||||
*/
|
||||
export const PageColumns = ({ children, gap = 6, className }: PageColumnsProps) => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
|
||||
import { PageContainer } from './PageContainer';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
const meta: Meta<typeof PageContainer> = {
|
||||
title: 'components/Page/PageContainer',
|
||||
component: PageContainer,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
width: {
|
||||
control: 'select',
|
||||
options: ['slim', 'default', 'wide', 'full'],
|
||||
},
|
||||
paddingX: {
|
||||
control: 'select',
|
||||
options: ['none', 'small', 'medium', 'large'],
|
||||
},
|
||||
paddingY: {
|
||||
control: 'select',
|
||||
options: ['none', 'small', 'medium', 'large'],
|
||||
},
|
||||
centerVertically: {
|
||||
control: 'boolean',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PageContainer>;
|
||||
|
||||
const Box = ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div
|
||||
className={`p-4 bg-slate-200 dark:bg-slate-800 border-2 border-slate-400 dark:border-slate-600 rounded-lg ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Standard container with default width and padding.
|
||||
*/
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: <Box>Default Container Content</Box>,
|
||||
width: 'default',
|
||||
paddingY: 'medium',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Narrower slim container, ideal for articles.
|
||||
*/
|
||||
export const Slim: Story = {
|
||||
args: {
|
||||
children: <Box>Slim Container Content (1100px Max-Width)</Box>,
|
||||
width: 'slim',
|
||||
paddingY: 'medium',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Wider wide container, useful for dashboards.
|
||||
*/
|
||||
export const Wide: Story = {
|
||||
args: {
|
||||
children: <Box>Wide Container Content (1920px Max-Width)</Box>,
|
||||
width: 'wide',
|
||||
paddingY: 'medium',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Vertically centered container.
|
||||
*/
|
||||
export const VerticallyCentered: Story = {
|
||||
render: (args) => (
|
||||
<div className="h-[400px] border-2 border-dashed border-slate-300 dark:border-slate-700 m-4 flex flex-col">
|
||||
<PageContainer {...args} centerVertically className="flex-1">
|
||||
<Box className="text-center font-bold">I am vertically centered!</Box>
|
||||
</PageContainer>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Demonstration of different horizontal padding scales.
|
||||
*/
|
||||
export const PaddingScales: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-8 py-8">
|
||||
<div>
|
||||
<h3 className="mb-2 font-bold px-4">paddingX: small</h3>
|
||||
<PageContainer paddingX="small" width="full">
|
||||
<Box className="bg-blue-100 dark:bg-blue-900 border-blue-300">Small Padding Content</Box>
|
||||
</PageContainer>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-2 font-bold px-4">paddingX: medium (Default)</h3>
|
||||
<PageContainer paddingX="medium" width="full">
|
||||
<Box className="bg-green-100 dark:bg-green-900 border-green-300">
|
||||
Medium Padding Content
|
||||
</Box>
|
||||
</PageContainer>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-2 font-bold px-4">paddingX: large</h3>
|
||||
<PageContainer paddingX="large" width="full">
|
||||
<Box className="bg-purple-100 dark:bg-purple-900 border-purple-300">
|
||||
Large Padding Content
|
||||
</Box>
|
||||
</PageContainer>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { PageContainer } from './PageContainer';
|
||||
|
||||
describe('PageContainer', () => {
|
||||
it('renders children correctly', () => {
|
||||
render(
|
||||
<PageContainer>
|
||||
<div data-testid="child">Test Content</div>
|
||||
</PageContainer>
|
||||
);
|
||||
expect(screen.getByTestId('child')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies correct width classes', () => {
|
||||
const { container: slim } = render(<PageContainer width="slim">Content</PageContainer>);
|
||||
expect(slim.firstChild).toHaveClass('max-w-[var(--rb-page-width-slim,1100px)]');
|
||||
|
||||
const { container: def } = render(<PageContainer width="default">Content</PageContainer>);
|
||||
expect(def.firstChild).toHaveClass('max-w-[var(--rb-page-width-default,1600px)]');
|
||||
|
||||
const { container: wide } = render(<PageContainer width="wide">Content</PageContainer>);
|
||||
expect(wide.firstChild).toHaveClass('max-w-[var(--rb-page-width-wide,1920px)]');
|
||||
|
||||
const { container: full } = render(<PageContainer width="full">Content</PageContainer>);
|
||||
expect(full.firstChild).toHaveClass('max-w-full');
|
||||
});
|
||||
|
||||
it('applies padding classes', () => {
|
||||
const { container: small } = render(
|
||||
<PageContainer paddingX="small" paddingY="small">
|
||||
Content
|
||||
</PageContainer>
|
||||
);
|
||||
expect(small.firstChild).toHaveClass('px-2');
|
||||
expect(small.firstChild).toHaveClass('py-4');
|
||||
|
||||
const { container: large } = render(
|
||||
<PageContainer paddingX="large" paddingY="large">
|
||||
Content
|
||||
</PageContainer>
|
||||
);
|
||||
expect(large.firstChild).toHaveClass('px-6');
|
||||
expect(large.firstChild).toHaveClass('py-12');
|
||||
});
|
||||
|
||||
it('handles centerVertically prop', () => {
|
||||
const { container } = render(<PageContainer centerVertically>Content</PageContainer>);
|
||||
expect(container.firstChild).toHaveClass('flex-1');
|
||||
expect(container.firstChild).toHaveClass('flex-col');
|
||||
expect(container.firstChild).toHaveClass('justify-center');
|
||||
});
|
||||
|
||||
it('renders as different element', () => {
|
||||
render(<PageContainer as="main">Content</PageContainer>);
|
||||
expect(screen.getByRole('main')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Supported width configurations for the PageContainer.
|
||||
* - `slim`: 1100px
|
||||
* - `default`: 1600px
|
||||
* - `wide`: 1920px
|
||||
* - `full`: 100%
|
||||
*/
|
||||
export type PageWidth = 'slim' | 'default' | 'wide' | 'full';
|
||||
|
||||
/**
|
||||
* Supported padding scales for the PageContainer.
|
||||
* - `none`: No padding.
|
||||
* - `small`: Standard small padding.
|
||||
* - `medium`: Standard medium padding.
|
||||
* - `large`: Standard large padding.
|
||||
*/
|
||||
export type PagePadding = 'none' | 'small' | 'medium' | 'large';
|
||||
|
||||
/**
|
||||
* Props for the PageContainer.
|
||||
*/
|
||||
export interface PageContainerProps {
|
||||
/** The content of the container. */
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* The maximum width of the container on desktop.
|
||||
* @default 'default'
|
||||
*/
|
||||
width?: PageWidth;
|
||||
/**
|
||||
* Horizontal padding scale.
|
||||
* @default 'medium'
|
||||
*/
|
||||
paddingX?: PagePadding;
|
||||
/**
|
||||
* Vertical padding scale.
|
||||
* @default 'none'
|
||||
*/
|
||||
paddingY?: PagePadding;
|
||||
/**
|
||||
* If true, vertically centers the content on the page.
|
||||
* Has no effect if the content is taller than the viewport.
|
||||
* @default false
|
||||
*/
|
||||
centerVertically?: boolean;
|
||||
/**
|
||||
* Optional additional class names.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* The HTML element to use for the root of the component.
|
||||
* @default 'div'
|
||||
*/
|
||||
as?: React.ElementType;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Manages the horizontal and vertical constraints for page content.
|
||||
*
|
||||
* @param props - The component props.
|
||||
* @returns A PageContainer component.
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PageContainer width="slim" paddingY="medium">
|
||||
* Main Content
|
||||
* </PageContainer>
|
||||
* ```
|
||||
*/
|
||||
export const PageContainer = ({
|
||||
children,
|
||||
width = 'default',
|
||||
paddingX = 'medium',
|
||||
paddingY = 'none',
|
||||
centerVertically = false,
|
||||
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,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
|
||||
import { PageHead } from './PageHead';
|
||||
import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs';
|
||||
import { Button } from '../../Button/Button';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
const meta: Meta<typeof PageHead> = {
|
||||
title: 'Components/PageHead',
|
||||
component: PageHead,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PageHead>;
|
||||
|
||||
const dummyParagraphs = Array.from({ length: 50 }).map((_, i) => `paragraph-${i}`);
|
||||
|
||||
const DummyContent = () => (
|
||||
<div className="p-8">
|
||||
{dummyParagraphs.map((key) => (
|
||||
<p key={key}>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut
|
||||
labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco
|
||||
laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
|
||||
voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat
|
||||
cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
heading: 'Page Title',
|
||||
caption: 'A short and inspiring caption for the page.',
|
||||
breadcrumbs: (
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item>
|
||||
<a href="/">Home</a>
|
||||
</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Separator />
|
||||
<Breadcrumbs.Item>Components</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Separator />
|
||||
<Breadcrumbs.Item>PageHead</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
),
|
||||
addonLeft: <Button variant="outline">Back</Button>,
|
||||
addonRight: <Button>Primary Action</Button>,
|
||||
},
|
||||
render: (args) => (
|
||||
<div>
|
||||
<PageHead {...args} />
|
||||
<DummyContent />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const CollapsibleSticky: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
strategy: 'collapsible-sticky',
|
||||
},
|
||||
};
|
||||
|
||||
export const AlwaysExpandedFixed: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
strategy: 'always-expanded-fixed',
|
||||
},
|
||||
};
|
||||
|
||||
export const AlwaysCollapsedSticky: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
strategy: 'always-collapsed-sticky',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutAddons: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
addonLeft: undefined,
|
||||
addonRight: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutBreadcrumbs: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
breadcrumbs: undefined,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { PageHead } from './PageHead';
|
||||
|
||||
describe('PageHead', () => {
|
||||
it('renders the heading and caption', () => {
|
||||
render(<PageHead caption="Test Caption" heading="Test Heading" />);
|
||||
// Both layouts are rendered (one hidden), so we use getAllByText
|
||||
expect(screen.getAllByText('Test Heading')[0]).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Caption')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders breadcrumbs when provided', () => {
|
||||
render(<PageHead breadcrumbs={<div>My Breadcrumbs</div>} heading="Test" />);
|
||||
expect(screen.getByText('My Breadcrumbs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders addons when provided', () => {
|
||||
render(
|
||||
<PageHead
|
||||
addonLeft={<div>Left Addon</div>}
|
||||
addonRight={<div>Right Addon</div>}
|
||||
heading="Test"
|
||||
/>
|
||||
);
|
||||
// Addons are rendered in both layouts
|
||||
expect(screen.getAllByText('Left Addon')[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Right Addon')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies the correct classes for the strategy', () => {
|
||||
render(<PageHead heading="Test" strategy="always-collapsed-fixed" />);
|
||||
const header = screen.getByRole('banner');
|
||||
expect(header).toHaveClass('fixed');
|
||||
expect(header).toHaveClass('shadow-md');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { cn, useScroll, useResizeObserver } from '@ermnvldmr/stl';
|
||||
import React, { type ReactNode, useRef, useEffect, useState } from 'react';
|
||||
|
||||
import { Header } from '../../Header/Header';
|
||||
import { HStack } from '../../HStack/HStack';
|
||||
import { Text } from '../../Text/Text';
|
||||
import { VStack } from '../../VStack/VStack';
|
||||
import { PageContainer } from '../PageContainer/PageContainer';
|
||||
|
||||
/**
|
||||
* Defines the positioning and behavior of the PageHead component.
|
||||
* - `always-collapsed-fixed`: Always collapsed and fixed to the top.
|
||||
* - `always-collapsed-sticky`: Always collapsed and sticks to the top when scrolling.
|
||||
* - `always-expanded-fixed`: Always expanded and fixed to the top.
|
||||
* - `always-expanded-sticky`: Always expanded and sticks to the top when scrolling.
|
||||
* - `collapsible-sticky`: Starts expanded and collapses when scrolling down, sticking to the top.
|
||||
*/
|
||||
export type PageHeadStrategy =
|
||||
| 'always-collapsed-fixed'
|
||||
| 'always-collapsed-sticky'
|
||||
| 'always-expanded-fixed'
|
||||
| 'always-expanded-sticky'
|
||||
| 'collapsible-sticky';
|
||||
|
||||
/**
|
||||
* Props for the PageHead component.
|
||||
*/
|
||||
export interface PageHeadProps {
|
||||
/** The main heading text. */
|
||||
heading: string;
|
||||
/** The caption text displayed below the heading. */
|
||||
caption?: ReactNode;
|
||||
/** The breadcrumbs navigation component. */
|
||||
breadcrumbs?: ReactNode;
|
||||
/** A component to be displayed on the left side of the header. */
|
||||
addonLeft?: ReactNode;
|
||||
/** A component to be displayed on the right side of the header. */
|
||||
addonRight?: ReactNode;
|
||||
/** The positioning and behavior strategy for the header. */
|
||||
strategy?: PageHeadStrategy;
|
||||
/** Additional CSS classes for the component. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A universal page head component with multiple layout and behavior strategies.
|
||||
*
|
||||
* @param props - The props for the component.
|
||||
* @returns A PageHead component.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PageHead
|
||||
* heading="Page Title"
|
||||
* caption="A short and inspiring caption"
|
||||
* strategy="collapsible-sticky"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const PageHead = ({
|
||||
heading,
|
||||
caption,
|
||||
breadcrumbs,
|
||||
addonLeft,
|
||||
addonRight,
|
||||
strategy = 'collapsible-sticky',
|
||||
className,
|
||||
}: PageHeadProps) => {
|
||||
const { y: scrollY } = useScroll();
|
||||
const [isCollapsed, setIsCollapsed] = useState(strategy.includes('always-collapsed'));
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const { height } = useResizeObserver(headerRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (strategy === 'collapsible-sticky') {
|
||||
setIsCollapsed(scrollY > 10);
|
||||
}
|
||||
}, [scrollY, strategy]);
|
||||
|
||||
const rootClasses = cn(
|
||||
'page-head w-full transition-all duration-200 bg-[var(--rb-background)] fixed top-0 z-10 border-b border-[var(--rb-outline)]/10',
|
||||
{
|
||||
'shadow-md bg-[var(--rb-background)]/90 backdrop-blur-md': isCollapsed,
|
||||
},
|
||||
className
|
||||
);
|
||||
|
||||
const expandedContent = (
|
||||
<PageContainer paddingY="medium">
|
||||
<VStack align="stretch" gap={6}>
|
||||
<HStack align="center" justify="between">
|
||||
<HStack align="center" gap={4}>
|
||||
{addonLeft && <div>{addonLeft}</div>}
|
||||
{breadcrumbs && <div className="hidden sm:block">{breadcrumbs}</div>}
|
||||
</HStack>
|
||||
{addonRight && <div>{addonRight}</div>}
|
||||
</HStack>
|
||||
<VStack gap={2}>
|
||||
<Header level={1}>{heading}</Header>
|
||||
{typeof caption === 'string' ? (
|
||||
<Text color="muted" size="l" type="body">
|
||||
{caption}
|
||||
</Text>
|
||||
) : (
|
||||
caption
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
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">
|
||||
{heading}
|
||||
</Header>
|
||||
</HStack>
|
||||
{addonRight && <div className="flex-shrink-0 ml-4">{addonRight}</div>}
|
||||
</HStack>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header ref={headerRef} className={rootClasses}>
|
||||
<div
|
||||
className={cn(
|
||||
'grid transition-[grid-template-rows] duration-300 ease-in-out',
|
||||
isCollapsed ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]'
|
||||
)}
|
||||
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>
|
||||
<div style={{ height }} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
|
||||
import { PageRoot } from './PageRoot';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
const meta: Meta<typeof PageRoot> = {
|
||||
title: 'components/Page/PageRoot',
|
||||
component: PageRoot,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PageRoot>;
|
||||
|
||||
/**
|
||||
* The default PageRoot story showing the base technical container.
|
||||
*/
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold">Page Root Content</h1>
|
||||
<p className="mt-4">
|
||||
This is the base technical container that handles theme initialization and core
|
||||
background/text colors.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* PageRoot with custom background classes.
|
||||
*/
|
||||
export const CustomStyling: Story = {
|
||||
args: {
|
||||
className: 'bg-slate-100 dark:bg-slate-900',
|
||||
children: (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold">Custom Styled Root</h1>
|
||||
<p className="mt-4 text-slate-600 dark:text-slate-400">
|
||||
PageRoot accepts a className prop to override or extend base styles.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { PageRoot } from './PageRoot';
|
||||
|
||||
describe('PageRoot', () => {
|
||||
it('renders children correctly', () => {
|
||||
render(
|
||||
<PageRoot>
|
||||
<div data-testid="child">Test Content</div>
|
||||
</PageRoot>
|
||||
);
|
||||
expect(screen.getByTestId('child')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(
|
||||
<PageRoot className="custom-class">
|
||||
<div>Content</div>
|
||||
</PageRoot>
|
||||
);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('has base technical classes', () => {
|
||||
const { container } = render(
|
||||
<PageRoot>
|
||||
<div>Content</div>
|
||||
</PageRoot>
|
||||
);
|
||||
expect(container.firstChild).toHaveClass('w-full');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import React from 'react';
|
||||
|
||||
import { useTheme } from '../../../lib/theme';
|
||||
|
||||
/**
|
||||
* Props for the PageRoot component.
|
||||
*/
|
||||
export interface PageRootProps {
|
||||
/** The main content slot. */
|
||||
children: React.ReactNode;
|
||||
/** Additional CSS classes for the root container. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* The technical bootstrap layer for all layouts.
|
||||
* It handles theme initialization and base background/text colors.
|
||||
*
|
||||
* @param props - The component props.
|
||||
* @returns A PageRoot component.
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PageRoot>
|
||||
* <PageContainer>Content</PageContainer>
|
||||
* </PageRoot>
|
||||
* ```
|
||||
*/
|
||||
export const PageRoot = ({ children, className }: PageRootProps) => {
|
||||
useTheme();
|
||||
|
||||
return <div className={cn('w-full transition-colors duration-200', className)}>{children}</div>;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './PageRoot/PageRoot';
|
||||
export * from './PageContainer/PageContainer';
|
||||
export * from './PageColumns/PageColumns';
|
||||
export * from './PageColumn/PageColumn';
|
||||
export * from './PageHead/PageHead';
|
||||
@@ -11,8 +11,8 @@ import type { Meta, StoryObj } from '@storybook/react';
|
||||
* - Single or double line variants inspired by newspaper layout traditions
|
||||
* - Multiple color options (black, outline, outline-light) with theme support
|
||||
* - Horizontal and vertical orientations
|
||||
* - Optional thinned gradient fade effect at line ends (10% fade length)
|
||||
* - 2px stroke width following print design standards
|
||||
* - Optional thinned gradient fade effect at line ends ('thinned', 'thinned-start', 'thinned-end', 'none')
|
||||
* - 1px stroke width following print design standards
|
||||
* - Semantic div elements with proper ARIA separator role for accessibility
|
||||
*/
|
||||
const meta: Meta<typeof Separator> = {
|
||||
@@ -44,14 +44,15 @@ const meta: Meta<typeof Separator> = {
|
||||
description: 'Orientation of the separator',
|
||||
},
|
||||
thinned: {
|
||||
control: 'boolean',
|
||||
description: 'Whether line ends should be thinned with gradient fade (10% fade length)',
|
||||
control: 'select',
|
||||
options: ['thinned', 'thinned-start', 'thinned-end', 'none'],
|
||||
description: 'How line ends should be thinned with gradient fade',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
type: 'single',
|
||||
direction: 'horizontal',
|
||||
thinned: true,
|
||||
thinned: 'thinned',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -63,7 +64,7 @@ type Story = StoryObj<typeof meta>;
|
||||
|
||||
/**
|
||||
* The default single-line separator with light outline color.
|
||||
* Uses the newspaper-standard 2px stroke with thinned gradient ends.
|
||||
* Uses the newspaper-standard 1px stroke with thinned gradient ends.
|
||||
*/
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
@@ -191,7 +192,7 @@ export const VerticalDouble: Story = {
|
||||
*/
|
||||
export const NonThinned: Story = {
|
||||
args: {
|
||||
thinned: false,
|
||||
thinned: 'none',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
@@ -251,19 +252,27 @@ export const AllTypes: Story = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Comparison between thinned and non-thinned effects.
|
||||
* Demonstrates the 10% gradient fade at line ends.
|
||||
* Comparison between different thinning effects.
|
||||
* Demonstrates the gradient fade at line ends.
|
||||
*/
|
||||
export const ThinnedComparison: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-8 w-96">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">With Thinned Ends (Default)</h3>
|
||||
<Separator thinned />
|
||||
<h3 className="text-sm font-medium mb-2">Full Thinned (Default)</h3>
|
||||
<Separator thinned="thinned" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Without Thinned Ends</h3>
|
||||
<Separator thinned={false} />
|
||||
<h3 className="text-sm font-medium mb-2">Thinned Start Only</h3>
|
||||
<Separator thinned="thinned-start" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Thinned End Only</h3>
|
||||
<Separator thinned="thinned-end" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">No Fading (None)</h3>
|
||||
<Separator thinned="none" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -5,6 +5,11 @@ import { VStack } from '../VStack/VStack';
|
||||
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
|
||||
/**
|
||||
* Options for thinning the separator line ends with a gradient fade.
|
||||
*/
|
||||
export type SeparatorThinnedType = 'thinned' | 'thinned-end' | 'thinned-start' | 'none';
|
||||
|
||||
/**
|
||||
* Props for the Separator component.
|
||||
*/
|
||||
@@ -15,10 +20,32 @@ export interface SeparatorProps extends ClassNameProps, TestIdProps {
|
||||
color?: 'black' | 'outline' | 'outline-light';
|
||||
/** Orientation of the separator */
|
||||
direction?: 'horizontal' | 'vertical';
|
||||
/** Whether line ends should be thinned with gradient fade */
|
||||
thinned?: boolean;
|
||||
/**
|
||||
* How line ends should be thinned with gradient fade.
|
||||
* - 'thinned': Both ends fade out (default)
|
||||
* - 'thinned-start': Only the start of the line fades out
|
||||
* - 'thinned-end': Only the end of the line fades out
|
||||
* - 'none': No fading, sharp edges
|
||||
*/
|
||||
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)]',
|
||||
},
|
||||
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)]',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A separator component for dividing content sections.
|
||||
*
|
||||
@@ -26,33 +53,18 @@ export interface SeparatorProps extends ClassNameProps, TestIdProps {
|
||||
* - Single or double line variants inspired by newspaper layout traditions
|
||||
* - Multiple color options (black, outline, outline-light) with theme support
|
||||
* - Horizontal and vertical orientations
|
||||
* - Optional thinned gradient fade effect at line ends (10% fade length)
|
||||
* - 2px stroke width following print design standards
|
||||
* - Optional thinned gradient fade effect at line ends ('thinned', 'thinned-start', 'thinned-end', 'none')
|
||||
* - 1px stroke width for subtle division
|
||||
* - Semantic HTML with proper ARIA attributes for accessibility
|
||||
*
|
||||
* The component uses CSS mask-image for the thinned effect and CSS custom properties
|
||||
* for theme-aware colors. Double separators have 4px spacing between lines.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Basic horizontal separator
|
||||
* <Separator />
|
||||
*
|
||||
* // Double-line separator with black color
|
||||
* <Separator type="double" color="black" />
|
||||
*
|
||||
* // Vertical separator without thinned ends
|
||||
* <Separator direction="vertical" thinned={false} />
|
||||
*
|
||||
* // Light separator for subtle content division
|
||||
* <Separator color="outline-light" />
|
||||
* ```
|
||||
*/
|
||||
export const Separator = memo(function Separator({
|
||||
type = 'single',
|
||||
color,
|
||||
direction = 'horizontal',
|
||||
thinned = true,
|
||||
thinned = 'thinned',
|
||||
className,
|
||||
'data-testid': testId,
|
||||
}: SeparatorProps) {
|
||||
@@ -63,8 +75,8 @@ export const Separator = memo(function Separator({
|
||||
// Color mapping to CSS custom properties
|
||||
const colorClasses = {
|
||||
black: 'border-foreground',
|
||||
outline: 'border-border',
|
||||
'outline-light': 'border-muted',
|
||||
outline: 'border-[var(--rb-outline)]',
|
||||
'outline-light': 'border-[var(--rb-outline)]/40',
|
||||
};
|
||||
|
||||
// Base line styles
|
||||
@@ -72,18 +84,12 @@ export const Separator = memo(function Separator({
|
||||
'border-0',
|
||||
'border-solid',
|
||||
colorClasses[finalColor],
|
||||
// 2px stroke as specified
|
||||
direction === 'horizontal' ? 'border-t-2' : 'border-l-2',
|
||||
// 1px stroke for subtler appearance
|
||||
direction === 'horizontal' ? 'border-t' : 'border-l',
|
||||
];
|
||||
|
||||
// Thinned effect using CSS mask
|
||||
const thinnedClasses = thinned
|
||||
? [
|
||||
direction === 'horizontal'
|
||||
? '[mask-image:linear-gradient(to_right,transparent,black_10%,black_90%,transparent)]'
|
||||
: '[mask-image:linear-gradient(to_bottom,transparent,black_10%,black_90%,transparent)]',
|
||||
]
|
||||
: [];
|
||||
const thinnedClasses = thinned !== 'none' ? [THINNED_TYPE_CLASSES[direction][thinned]] : [];
|
||||
|
||||
// Size classes - fill container by default
|
||||
const sizeClasses =
|
||||
|
||||
@@ -17,12 +17,16 @@ describe('components/Switch', () => {
|
||||
|
||||
const checkbox = screen.getByRole('switch');
|
||||
await user.click(checkbox);
|
||||
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('respects isSelected prop', () => {
|
||||
render(<Switch isSelected onChange={() => {}}>Switch</Switch>);
|
||||
render(
|
||||
<Switch isSelected onChange={() => {}}>
|
||||
Switch
|
||||
</Switch>
|
||||
);
|
||||
const checkbox = screen.getByRole('switch');
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
@@ -30,11 +34,15 @@ describe('components/Switch', () => {
|
||||
it('respects isDisabled prop', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleChange = vi.fn();
|
||||
render(<Switch isDisabled onChange={handleChange}>Switch</Switch>);
|
||||
render(
|
||||
<Switch isDisabled onChange={handleChange}>
|
||||
Switch
|
||||
</Switch>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole('switch');
|
||||
expect(checkbox).toBeDisabled();
|
||||
|
||||
|
||||
await user.click(checkbox);
|
||||
expect(handleChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ export function Switch(props: SwitchProps): React.JSX.Element {
|
||||
const { inputProps } = useSwitch(props, state, ref);
|
||||
const { isFocusVisible, focusProps } = useFocusRing();
|
||||
|
||||
const getVariantClasses = (): string => {
|
||||
const getVariantStyles = (): React.CSSProperties => {
|
||||
const isNegative = color.endsWith('-negative');
|
||||
const baseColor = color.replace('-negative', '');
|
||||
|
||||
@@ -76,31 +76,22 @@ export function Switch(props: SwitchProps): React.JSX.Element {
|
||||
const backgroundColor = isNegative ? colorValues.text : colorValues.base;
|
||||
|
||||
if (!state.isSelected) {
|
||||
switch (variant) {
|
||||
case 'solid':
|
||||
return 'bg-[var(--rb-muted-base)]';
|
||||
case 'outline':
|
||||
return 'bg-transparent border-[var(--rb-muted-base)] border-2';
|
||||
case 'ghost':
|
||||
return 'bg-transparent border-transparent';
|
||||
default:
|
||||
return 'bg-[var(--rb-muted-base)]';
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
switch (variant) {
|
||||
case 'solid':
|
||||
return `bg-[${backgroundColor}]`;
|
||||
return { backgroundColor };
|
||||
case 'outline':
|
||||
return `bg-transparent border-[${backgroundColor}] border-2`;
|
||||
return { borderColor: backgroundColor };
|
||||
case 'ghost':
|
||||
return `bg-[${backgroundColor}]/20 border-transparent`;
|
||||
return { backgroundColor: `${backgroundColor}33`, borderColor: 'transparent' }; // 33 is 20% opacity in hex
|
||||
default:
|
||||
return `bg-[${backgroundColor}]`;
|
||||
return { backgroundColor };
|
||||
}
|
||||
};
|
||||
|
||||
const getThumbClasses = (): string => {
|
||||
const getThumbStyles = (): React.CSSProperties => {
|
||||
const isNegative = color.endsWith('-negative');
|
||||
const baseColor = color.replace('-negative', '');
|
||||
|
||||
@@ -112,20 +103,20 @@ export function Switch(props: SwitchProps): React.JSX.Element {
|
||||
neutral: { text: 'var(--rb-text)' },
|
||||
};
|
||||
|
||||
const thumbColor = isNegative ? 'var(--rb-background)' : colors[baseColor].text;
|
||||
const thumbColor = isNegative ? 'var(--rb-base)' : colors[baseColor].text;
|
||||
|
||||
if (!state.isSelected) {
|
||||
return 'bg-[var(--rb-text)]';
|
||||
return {};
|
||||
}
|
||||
|
||||
switch (variant) {
|
||||
case 'solid':
|
||||
return `bg-[${thumbColor}]`;
|
||||
return { backgroundColor: thumbColor };
|
||||
case 'outline':
|
||||
case 'ghost':
|
||||
return `bg-[${isNegative ? 'var(--rb-text)' : 'var(--rb-' + baseColor + '-base)'}]`;
|
||||
return { backgroundColor: isNegative ? 'var(--rb-text)' : `var(--rb-${baseColor}-base)` };
|
||||
default:
|
||||
return `bg-[${thumbColor}]`;
|
||||
return { backgroundColor: thumbColor };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -144,22 +135,39 @@ export function Switch(props: SwitchProps): React.JSX.Element {
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'relative w-11 h-6 transition-all duration-200 ease-in-out rounded-full outline-none flex items-center',
|
||||
getVariantClasses(),
|
||||
!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',
|
||||
isFocusVisible && 'ring-2 ring-offset-2 ring-[var(--rb-ring)]',
|
||||
props.isDisabled && 'grayscale-[0.5]'
|
||||
)}
|
||||
style={getVariantStyles()}
|
||||
>
|
||||
<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',
|
||||
getThumbClasses()
|
||||
state.isSelected
|
||||
? variant === 'outline'
|
||||
? 'translate-x-4.5'
|
||||
: 'translate-x-5'
|
||||
: 'translate-x-0'
|
||||
)}
|
||||
style={getThumbStyles()}
|
||||
/>
|
||||
</div>
|
||||
{children && (
|
||||
<Text bold={state.isSelected} color={state.isSelected ? 'default' : 'muted'} size="s" type="label">
|
||||
<Text
|
||||
bold={state.isSelected}
|
||||
color={state.isSelected ? 'default' : 'muted'}
|
||||
size="s"
|
||||
type="label"
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
Loaded 100 of 482 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user