diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 528331a56..4ea234625 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,8 +29,6 @@ jobs: - uses: ./.github/actions/setup-project - name: Validate dependency boundaries run: pnpm check:boundaries - - name: Verify release bootstrap - run: pnpm test:release - name: Lint packages run: pnpm lint @@ -44,8 +42,8 @@ jobs: - uses: ./.github/actions/setup-project - name: Run unit tests with coverage # Nested project names begin with the config package, not "storybook". - # Vitest 5 resolves coverage globs relative to each selected package. - run: pnpm test --project='!@repo/storybook-config*' --coverage.include='src/**/*.{js,jsx,ts,tsx}' + # Match source paths relative to either a package or the root CI project. + run: pnpm test:unit --coverage.include='**/src/**/*.{js,jsx,ts,tsx}' - name: Verify coverage report run: | test -s coverage/lcov.info @@ -58,10 +56,15 @@ jobs: path: coverage/ if-no-files-found: error - storybook: - name: Storybook tests + storybook-shards: + name: Storybook tests (${{ matrix.shard }}/4) runs-on: ubuntu-latest timeout-minutes: 15 + strategy: + # Each shard gets its own browser and runner, preserving focus isolation. + fail-fast: false + matrix: + shard: [1, 2, 3, 4] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -71,7 +74,46 @@ jobs: # binary and Linux system dependencies before running the browser tests. run: pnpm --filter @repo/storybook-config exec playwright install --with-deps chromium - name: Run Storybook browser tests - run: pnpm test:storybook + run: pnpm test:storybook --shard=${{ matrix.shard }}/4 + + storybook: + # Preserve the existing status check name for branch protection. + name: Storybook tests + needs: [storybook-shards] + if: ${{ always() }} + runs-on: ubuntu-latest + timeout-minutes: 1 + steps: + - name: Verify all Storybook shards passed + run: test "${{ needs.storybook-shards.result }}" = success + + visual-tests: + name: Visual regression tests + runs-on: ubuntu-24.04-arm + timeout-minutes: 20 + container: + image: mcr.microsoft.com/playwright:v1.64.0-noble@sha256:06a9939e57531807f8d5fd76ce44b53165ffb7d7501d87ab10e285c20b1e971f + options: --ipc=host + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ./.github/actions/setup-project + - name: Build Storybook + run: pnpm --filter @repo/storybook-config storybook:build + - name: Compare visual baselines + env: + FORCE_COLOR: '1' + run: pnpm test:visual + - name: Upload visual report and differences + if: ${{ !cancelled() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: visual-regression-report + path: | + packages/config/storybook-config/playwright-report/ + packages/config/storybook-config/test-results/visual/ + retention-days: 14 + if-no-files-found: error package-smoke: name: Package smoke tests diff --git a/.github/workflows/renovate-snapshot-autofix.yml b/.github/workflows/renovate-snapshot-autofix.yml index 01251e3cc..148f9a073 100644 --- a/.github/workflows/renovate-snapshot-autofix.yml +++ b/.github/workflows/renovate-snapshot-autofix.yml @@ -1,47 +1,38 @@ # This workflow lets maintainers explicitly request snapshot autofixes on Renovate PRs. -# It runs tests, updates snapshots only when failures are snapshot-related, and pushes -# the snapshot commit back to the Renovate branch while blocking non-snapshot changes. +# It tests updates and pushes external snapshot changes back to the Renovate branch. +# Untrusted build/test code and repository write credentials run on separate runners. name: renovate-snapshot-autofix on: - # Request-driven mode on Renovate PRs. - # We include synchronize/reopened so reruns happen automatically after updates - # when the label is still present. + # Request-driven mode on Renovate PRs. Keep rerunning after updates/reopens + # while the opt-in label is present. pull_request_target: - types: - - labeled - - synchronize - - reopened + types: [labeled, synchronize, reopened] # Alternative request mode: comment `/autofix-snapshots` on a Renovate PR. issue_comment: - types: - - created + types: [created] # Fallback manual trigger from Actions UI for a specific branch. workflow_dispatch: inputs: branch: - description: 'Renovate branch to update snapshots for (e.g. renovate/pkg-1.x)' + description: 'Renovate branch to update external snapshots for' required: true type: string -permissions: - # Needed to dispatch CI after the GITHUB_TOKEN-backed push. Such pushes do not - # emit another pull_request event themselves. - actions: write - # Needed to push snapshot updates back to the Renovate branch. - contents: write - # Needed to read PR metadata (label + head branch). - pull-requests: read +# Grant each job only the permissions needed for its part of the workflow. +permissions: {} jobs: - autofix-snapshots: - name: Autofix Snapshot Updates + resolve: + name: Resolve immutable Renovate head runs-on: ubuntu-latest - timeout-minutes: 20 - # Run when: - # 1) a Renovate PR has the opt-in label, - # 2) a maintainer comments `/autofix-snapshots` on a PR, or - # 3) the workflow is manually dispatched. + timeout-minutes: 5 + permissions: + contents: read + # Needed to resolve current PR metadata for label and comment requests. + pull-requests: read + # Run for an opted-in same-repository Renovate PR, an authorized comment, + # or a manual request. Resolve the head once so later jobs test the same SHA. if: >- ${{ (github.event_name == 'pull_request_target' && @@ -54,113 +45,90 @@ jobs: contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)) || github.event_name == 'workflow_dispatch' }} - + outputs: + branch: ${{ steps.head.outputs.branch }} + sha: ${{ steps.head.outputs.sha }} steps: - - name: Resolve PR head branch for issue comment trigger - id: pr-head - if: github.event_name == 'issue_comment' + # Resolve requests using the trusted workflow revision, without Git auth. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + - id: head uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | - const { owner, repo } = context.repo; - const issueNumber = context.payload.issue.number; - const { data: pr } = await github.rest.pulls.get({ - owner, - repo, - pull_number: issueNumber, - }); - - if (!pr.head.ref.startsWith('renovate/')) { - core.setFailed(`PR head '${pr.head.ref}' is not a renovate branch.`); - return; - } - if (pr.head.repo.full_name !== `${owner}/${repo}`) { - core.setFailed( - `Refusing to push to external repo '${pr.head.repo.full_name}'.`, - ); - return; - } - - core.setOutput('ref', pr.head.ref); - core.setOutput('repository', pr.head.repo.full_name); - + const { pathToFileURL } = require('node:url'); + const scriptUrl = pathToFileURL(`${process.env.GITHUB_WORKSPACE}/scripts/ci-snapshot-autofix.mjs`); + const { resolveHead } = await import(scriptUrl.href); + await resolveHead({ github, context, core }); + + snapshots: + name: Build and test without write credentials + # Branch code must not populate trusted caches, including on manual runs. + cache-mode: read + needs: resolve + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + outputs: + changed: ${{ steps.payload.outputs.changed }} + steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - token: ${{ secrets.GITHUB_TOKEN }} - fetch-depth: 0 - # For label-triggered runs, check out the Renovate PR head branch. - # For issue comments, resolve PR head via API first. - # For manual runs, use the branch provided as workflow input. - ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.ref || github.event_name == 'issue_comment' && steps.pr-head.outputs.ref || github.event.inputs.branch }} - # For Renovate PRs this can point to a fork; for manual runs use current repo. - repository: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name || github.event_name == 'issue_comment' && steps.pr-head.outputs.repository || github.repository }} - + # Build the resolved PR/manual-request commit without retaining auth. + ref: ${{ needs.resolve.outputs.sha }} + persist-credentials: false - name: Setup project uses: ./.github/actions/setup-project - - - name: Run tests - id: test-run - shell: bash - run: | - set +e - pnpm test - test_exit=$? - set -e - - if [ "$test_exit" -eq 0 ]; then - echo "status=passed" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "status=failed" >> "$GITHUB_OUTPUT" - - - name: Update snapshots - if: steps.test-run.outputs.status == 'failed' - run: pnpm test -u - - - name: Ensure only snapshot files changed - if: steps.test-run.outputs.status == 'failed' - shell: bash - run: | - changed_files="$(git status --porcelain=v1 --untracked-files=all | cut -c4-)" - if [ -z "$changed_files" ]; then - echo "Tests failed, but updating snapshots produced no changes." - exit 1 - fi - - # Safety gate: only allow known snapshot file patterns. - non_snapshot_files="$(printf '%s\n' "$changed_files" | rg -v '(^|/)__snapshots__/|\.snap$|(\.|/)(test|spec)\.(ts|tsx|js|jsx|mjs|cjs)$' || true)" - if [ -n "$non_snapshot_files" ]; then - echo "Found non-snapshot changes after update:" - printf '%s\n' "$non_snapshot_files" - exit 1 - fi - - - name: Verify tests pass with updated snapshots - if: steps.test-run.outputs.status == 'failed' - run: pnpm test - - - name: Commit and push snapshot updates - id: snapshot-commit - if: steps.test-run.outputs.status == 'failed' - shell: bash - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "test: update snapshots" - git push - echo "pushed=true" >> "$GITHUB_OUTPUT" - - - name: Run CI for the snapshot commit - if: steps.snapshot-commit.outputs.pushed == 'true' + - name: Run tests and collect external snapshot updates + id: payload + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const { pathToFileURL } = require('node:url'); + const scriptUrl = pathToFileURL(`${process.env.GITHUB_WORKSPACE}/scripts/ci-snapshot-autofix.mjs`); + const { buildSnapshots } = await import(scriptUrl.href); + buildSnapshots({ core }); + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + if: steps.payload.outputs.changed == 'true' + with: + name: snapshots-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/snapshot-output/snapshots.json + if-no-files-found: error + retention-days: 1 + + write: + name: Apply validated snapshot data + needs: [resolve, snapshots] + if: needs.snapshots.outputs.changed == 'true' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + # Needed to create the snapshot commit and update the Renovate branch. + contents: write + # GITHUB_TOKEN-backed commits do not emit another pull_request event; + # dispatch CI explicitly after the snapshot commit is accepted. + actions: write + steps: + # Never check out the Renovate head or install its dependencies here. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + name: snapshots-${{ github.run_id }}-${{ github.run_attempt }} + path: snapshot-input + - name: Validate snapshots and create commit through GitHub API uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - HEAD_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.ref || github.event_name == 'issue_comment' && steps.pr-head.outputs.ref || github.event.inputs.branch }} + HEAD_BRANCH: ${{ needs.resolve.outputs.branch }} + HEAD_SHA: ${{ needs.resolve.outputs.sha }} with: script: | - await github.rest.actions.createWorkflowDispatch({ - ...context.repo, - workflow_id: 'ci.yml', - ref: process.env.HEAD_REF, - }); + const { pathToFileURL } = require('node:url'); + const scriptUrl = pathToFileURL(`${process.env.GITHUB_WORKSPACE}/scripts/ci-snapshot-autofix.mjs`); + const { run } = await import(scriptUrl.href); + await run({ github, context }); diff --git a/.gitignore b/.gitignore index e8fea0780..be2645ccb 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,8 @@ node_modules # Extentions *.pyc *.csv* + +# Storybook builds and visual-test artifacts +storybook-static/ +playwright-report/ +test-results/ diff --git a/AGENTS.md b/AGENTS.md index 78dce48f3..8c853ae90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,15 @@ Pixels is a pnpm monorepo of independently released TypeScript packages. Keep changes focused and read the nearest `AGENTS.md` before editing a package. +## Code style + +Read `docs/code-style.md` before component changes. It records readability, +component/type ownership, documentation, test and story conventions that lint +cannot fully enforce. Follow `docs/styling.md` for styling implementation details. +When the maintainer gives additional agreed code-style feedback, update the +relevant guide rule and its concrete example in the same change, and reconcile +cross-referenced documentation. Preserve the user's current review/commit flow. + ## Architecture - Foundations: `pixel-utils`, `pixel-motion`, and the independent validation diff --git a/Makefile b/Makefile index 733289b93..84eeac52d 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,19 @@ -.PHONY: check +.PHONY: check test test-unit test-storybook test-visual test-dts # List all available make targets list: @LC_ALL=C $(MAKE) -pRrq -f $(lastword $(MAKEFILE_LIST)) : 2>/dev/null | awk -v RS= -F: '/(^|\n)# Files(\n|$$)/,/(^|\n)# Finished Make data base/ {if ($$1 !~ "^[#.]") {print $$1}}' | sort | grep -E -v -e '^[^[:alnum:]]' -e '^$@$$' +# Bootstrap or update the 'next' prerelease branch +# Creates .release-please-manifest-next.json with major version bumps +# Start a fresh cycle after the stable release with ARGS=--start-new-cycle +bootstrap-next: + node ./scripts/bootstrap-next-branch.mjs $(ARGS); + +# Run the canonical repository verification suite. +check: + pnpm check; + # Clean up repository (remove node_modules, dist, build artifacts, etc.) clean: sh ./scripts/cleanup-repository.sh; @@ -34,24 +44,29 @@ storybook: install storybook-react-scan: pnpx react-scan@latest http://localhost:6006 -# Run all tests (installs dependencies first) +# Run unit, parallel Storybook and Docker visual tests, with a combined summary. test: install - clear; - pnpm test; - -# Run the canonical repository verification suite. -check: - pnpm check; + clear + node scripts/test.mjs all $(ARGS) # Run the declaration-emit smoke test (installs dependencies first). # `install` already runs `pnpm build`, so dist/index.d.ts is fresh # by the time tsc reads it. See: packages/veto/test/dts-smoke/. test-dts: install - clear; - pnpm --filter @fuf-stack/veto test:dts; + clear + node scripts/test.mjs dts $(ARGS) -# Bootstrap or update the 'next' prerelease branch -# Creates .release-please-manifest-next.json with major version bumps -# Start a fresh cycle after the stable release with ARGS=--start-new-cycle -bootstrap-next: - node ./scripts/bootstrap-next-branch.mjs $(ARGS); +# Run only unit and CI-script tests. +test-unit: install + clear + node scripts/test.mjs unit $(ARGS) + +# Run only Storybook interaction and accessibility tests in Chromium. +test-storybook: install + clear + node scripts/test.mjs storybook $(ARGS) + +# Build Storybook and compare screenshots in the pinned Docker environment. +test-visual: install + clear + node scripts/test.mjs visual $(ARGS) diff --git a/README.md b/README.md index be21a5994..0fb557e21 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,9 @@ pnpm exec playwright install chromium ``` Project documentation lives in [`docs/`](./docs/). Start with the -[agent workflows](./docs/agent-workflows.md) for common change types and the +[code style guide](./docs/code-style.md) for structure and readability, +[component styling](./docs/styling.md) for Tailwind Variants and CSS, +[agent workflows](./docs/agent-workflows.md) for common change types, and the [release workflow](./docs/package-release-workflow.md) for publishing. ## License diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index ebbba440f..2eecd0efb 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -2,7 +2,9 @@ These workflows are deliberately command-led. Read the root and nearest package `AGENTS.md`, make the smallest coherent change, and record the commands and -manual checks in the pull request. +manual checks in the pull request. Read [the code style guide](./code-style.md) +for component structure, readability, type ownership and concrete examples; +[component styling](./styling.md) owns the Tailwind Variants and CSS details. ## Public API review @@ -12,13 +14,78 @@ and new call sites and decide whether the change is internal, additive, or breaking. Run focused tests followed by `pnpm check`. A breaking change needs a migration document and explicit release sequencing. +## Test commands + +`make test` installs and builds the workspace, then runs unit and script tests +with coverage, isolated parallel Storybook Chromium shards, and Docker visual +comparisons. All three categories run even if an earlier category fails. The +visual stage builds static Storybook first, and skips comparisons if that build +fails rather than testing stale output. The final summary lists each category's +pass/fail status and runtime, including visual build preparation, plus total +elapsed time. Any failure makes the command fail, even when the last suite passes. +Cancellation stops the sequence instead of starting another suite. + +Example final report: + +```text +=== Test summary === +PASS test:unit (30.0s) +PASS test:storybook (45.0s) +PASS test:visual (90.0s) +3/3 test suites passed. +Total runtime: 165.0s +``` + +Docker must be running for the full test command. Select a category to run only +its tests: + +| Command | Tests | +| --------------------- | ----------------------------------------------------------------------- | +| `make test` | Unit tests, parallel Storybook tests, then Docker visual comparisons | +| `make test-unit` | Unit and CI-script tests, with coverage, excluding browser stories | +| `make test-storybook` | Storybook interaction and accessibility tests in Chromium | +| `make test-visual` | Builds static Storybook, then compares screenshots in CI's Docker image | +| `make test-dts` | Veto declaration-consumer smoke tests | + +All Make test commands install dependencies and build the workspace first. Use +`ARGS` for runner options, for example: + +```sh +make test-unit ARGS="--project=@fuf-stack/uniform" +make test-visual ARGS="--grep=uniform-input--invalid" +``` + +With dependencies and packages already built, use `node scripts/test.mjs all` +for the same full test sequence, or `pnpm test:unit` and `pnpm test:storybook` +for individual categories. `pnpm test` remains the direct +single-Vitest entry point for existing callers. `pnpm test:visual:docker` +compares an existing static Storybook build; `pnpm test:visual` runs directly in +the pinned Linux container. See [visual regression](./visual-regression.md) for +baseline updates and environment requirements. + +The dedicated Make targets delegate to `scripts/test.mjs`, which selects the +runner and forwards `ARGS`. For `make test`, these options apply to the two +Vitest categories; the visual stage runs its complete suite. Use `make test-visual` +for Playwright options such as `--grep` or `--workers`. `make storybook` starts the +development server. +`make check` / `pnpm check` runs the verification stages, including unit and +Storybook tests; visual comparisons have their own CI job and run locally through +`make test` or `make test-visual`. + +Script tests, including release-bootstrap tests, belong to the `ci-scripts` +Vitest project and run with the unit suite. Add them as `scripts/*.test.mjs`, +using Vitest's test API with Node assertions and filesystem/process APIs where +useful. To run only the bootstrap tests: + +```sh +pnpm exec vitest run --project=ci-scripts scripts/bootstrap-next-branch.test.mjs +``` + ## Pre-commit checks -`pnpm lint-staged` batches staged files from all seven runtime packages into -one `vitest related --run` invocation, then lints those files. Related tests exclude `@repo/storybook-config*`, matching the unit-test -CI job; Storybook browser tests run through `pnpm test:storybook`. This keeps -browser-only CSS transforms out of related unit-test discovery and avoids -scanning the workspace separately for each affected package. +`pnpm lint-staged` batches staged runtime-package files into one related unit-test +run, followed by lint. Related tests exclude `@repo/storybook-config*`, matching +CI's unit-test job and `pnpm check`'s unit stage. Browser stories run separately through `pnpm test:storybook`. ## Package release @@ -36,39 +103,83 @@ important loading/error/empty states. Run the package's focused Vitest command, `pnpm test:storybook`, and then `pnpm check`. Attach before/after evidence for visual changes. -## Storybook edge cases +Pixels' `test:types` runs TypeScript directly over its source, tests and stories. +It is part of `pnpm check`, declaration generation alone does not verify every +internal implementation and story contract. Run `pnpm test:storybook` from the root to delegate to the shared Storybook -package's `test:storybook` script. That package owns the browser test command. -The CI unit-test job uses `pnpm test --project='!@repo/storybook-config*'` to -exclude browser projects; the separate Storybook job installs Chromium and runs -those tests. - -Keep normal usage and variant examples in `.stories.tsx`. Put edge -cases and focused regression scenarios in a sibling -`EdgeCases.stories.tsx`, following the Slider examples. Give that file -the title `/Edge Cases` so Storybook groups these -scenarios under the component rather than mixing them into its regular examples. -Use short story names without repeating "Edge Case" in the sidebar. - -Reuse the component's default story metadata (including decorators and args) -and append `@repo/storybook-config/edge-case-decorator` to its decorators. Each -edge-case story must set `parameters.edgeCaseDescription` to a short explanation -of the trigger and expected behavior. The shared decorator renders this in a -"What this checks" box above the example. Keep the outer layout unconstrained; -only the explanation box should have a fixed width, so form debug panels and -portals still have room. - -Move scenario-only fixtures and helpers with their stories. Preserve their -`play` functions, assertions, setup, and cleanup. Update the component's snapshot -test to call `storySnapshots` for both story modules, retaining any documented -environment-specific exclusions. Browser Storybook tests discover both files -automatically. Keep export names when moving existing stories to preserve -snapshot keys; the new title changes their Storybook URLs, so update any links. - -Ordinary disabled, empty, loading, and error variants can remain with the normal -examples. Separate stories that exercise unusual combinations, boundary -conditions, or a specific regression. +package's browser-test command. CI's unit job excludes `@repo/storybook-config*`, +and its separate browser job installs Chromium and runs those stories. +CI runs four Storybook shards in parallel on separate GitHub runners. Each shard +has its own Chromium process, Vite cache and test server. Files run serially +within each shard so keyboard and focus-restoration assertions do not compete +across pages. `fail-fast: false` lets every shard report failures. A final `Storybook tests` +status check succeeds only when all shards pass, preserving the existing check +name for branch protection. + +Locally, `make test-storybook`, `pnpm test:storybook` and the Storybook stage of +`pnpm check` run up to four shards in parallel, limited by available CPU cores. +Each process has its own Chromium instance, test server and dependency cache. +Files remain serial within each process. The first run populates each cache; +subsequent runs reuse them. Local shards use non-interactive reporters, with each +shard's stdout and stderr printed together when it finishes. This avoids competing +terminal progress displays and keeps complete failure details. A final Storybook +summary lists every shard's result and fails if any shard failed, regardless of +which one finishes last. An explicit single shard retains the normal live reporter. + +Focused file filters run in one process, so a shard without matching files cannot +cause a false failure. Options written with separate argument values also use +this path; use `--option=value` to retain parallelism for option-only runs. + +```sh +pnpm test:storybook Modal.interactions.stories.tsx +``` + +Set `STORYBOOK_TEST_SHARDS` to reduce resource use or reproduce serial execution: + +```sh +STORYBOOK_TEST_SHARDS=2 pnpm test:storybook +STORYBOOK_TEST_SHARDS=1 make test-storybook +``` + +`make test` uses the same parallel Storybook runner after its unit tests. +Shared `ARGS`, such as `--testNamePattern`, reach both categories; use dedicated +targets for project-specific filters or runner options. An explicit `--shard` +bypasses local fan-out and runs only that partition, as CI does. To reproduce a specific CI shard: + +```sh +make test-storybook ARGS="--shard=2/4" +# Without dependency setup and workspace builds: +pnpm test:storybook --shard=2/4 +``` + +Use the same shard count for every job, and run every index from 1 through that +count. Keep focus assertions enabled when changing browser concurrency. Do not +enable file parallelism within a shard to increase concurrency. + +## Storybook stories and edge cases + +Keep every story file in its owner's `stories/` folder. Normal usage and +composition live in `.stories.tsx`, with separate +`.interactions.stories.tsx` and `.edge-cases.stories.tsx` +when those scenarios exist. Use the corresponding titles `/`, +`//Interactions` and `//Edge Cases`. +See [the code style guide](./code-style.md#story-folders-and-sidebar-groups) for +shared setup, ordering and concrete examples. Interaction stories must perform +and assert the named interaction through `play`. + +Each edge-case story sets `parameters.edgeCaseDescription` to explain its trigger +and expected behavior. Use `@repo/storybook-config/edge-case-decorator` for the +"What this checks" box. Keep it outside the form and keep the outer layout +unconstrained; only the explanation box should have a fixed width, so form debug +panels and portals still have room. Input's shared story setup demonstrates a +conditional metadata decorator that applies the explanation only to edge cases. + +Preserve `play` functions, assertions, setup, cleanup and export names when +moving stories. Check the decorator order and existing snapshots, and update +story links when consolidation changes their URLs. The component's snapshot +test imports every group separately; browser tests discover the stories +automatically. Keep documented environment-specific exclusions explicit. ## Dependency upgrade @@ -97,38 +208,55 @@ out of date, comment `/autofix-snapshots` on the pull request or add the update mode, refuses to commit non-snapshot changes, verifies the updated suite, pushes the snapshot commit to the Renovate branch, and dispatches CI for it. -## SonarQube coverage +## Expected errors in stories -The unit-test CI command selects projects to exclude Storybook. With Vitest 5, -coverage include globs are relative to the selected package roots, so this -command also sets `--coverage.include='src/**/*.{js,jsx,ts,tsx}'`. The shared -workspace preset uses workspace-relative globs for unfiltered test runs. -Using those globs with `--project` produces an empty report even when all -tests pass. +An error-boundary story should verify its fallback without hiding unrelated +errors. For React 19 stories, use the story's `parameters.react.rootOptions` to +handle the exact deliberate error instance via `onCaughtError`, forwarding every +other error to `console.error`. Keep the handler scoped to that story, leave +uncaught errors alone, and keep the fallback assertions in `play`. -CI verifies that `coverage/lcov.info` contains source records and measured -lines before uploading it and again after downloading it for SonarQube. An -empty report must fail CI rather than silently report 0% coverage. +```tsx +parameters: { + react: { + rootOptions: { + onCaughtError: createExpectedErrorHandler(expectedErrors['error-user']), + }, + }, +}, +``` -## Renovate memory failures +The `createWithSuspense` stories demonstrate this pattern. Its tests verify that +a different error with the same message is still reported. Avoid global console +filters matching broad text such as `Error:` or `The above error occurred`. -Mend-hosted jobs have a 3 GiB memory limit. `renovate.json5` caps Node child -processes used for package-manager commands at 1024 MiB with -`toolSettings.nodeMaxMemory`, leaving room for Renovate and other processes. -This limits the Node heap; it does not increase the hosted job limit or cap -the total memory of every process. +## jsdom limitations -If a job still fails with `kernel-out-of-memory`, inspect the end of its debug -log to identify the active operation before adjusting the cap. A Node heap -error in pnpm may require a higher cap; continued total job exhaustion may -require a higher Mend resource tier or a self-hosted runner. See the -[Mend-hosted FAQ](https://docs.renovatebot.com/mend-hosted/faq/#im-hitting-a-timeout-kernel-out-of-memory-limit-with-a-pnpmyarn-project). +Uniform's jsdom setup mocks `window.scrollTo` and the global `scrollTo` with the +same Vitest mock. jsdom does not implement viewport scrolling; this records scroll +requests without suppressing console warnings. Real viewport movement belongs in +browser tests. After a validation action that requests scrolling, a unit test can +assert `expect(window.scrollTo).toHaveBeenCalled()`, while a browser story checks +the resulting position. ## Visual and accessibility verification +The [visual regression suite](./visual-regression.md) compares selected stories in +CI. Build with `pnpm --filter @repo/storybook-config storybook:build`, then run +`pnpm test:visual:docker` for the same Linux rendering locally. Review baseline +changes alongside component changes. + Use Storybook to cover meaningful states, themes, responsive behavior, and reduced motion. Run `pnpm test:storybook`, then manually verify keyboard order, focus visibility/restoration, accessible names and descriptions, disabled and error states, and contrast. Record browsers, assistive technology, and visual evidence in the pull request. Automated accessibility checks are a baseline, not a replacement for interaction review. + +### Sidebar ordering configuration + +Storybook statically extracts `parameters.options.storySort` from each actual +`.storybook/preview.tsx`. Keep the ordering object there, an imported shared +preview spread is not followed by the indexer. The extraction regression test is +`scripts/ci-storybook-order.test.mjs`. Verify ordering in the rendered sidebar +after changing it. Normal stories precede Composition, Interactions and Edge Cases. diff --git a/docs/code-style-guide.md b/docs/code-style-guide.md new file mode 100644 index 000000000..9409175a0 --- /dev/null +++ b/docs/code-style-guide.md @@ -0,0 +1,73 @@ +# Code style guide + +Write code so readers can follow its intent from top to bottom. Apply these +conventions to new and changed code alongside the root and package `AGENTS.md` +instructions and the repository's ESLint and Prettier configuration. + +## Keep each step easy to follow + +Use descriptive intermediate variables when nested calls, transformations, or +conditions make a call or return object harder to read. Keep simple expressions +inline; avoid introducing variables that only repeat an existing name. + +```ts +const validationInput = omitDisabledValues(validationValues, disabledFields); +const result = await validation.validateAsync(validationInput); + +// Schema defaults can restore disabled values, so filter the output too. +const submissionValues = omitDisabledValues(result.data ?? {}, disabledFields); + +return { + errors: result.errors ?? {}, + values: submissionValues, +}; +``` + +Move large schemas and conditional configuration out of JSX into named +variables. In tests, separate the operation being tested from its assertion +when combining them would hide the sequence. + +## Order return properties alphabetically + +List properties in returned objects alphabetically so readers can find them +consistently. Match that order in expected result objects where practical. +Compute values before the return when their expressions need explanation. + +Do not reorder spreads or expressions when doing so would change override +precedence or evaluation behavior. Refactor those cases explicitly first. + +## Separate logical stages + +Use blank lines between setup, transformations, validation, and returned +results. Keep closely related statements together. Add a short comment when +the transition or ordering has a non-obvious reason. + +Prefer a direct expression for a simple callback. Use a block when it contains +multiple steps or needs an explanatory comment, following the lint rules for +that file. + +## Preserve useful explanations + +Keep comments that explain intent, constraints, lifecycle ordering, or edge +cases. Update them when behavior changes. Avoid comments that merely restate +the next line of code. + +When relying on a dependency's internal API, explain why it is needed and what +must be checked when upgrading the dependency. Keep that explanation near the +access rather than scattering it across callers. + +Review larger docstrings whenever responsibilities or behavior change. Describe +the current implementation, including important caveats, while preserving +still-relevant context. Do not expand unrelated documentation just because the +file is being edited. + +For public props, use the repository's active-verb JSDoc convention: describe +what the prop does, identify callback triggers, and include useful defaults or +fallbacks. See the root `AGENTS.md` for the full convention. + +## Keep cleanup focused + +Prefer small local improvements over new abstractions for a single expression. +Preserve runtime behavior, public types, and helpful comments during readability +changes. Run focused tests and the checks required by the repository; do not +weaken lint rules or assertions to accommodate a refactor. diff --git a/docs/code-style.md b/docs/code-style.md new file mode 100644 index 000000000..67f3dd1cb --- /dev/null +++ b/docs/code-style.md @@ -0,0 +1,1152 @@ +# Code style guide + +First draft. This guide records the readability and component-structure decisions +that ESLint and Prettier do not fully enforce. Apply it to new code and focused +refactors; older code is not automatically a counterexample or a request for a +repository-wide rewrite. Explicit maintainer feedback takes precedence over an +older example. + +Use this guide alongside: + +- [Component styling](./styling.md) for Tailwind Variants, slots, consumer class + merging, tokens and CSS packaging. +- [Agent and maintainer workflows](./agent-workflows.md) for verification, + accessibility and change management. +- The root and nearest package `AGENTS.md` for architecture and public API rules. + +The current [Uniform Input](../packages/uniform/src/Input/Input.tsx) is a concrete +reference for the structure below. Examples here are intentionally small excerpts, +not complete component implementations. + +## Component and slot markers + +Public UI components identify their outer DOM element with `data-component`, +using the component name in lowercase kebab-case. Slots retain `data-slot`. +Subcomponents do not add component markers unless independently public. +When a component exposes a marker override, use a normal camelCase React prop, +for example `, + content: 'Keyboard-accessible details', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole('button', { name: 'More information' }); + + await userEvent.tab(); + await expect(trigger).toHaveFocus(); + + const tooltip = await canvas.findByRole('tooltip'); + await expect(trigger).toHaveAttribute('aria-describedby', tooltip.id); + }, +}; +``` + +Every public component with a rendered surface needs a representative visual +baseline. Opt stable stories into further coverage when they protect a distinct +layout or visible state. Prefer a representative example and compact variant galleries, +then add meaningful focus, validation, disabled, overlay and responsive states. +Do not tag every story or duplicate behavior-only coverage. For example, tag an +invalid TextArea after `play` shows its validation message, rather than every +value-transform story. Keep capture settings on the existing story and baseline +PNGs with the component. See [visual story selection](./visual-regression.md#which-stories-get-visual-coverage) +for the selection table and concrete examples. Keep the +[coverage inventory](./visual-coverage.md) updated alongside tags, and document +exceptions for hooks, providers and APIs without their own rendered surface. + +Group hook stories under `/hooks/`, including private hooks. +The sidebar describes the code's role, not its export visibility: + +```tsx +title: 'uniform/hooks/useUniformField', +``` + +Preserve `play` assertions, scenario descriptions and decorator order when moving +stories. See [the story workflow](./agent-workflows.md#storybook-stories-and-edge-cases) +for the shared explanation decorator and verification. + +## Package READMEs + +Keep the package-root README to a short introduction describing the package and +what it provides. Do not turn it into an index of component guides or repository +style documentation. Those files are not necessarily shipped with the package. + +```md +# Megapixels + +Advanced React components for data-heavy interfaces, built on Pixels and Uniform. +Includes data tables, configurable filters, virtualized lists and notifications. +``` + +## Component READMEs + +Keep a small `README.md` beside each public component, starting with Uniform. +Private subcomponents are covered by their owner's README unless they need +independent documentation. Sort component indexes alphabetically by component name, +as in [Uniform’s component index](../packages/uniform/README.md#components). + +Keep package READMEs as concise entry points: purpose, a typical example, a +component index and links to further docs. Put package architecture and migration +notes in `docs//`, for example +[Uniform field composition](./uniform/field-composition.md) and +[Uniform migration notes](./uniform/migration.md). Link to shared code-style and +styling guides instead of copying their rules into the package README. + +Include a one-line purpose, one typical example using the public import, and a +few notes only where a consumer needs them. Link to props and stories instead of +copying the prop interface. Put relevant upstream documentation links here, +rather than in the main component's docstring. Include required providers or parent components in +the example. Keep styling details in [styling.md](./styling.md). + +For example, [Input's README](../packages/uniform/src/Input/README.md) follows: + +````md +# Input + +Text, password and number input connected to a Uniform form. + +## Usage + +```tsx +import Form from '@fuf-stack/uniform/Form'; +import Input from '@fuf-stack/uniform/Input'; + +
+ +
; +``` + +## Notes + +- Edits update the display immediately, form updates are debounced by default + +[Props](./Input.tsx) · [Stories](./stories/Input.stories.tsx) +```` + +Add sections as useful information appears, such as a tricky composition or value +format. Do not add empty placeholders, exhaustive prop tables or implementation +inventories to satisfy a template. Update the README when its usage or behavior +changes, and keep examples consistent with the component. + +## Maintain this guide with feedback + +Update the relevant rule **and its concrete example** when the maintainer gives +further agreed feedback. Keep entries short enough to skim; explain the useful +choice without listing every possible exception. + +For example, feedback that a registration comment is redundant should remove +that comment from the example and retain the blank line. A debounce comment can +stay because it explains when updates happen. + +Reconcile corresponding examples in [styling.md](./styling.md) and +[agent-workflows.md](./agent-workflows.md). Older code is a reference, not proof +that every existing pattern is preferred. Follow the user's current review and +commit instructions. diff --git a/docs/heroui-removal.md b/docs/heroui-removal.md new file mode 100644 index 000000000..bb3d945e3 --- /dev/null +++ b/docs/heroui-removal.md @@ -0,0 +1,690 @@ +# HeroUI removal: implementation inventory + +Status: complete in this repository. Uniform, Pixels and workspace consumers use +owned rendering, tokens and configured styling. HeroUI dependencies, the public +plugin re-export and workspace plugin/source scanning are removed. This remains +an intentionally breaking release for consumers that used the old plugin or hooks. +Uniform’s final cleanup and verification are recorded in the +[completion review](./uniform/migration-review.md). The dated sections below are +historical implementation notes; use that review and the current Uniform guides +for its final structure and remaining release constraints. +The agreed breaking changes include consumer theme setup and removal of +`uniform/hooks/useInput`, `uniform/hooks/useUniformField` and +`uniform/hooks/useUniformFieldArray`, including their public compatibility types +and getters. The user confirmed that consumers do not use the latter two hooks +and authorized removal of the known Markdown editor hook dependency. Production +component props, recipes, slots and storage contracts remain compatible. + +## Pixels Button and ButtonGroup + +Button and ButtonGroup now use React Aria interaction handling and owned recipes. +Their public component imports and existing group precedence are preserved. +See [Pixels migration notes](./pixels/migration.md) for composition and remaining +HeroUI consumers. The original inventory below records the pre-migration source. + +## Pixels Popover + +Popover now uses React Aria dialog components and overlay hooks with owned slot +styles. Pixels no longer declares `@heroui/popover` directly. Its scrollable body also uses the owned ScrollShadow implementation. Menu also uses owned trigger and overlay rendering. See the +[Popover migration notes](./pixels/migration.md#popover). + +## Pixels ScrollShadow + +ScrollShadow now owns its native scroll container, edge measurement and mask +recipe. Its direct HeroUI dependency is removed, including the remaining HeroUI +rendering inside Popover. See [Pixels migration notes](./pixels/migration.md#scrollshadow). + +## Pixels Progress + +Linear Progress now uses React Aria semantics, owned slot styles and activity +animation. ProgressCircular now owns its rendering too, removing the direct +`@heroui/progress` dependency. +See [Pixels migration notes](./pixels/migration.md#progress). + +## Pixels ProgressCircular + +ProgressCircular owns its SVG ring, value and status icon styling, with React Aria +range semantics. Both progress components now have no HeroUI imports or types. +See [Pixels migration notes](./pixels/migration.md#progresscircular). + +## Pixels Tooltip + +Tooltip now uses React Aria trigger and overlay behavior with owned surface styles. +The span wrapper, placement names, delays and section slots remain supported. +The direct `@heroui/tooltip` dependency is removed. +See [Pixels migration notes](./pixels/migration.md#tooltip). + +## Pixels Menu + +Menu now uses React Aria action collections and the popover hook, with owned +trigger and option styling. Grouped actions, disabled options, press callbacks, +typeahead and Element portals remain supported. Direct `@heroui/button` and +`@heroui/dropdown` dependencies are removed. +See [Pixels migration notes](./pixels/migration.md#menu). + +## Pixels Modal + +Modal now uses owned surface recipes with React Aria dialog and modal overlay +hooks. Controlled and imperative APIs, Element portals and scroll locking remain +supported, and body descriptions reference unique rendered IDs. The direct +`@heroui/modal` dependency is removed. +See [Pixels migration notes](./pixels/migration.md#modal). + +## Pixels Drawer + +Drawer now shares Modal's owned dialog engine with its own placement and size +recipe. Custom close elements, Element portals and existing styling slots remain +supported. The direct `@heroui/drawer` dependency is removed. +See [Pixels migration notes](./pixels/migration.md#drawer). + +## Pixels Avatar + +Avatar now owns image loading and fallback rendering with owned slot recipes. +AvatarGroup now owns overlap, wrappers and its accessible overflow indicator. +The direct `@heroui/avatar` dependency is removed. +See [Pixels migration notes](./pixels/migration.md#avatar). + +## Pixels Badge + +Badge now owns its positioning, content sizing and slot styles. Its direct +`@heroui/badge` dependency is removed. +See [Pixels migration notes](./pixels/migration.md#badge). + +## Pixels Label + +Label now owns its recipes and uses a native React Aria close button. The active +filter consumer keeps edit and close actions separate. The direct `@heroui/chip` +dependency and Label's HeroUI theme import are removed. +See [Pixels migration notes](./pixels/migration.md#label). + +## Pixels Accordion + +Accordion now owns expansion, heading associations and animated panels with +React Aria trigger interactions. The direct `@heroui/accordion` dependency is +removed. See [Pixels migration notes](./pixels/migration.md#accordion). + +## Pixels Alert + +Alert now owns its message layout, icons, palette and native close action. +The direct `@heroui/alert` dependency and Alert's HeroUI theme import are removed. +See [Pixels migration notes](./pixels/migration.md#alert). + +## Pixels Breadcrumb + +Breadcrumb uses owned recipes and React Aria hooks for its landmark and item +associations. The direct `@heroui/breadcrumbs` dependency is removed. See the +[Pixels migration notes](./pixels/migration.md#breadcrumb). + +## Pixels Card + +Card renders owned surfaces and section dividers. Its direct `@heroui/card` and +unused `@heroui/divider` dependencies are removed. See the +[Pixels migration notes](./pixels/migration.md#card). + +## Pixels SearchInput + +SearchInput owns its editable surface, clear action and recipes. Its direct +`@heroui/input` dependency is removed. See the +[Pixels migration notes](./pixels/migration.md#searchinput). + +## Pixels Tabs + +Tabs owns its recipes and composes React Aria tab controls, panels and selection +indicators. Its direct `@heroui/tabs` dependency is removed. See the +[Pixels migration notes](./pixels/migration.md#tabs). + +## Pixels Table + +Table owns native grid rendering and virtual row windows, with React Aria hooks +for keyboard navigation and associations. Its direct `@heroui/table` dependency +is removed. Pixels components now have no HeroUI runtime/type imports. Shared +theme, plugin/source configuration and unused dependency cleanup are complete. +See the [Pixels migration notes](./pixels/migration.md#table). + +## Implemented foundation + +- `TVProps` uses direct Tailwind Variants types. +- Existing standalone `cn` retains its old classification during coexistence. + The new instance helpers share owned semantic merge rules; migrate component + call sites and compare visuals before unifying the legacy export. +- `createStyling` owns an isolated Tailwind Merge instance shared by application + `cn`, recipes, and slot resolution. Recipe defaults, selected variants, + compounds, extension, and caller slot classes reach final merging together. +- `StylingProvider` lives in Pixels; `useStyling` follows React portals and + isolates application roots and SSR renders. Default helpers remain available. +- Tests cover `text-body` as a font size while preserving `text-danger`, arrays, + slot records, responsive/state/important/arbitrary utilities, custom groups, + directional conflicts, compounds, extension, inferred variants, portals, SSR, + and prose utilities. The provider has a browser Storybook regression. + +The implementation uses the installed Tailwind Variants 3.2.2 lite entry point. +Its normal entry point stores the effective merge function in shared module +state; `createTV` alone therefore does not provide isolated merge instances. +Recipes retain raw metadata for extension. `resolve(recipe, props)` evaluates +pixel-utils recipes using the consuming instance before slot classes merge. +It cannot recover classes already discarded by an external HeroUI recipe. + +## Initial source inventory + +This table records the migration baseline, including the original HeroUI imports +and story counts. Completed migrations are tracked above. It records explicitly +declared slots, not inherited HeroUI slots. The +linked source is authoritative for public props, defaults, variants and callbacks; +all component export paths in each package manifest must stay stable. A zero +story count means no same-directory story, not necessarily no consumer coverage. +This inventory does **not** certify DOM or visual compatibility. Those checks +must accompany each renderer migration, with before/after browser evidence. + +| Component | Props/source | Recipe | Explicit slots | Direct HeroUI modules | Story files | +| ----------------------- | ---------------------------------------------------------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ----------- | +| pixels/Accordion | [AccordionItemProps, AccordionProps](../packages/pixels/src/Accordion/Accordion.tsx) | accordionVariants | base, content, heading, indicator, startContent, subtitle, title, titleWrapper, trigger, wrapper | divider, accordion | 1 | +| pixels/Alert | [AlertProps](../packages/pixels/src/Alert/Alert.tsx) | alertVariants | base, closeButton, description, endContent, icon, iconWrapper, mainWrapper, title | alert, theme | 1 | +| pixels/Avatar | [AvatarProps](../packages/pixels/src/Avatar/Avatar.tsx) | avatarVariants | base, fallback, icon, img, name | avatar | 1 | +| pixels/AvatarGroup | [AvatarGroupProps](../packages/pixels/src/AvatarGroup/AvatarGroup.tsx) | — | — | avatar | 1 | +| pixels/Badge | [BadgeProps](../packages/pixels/src/Badge/Badge.tsx) | badgeVariants | badge, base | badge | 1 | +| pixels/Breadcrumb | [BreadcrumbItemProps, BreadcrumbProps](../packages/pixels/src/Breadcrumb/Breadcrumb.tsx) | breadcrumbVariants | ellipsis, item, itemBase, list, separator, nav | breadcrumbs | 1 | +| pixels/Button | [ButtonProps](../packages/pixels/src/Button/Button.tsx) | buttonVariants | — | button, theme | 1 | +| pixels/ButtonGroup | [inferred/re-exported](../packages/pixels/src/ButtonGroup/ButtonGroup.tsx) | — | — | button | 1 | +| pixels/Card | [CardProps](../packages/pixels/src/Card/Card.tsx) | cardVariants | base, body, divider, footer, header | card, divider | 1 | +| pixels/Drawer | [DrawerProps](../packages/pixels/src/Drawer/Drawer.tsx) | drawerVariants | wrapper, base, backdrop, header, body, footer, closeButton | — | 1 | +| pixels/Label | [LabelProps](../packages/pixels/src/Label/Label.tsx) | labelVariants | base, closeButton, content, dot, icon | chip, theme | 1 | +| pixels/Menu | [MenuProps](../packages/pixels/src/Menu/Menu.tsx) | menuVariants | item, trigger | button, dropdown | 1 | +| pixels/Modal | [ModalProps](../packages/pixels/src/Modal/Modal.tsx) | modalVariants | backdrop, base, body, closeButton, footer, header, wrapper | — | 1 | +| pixels/Popover | [PopoverProps](../packages/pixels/src/Popover/Popover.tsx) | popoverVariants | body, content, divider, footer, footerWrapper, header, headerWrapper, trigger | button, popover | 1 | +| pixels/Progress | [ProgressProps](../packages/pixels/src/Progress/Progress.tsx) | progressVariants | base, indicator, label, labelWrapper, track, value | progress | 1 | +| pixels/ProgressCircular | [ProgressCircularProps](../packages/pixels/src/ProgressCircular/ProgressCircular.tsx) | progressCircularVariants | base, indicator, label, svg, svgWrapper, track, value | progress, theme | 1 | +| pixels/ScrollShadow | [ScrollShadowProps](../packages/pixels/src/ScrollShadow/ScrollShadow.tsx) | — | — | scroll-shadow | 1 | +| pixels/SearchInput | [SearchInputProps](../packages/pixels/src/SearchInput/SearchInput.tsx) | inputVariants | base, clearButton, input, inputWrapper | input | 1 | +| pixels/Table | [TableColumnProps, TableRowProps, TableProps](../packages/pixels/src/Table/Table.tsx) | tableVariants | base, wrapper, table, thead, tbody, tr, th, td, tfoot, sortIcon, emptyWrapper, loadingWrapper | table | 1 | +| pixels/Tabs | [TabProps, TabsProps](../packages/pixels/src/Tabs/Tabs.tsx) | tabsVariants | base, cursor, panel, tab, tabContent, tabList, tabWrapper | tabs | 1 | +| pixels/Tooltip | [TooltipProps](../packages/pixels/src/Tooltip/Tooltip.tsx) | tooltipVariants | base, body, content, divider, footer, footerWrapper, header, headerWrapper, trigger | tooltip | 1 | +| uniform/Checkboxes | [CheckboxesProps](../packages/uniform/src/Checkboxes/Checkboxes.tsx) | checkboxesVariants | base, errorMessage, label, optionBase, optionIcon, optionLabel, optionLabelSubline, optionWrapper, wrapper | — | 2 | +| uniform/DatePicker | [DatePickerProps](../packages/uniform/src/DatePicker/DatePicker.tsx) | datePickerVariants | base, helperWrapper, calendar, calendarContent, inputWrapper, label, popoverContent, selectorButton, selectorIcon, timeInput, timeInputLabel | — | 2 | +| uniform/FieldArray | [inferred/re-exported](../packages/uniform/src/FieldArray/FieldArray.tsx) | fieldArrayVariants | base, actionsMenuButton, appendButton, elementFieldsGrid, label, list, listWrapper, listItem, listItemInner, removeButton, sortDragHandle | — | 2 | +| uniform/FieldCard | [FieldCardProps](../packages/uniform/src/FieldCard/FieldCard.tsx) | fieldCardVariants | base, label, content, errorFooter, errorText | — | 3 | +| uniform/Form | [FormProps](../packages/uniform/src/Form/Form.tsx) | formVariants | base, debugViewer, debugViewerCopyButtonToggle, debugViewerHeader, debugViewerJson, debugViewerTriggerButton, form | — | 1 | +| uniform/Input | [InputProps](../packages/uniform/src/Input/Input.tsx) | inputVariants | base, clearButton, description, errorMessage, helperWrapper, innerWrapper, input, inputWrapper, label, stepper, stepperButton | — | 3 | +| uniform/RadioBoxes | [RadioBoxesProps](../packages/uniform/src/RadioBoxes/RadioBoxes.tsx) | radioBoxesVariants | base, boxBase, boxControl, boxDescription, boxLabel, boxLabelWrapper, label, wrapper | — | 2 | +| uniform/RadioTabs | [RadioTabsProps](../packages/uniform/src/RadioTabs/RadioTabs.tsx) | radioTabsVariants | base, cursor, label, tab, tabBase, tabContent, tabList, tabPanel, tabWrapper, wrapper | — | 3 | +| uniform/Radios | [RadiosProps](../packages/uniform/src/Radios/Radios.tsx) | radiosVariants | base, errorMessage, itemBase, itemControl, itemDescription, itemLabel, itemLabelWrapper, itemWrapper, label, wrapper | — | 2 | +| uniform/Select | [SelectProps](../packages/uniform/src/Select/Select.tsx) | selectVariants | base, clearIndicator, control, control_focused, crossIcon, downChevron, dropdownIndicator, group, groupHeading, indicatorsContainer, indicatorSeparator, input, label, loadingIndicator, loadingMessage, menu, menuList, menuPortal, multiValue, multiValueContainer, multiValueLabel, multiValueRemove, noOptionsMessage, option, option_disabled, option_focused, option_selected, placeholder, selectContainer, singleValue, valueContainer | — | 3 | +| uniform/Slider | [SliderProps](../packages/uniform/src/Slider/Slider.tsx) | sliderVariants | base, endContent, errorMessage, filler, label, labelWrapper, mark, startContent, step, thumb, track, trackWrapper, value | — | 3 | +| uniform/SubmitButton | [SubmitButtonProps](../packages/uniform/src/SubmitButton/SubmitButton.tsx) | — | — | — | 1 | +| uniform/Switch | [SwitchProps](../packages/uniform/src/Switch/Switch.tsx) | switchVariants | base, endContent, errorMessage, label, outerWrapper, startContent, thumb, thumbIcon, wrapper | — | 2 | +| uniform/SwitchBox | [SwitchBoxProps](../packages/uniform/src/SwitchBox/SwitchBox.tsx) | switchBoxVariants | box, description, icon, label, wrapper, thumb, thumbIcon | — | 2 | +| uniform/TextArea | [TextAreaProps](../packages/uniform/src/TextArea/TextArea.tsx) | textAreaVariants | base, clearButton, input, inputWrapper | — | 2 | +| uniform/Time | [TimeProps](../packages/uniform/src/Time/Time.tsx) | timeVariants | base, helperWrapper, input, innerWrapper, inputWrapper, label, segment | — | 2 | + +Additional coupling: + +- The old pixel-utils `heroui` export, declaration bundling rules, Tailwind + plugin and HeroUI source globs are removed. `tokens.css` supplies the semantic + theme in both workspace previews and packaged consumers. +- Pixels Toast uses Sonner already; retain its behavior and test interoperability + with replacement overlays. Megapixels selection checkboxes now use React Aria, + and the Base UI dependency has been removed. +- Uniform has no direct HeroUI dependencies or published HeroUI runtime/type + references. Radio options, switch thumbs and date/time values use owned + contracts. Legacy hook exports have been removed as an explicit breaking change. +- Atelier and Megapixels consume Pixels primitives and semantic utilities, even + where they have no direct HeroUI imports. Audit published declarations and + packed consumers as well as source imports. + +## Field composition audit and migration gates + +The private `useUniformField` core owns RHF registration, transformed +values, required-schema state, stable control IDs, test IDs, the debug label +button, error flattening and invalid visibility. Existing debounce, reduced-motion +and serialization behavior is retained. All production fields consume this core. +Public legacy adapters and their presentation getters have now been removed. +The state implementations and their behavioral coverage remain private. + +`useUniformField` normalizes errors internally and returns a rendered +`errorMessage` using `FieldValidationError`. The raw/group `error` behavior remains +unchanged. `FieldValidationError` owns the message list, Pixel Motion entry/exit +animations and reduced-motion support. Field presentation owns visibility and the +styled wrapper associated with the control. Direct uses can supply `id` to +`FieldValidationError` for its optional association wrapper. Establish +stable control, label, description and error IDs once in the field hook, then +pass them to presentation, the editable element and React Aria contexts. Keep +native validation from competing with RHF/Veto. + +| Slice | Existing contract to preserve | Verification gate | +| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Input/TextArea | Actual input ref, debounce flush on blur, controlled empty values, clear-to-null/reset, addons, sizes, existing four slots | RHF defaults/reset/transforms; every slot target; text/password/number keyboard and accessible names | +| Checkbox/radio/switch families | Group and option slots, array/scalar transforms, required markers, disabled options, custom boxes/tabs and switch thumb renderer | Fieldset/legend where appropriate; arrows/Space; focus refs; touched/error timing | +| DatePicker/Time | UTC strings, date/time objects, numeric hours, zones, min/max limits, hour cycle, segments/calendar/time slots | Segment/popup focus must not trigger premature blur; serialization and clearing remain identical | +| Slider | Numeric/array values, limits, marks, steps, thumb/value/track slots and formatting | Keyboard/pointer updates, ref/focus, touched/error and reset behavior | +| Select | React Select engine, numeric option conversion, fallback options, search, multiselect, loading, clearing, callback types, all existing slots | Replace `useSelect` helper and parsed label IDs; names/error association on real input; preserve deferred touched notification | +| FieldArray/FieldCard/SubmitButton/debug | Shared primitives and public error/label partials | Recheck registration, array defaults/sorting and errors after the underlying primitives migrate | +| Pixels overlays | Modal/drawer/popover/menu focus, portals, Escape, scroll locking, stacking and toast behavior | Select/calendar inside overlays; restoration and keyboard/automated accessibility | + +Committed story DOM snapshots and existing behavioral tests are the current +source baseline. The [verification summary](./uniform/migration-verification.md) +retains the visual-review scope and unresolved accessibility findings after +removal of one-time screenshots and JSON reports. All Uniform production fields, +Pixels primitives and workspace consumers are migrated, with per-slice history +below. + +The production React Aria text/number Input consumes a shared field-state core +without calling legacy presentation hooks. Private shared state supports all +production fields; the former public adapters are removed. Production input tests cover +registration, resets, blur/submission flushing, pending-edit cancellation on +clear, focus restoration, decimal/zero/empty numbers, stepper updates, labels, +required markers, errors and consumer font-size merging. The form's existing +empty-field serialization remains unchanged. Local label and hint components +own presentation only and are not public entry points. + +## Completed sequence and release prerequisites + +1. Uniform's production-field, React Select and internal consumer scope is + complete. Preserve its committed behavior, visual evidence and packed API + fixtures while migrating the lower-level consumers. +2. Pixels component rendering is migrated to owned tokens and configured styling. + Preserve overlay interoperability, public slots, refs, keyboard behavior and + visual fixtures during the remaining integration cleanup. +3. Atelier and Megapixels retain their existing implementations and are checked + against the migrated Pixels dependencies. +4. Unused HeroUI dependencies, plugin/scanning, theme exports and inherited + recipes are removed, including published runtime and declaration references. +5. Uniform's legacy hook exports are removed with migration guidance. The user + confirmed no external use of the two Uniform field hooks. The known Markdown + editor must replace its old `useInput` import when adopting the breaking + release; its source remains outside this repository. +6. Run the full check, packed declarations/export/dependency audit, visual and + keyboard/accessibility review. Release foundations before Pixels, then Uniform + and consumers; Release Please owns versions and changelogs. + +See [styling](./styling.md), [agent workflows](./agent-workflows.md), and +[release workflow](./package-release-workflow.md). + +## Foundation verification (2026-10-14) + +`pnpm install --frozen-lockfile` and `pnpm exec playwright install chromium` +completed. Final `pnpm check` passed all six gates: dependency boundaries, lint, +builds, 1,470 unit tests (12 skipped), 430 browser Storybook tests, and packed +consumer verification for all seven published packages. The packed consumer +fixture checks variant/slot inference, extension and the provider’s public type. +Existing snapshots were not updated. No manual keyboard/assistive-technology +review or before/after screenshot capture was performed; those remain gates for +the upcoming renderer/theme migration. + +## Token foundation verification + +`pnpm check` passed all six gates after adding the exported, side-effectful +stylesheet and plugin-free packed CSS consumer. The consumer verifies semantic +colors, font sizes, radii, border widths (including directional utilities), +opacity, responsive/dark utilities and source discovery from compiled package +files. Storybook continues to use the transitional HeroUI setup until renderer +slices adopt the owned stylesheet. + +## Private input prototype verification + +`pnpm check` passed all six gates with the shared state core and private React +Aria input prototype: 1,483 unit tests (12 skipped), 441 browser stories, and +clean packed consumers for seven published packages. Existing production DOM +snapshots were retained. Ten visual comparisons match the captured input +metrics. Keyboard, clear, numeric stepper, error focus and provider merging are +covered by prototype stories/tests. Automated accessibility findings and their +baseline comparison are recorded with the field evidence; invalid-state +contrast remains unresolved. Other field families and production promotion +remain pending. The external Markdown editor is deferred at the user's request. + +## Production Input rollout verification + +`Input` now renders owned React Aria text/password/number controls. All four +public styling slots and existing props remain available; consumer merge +configuration applies before classes are discarded. The old private prototype +was removed and its focused stories/tests now exercise production composition. +The unused `@heroui/number-input` dependency was removed. Twenty-four visual +comparisons match the legacy measurements, including sizes, addons, custom +styles and focused/hovered inputs. Sixty-three affected consumer DOM snapshots +were reviewed and updated; behavior assertions were retained. + +Final `pnpm check` passed all six gates with 1,486 unit tests (12 skipped), +434 browser stories and seven packed consumers. The browser story count is +lower because seven prototype duplicates were removed. The packed fixture also +checks the production input prop/slot/transform declarations. Accessibility +findings remain identical to the captured invalid-state baseline. TextArea, +other field families, Pixels primitives and final HeroUI/theme removal remain +pending. + +## Production TextArea rollout verification + +`TextArea` now uses owned markup, React Aria field behavior and the same +three-to-eight-row autosizing engine declared as a direct dependency. Existing +props, four slot names, controlled values and debounce/reset behavior remain +available. Optional help text and visible errors are associated with the actual +textarea. Eight light/dark comparisons match the previous editable and wrapper +measurements. The shared text-field layout also restores original error spacing; +Input and TextArea browser stories now assert the control-to-error boundary. + +Final `pnpm check` passed all six gates: 1,491 unit tests (12 skipped), 438 +browser stories and seven packed consumers. Browser tests now compile Tailwind +utilities through the same plugin as the development preview, so live autosizing +and typography assertions exercise real utility styles. Packed declarations +check TextArea props and slots. The textarea audit records invalid-state text +contrast findings in both themes; its other audited states have no findings. +Pixels primitives, grouped/date/time/slider fields, Select integration and final +HeroUI/theme removal remain pending. + +## Uniform-only phase and Select integration (2026-10-15) + +The current phase covers remaining Uniform fields, React Select integration and +Uniform consumers. Pixels primitives and application package migrations remain +outside this phase; they continue to supply HeroUI-backed presentation until a +later rollout. The external Markdown editor remains deferred. + +Select keeps React Select while removing HeroUI select/input-presentation hooks +from its production path. Actual input associations preserve the engine’s own +placeholder/live-region descriptions. Declared icon/container slots now target +their intended elements, configured font-size merging is checked in a browser, +and numeric values, fallback options, transforms and deferred touched timing +are retained. The unused `@heroui/select` dependency is removed. + +All 16 light/dark control/wrapper measurement comparisons match the baseline. +Final `pnpm check` passed all six gates: 1,495 unit tests (12 skipped), 440 browser +stories and seven packed consumers. Twenty-four consumer snapshots were updated +for the intentional association/slot wiring changes; behavior assertions were +retained. Select accessibility findings are recorded with its field evidence. + +## Production Switch verification (2026-10-15) + +Switch now uses React Aria hooks with owned presentation and one registered +input. Keyboard/pointer interaction, boolean false/true submission, defaults, +reset, disabled behavior, actual invalid-submit focus and thumb callback types +are verified. Public styling slots remain available; the declared thumb props +are local structural types. Sixteen light/dark comparisons match the baseline +track/thumb geometry, colors, typography and positions. Eleven consumer DOM +snapshots were updated without changing their behavior assertions. + +Final `pnpm check` passed all six gates with 1,498 unit tests (12 skipped), 441 +browser stories and seven packed consumers. The audit records light-mode +invalid-state contrast; all other audited states have no findings. The remaining +Uniform field families and group consumers still need their production slices. + +## Owned group consumers (2026-10-15) + +FieldArray and FieldCard now render their own headings and error footers using +configured styling. Their groups reference visible heading IDs; FieldCard also +associates its object-level footer. Required markers are hidden from accessible +names. Child registration, object/array error visibility, touched/submitted +styling, reset normalization and array operations retain their existing behavior. +The public array hook retains legacy getters while the production component uses +a private state path; both share the same array methods and effects. + +Fourteen light/dark heading measurements match the captured baseline. Browser +stories check configured heading font merging; regression tests check group +names and child submission without invoking the legacy presentation hook. +The group audits record error-text contrast findings in both themes. Pixels +buttons, menus and drag controls used by arrays remain in the later Pixels phase. + +Final `pnpm check` passed all six gates: 1,500 unit tests (12 skipped), 442 browser +stories and seven packed consumers. Thirty-one group/consumer snapshots were +reviewed and updated for the intentional DOM association changes. + +## Production SwitchBox verification (2026-10-15) + +SwitchBox uses React Aria interactions and one registered native input. Its +visible label and description now associate explicitly with that input; the +same input handles invalid-submit focus, Space toggling, blur and boolean +submission. Required markers are hidden from accessible names. Existing card, +track, thumb, icon and description slots remain configurable, including size +and thumb callback types. Its unused direct `@heroui/switch` dependency is removed. + +Sixteen light/dark comparisons match the baseline geometry, colors and +typography, including icon positioning and all three sizes. Unit checks cover +false submission, reset, disabled behavior, required/help/error focus and thumb +callbacks; the browser story checks keyboard toggling. Existing consumer/reset +stories retain their behavior assertions while updating the intentional markup. + +Final `pnpm check` passed all six gates with 1,503 unit tests (12 skipped), 443 +browser stories and seven packed consumers. Eleven consumer DOM snapshots were +reviewed and updated. The audit records light invalid-state contrast; the other +audited SwitchBox states have no findings. + +## Owned radio families (2026-10-15) + +Radios and RadioBoxes now use owned React Aria radio groups and option controls. +Their existing option descriptors, custom list rendering, numeric conversion, +registered values, reset behavior, disabled options and styling slots remain +available. Label/help/error IDs associate with the group and editable inputs; +invalid submission focuses an enabled input. RadioBoxes also associates option +descriptions. The compatibility-only RadioBox prop type still references the +existing HeroUI type; its production renderer no longer invokes HeroUI hooks. + +RadioTabs now owns its tab engine and presentation within Uniform. Investigation +of the existing #1873 runner hang found an infinite loop in the older Tabs +keyboard delegate when every tab is disabled. The React Aria foundation already +used by Uniform has the terminating traversal. Moving RadioTabs onto it fixes +that case without changing Pixels. All eleven previously excluded RadioTabs +stories now run in both snapshot and browser suites. Numeric/boolean values, +conditional panels, reset, keyboard selection, disabled state and actual error +focus are checked; disabled RHF fields retain their existing submission omission. +Its public slots and four variants remain available, with measured cursor +placement and reduced-motion styling. The tab selection field uses a named group +and tab list rather than an empty radiogroup. Required selection is described +accessibly, and help/errors associate with the editable tabs. + +All 18 captured light/dark group/option geometry comparisons match the baseline. +Browser checks additionally cover configured option typography and all four tab +variants. The 22-state accessibility audit records invalid-state contrast; +other audited states have no findings. Pixels and application migrations remain +outside this phase. + +Final `pnpm check` passed all six gates: 1,523 unit tests (one skipped), 460 browser +stories and seven packed consumers. The DataTable filter consumer snapshot was +updated for the owned radio markup; no application implementation was migrated. + +## Owned Checkboxes (2026-10-15) + +Checkboxes now uses React Aria with owned group, option, icon and error markup. +Array values, optional clearing, scalar/object transforms, custom option +rendering/metadata, per-option and group disabled behavior, colors and +line-through styling are retained. Group and option descriptions associate with +the actual inputs; invalid submission focuses the first enabled checkbox. +Requiredness belongs to the selection group, is described accessibly and does +not impose native required validation on every option. RHF remains authoritative. +Existing slots now merge through the configured styling instance, including the +error-message slot. The direct `@heroui/checkbox` dependency is removed; the +Pixels table still retains that package transitively. + +All 24 light/dark group/option geometry and typography comparisons match the +baseline, including sublines, line-through, custom layouts and transform stories. +The 28-state audit records invalid/sublabel/line-through contrast findings; +other audited states have no findings. Existing behavior assertions remain, +with added checks for native names, array/scalar submission, reset, optional +clearing, disabled omission, keyboard operation and actual error focus. + +Final `pnpm check` passed all six gates: 1,528 unit tests (one skipped), 462 browser +stories and seven packed consumers. The DataTable filter modal and checkbox +consumer assertions pass unchanged, including ReactNode option names. + +## Owned Slider (2026-10-15) + +Slider now renders an owned React Aria control with one registered range input. +The input keeps the field's actual name and handles keyboard/pointer changes, +reset, disabled state and error focus. Programmatic focus through the field's +test-ID wrapper remains supported without adding a keyboard stop. Numeric +transforms, fractional steps, fill offsets, step dots, addons and public slots +remain available. Completing an explicit interaction at zero commits that value; +merely tabbing through an empty required slider still leaves it empty. + +All 24 light/dark comparisons match baseline dimensions, typography and colors, +including all sizes, transforms, step dots and addons. Browser checks retain the +real-coordinate drag/release/submission regression and verify configured label +and thumb sizing. The 28-state audit records light invalid/disabled contrast; +other audited states have no findings. The unused direct `@heroui/slider` +dependency is removed. Time, DatePicker and remaining Uniform consumer styling +remain pending in this phase. + +Final `pnpm check` passed all six gates: 1,531 unit tests (one skipped), 464 browser +stories and seven packed consumers. Existing drag/release and focus assertions +remain enabled. + +## Owned Time (2026-10-15) + +Time now owns its label, segmented input, helper and error presentation using +React Aria. Its public slots merge through the configured styling instance. +UTC minute strings, timezone conversion, 12/24-hour formatting, hour granularity, +numeric midnight and date/time object values retain their storage contracts. +The editable segments receive help, required and error associations; invalid +submission focuses the first enabled segment. RHF remains responsible for +validation and disabled-field submission omission. + +Controlled empty values clear completed edits on reset. The explicit form-reset +signal also clears partial edits whose stored value is still empty, without +remounting the control. Behavior tests cover keyboard changes, initial/reset +values, both storage formats, numeric zero, disabled submission and error focus. +Browser stories cover configured segment typography and accessible help/errors. +All 22 light/dark layout, typography and color comparisons match the baseline. +The 26-state accessibility audit records contrast findings in invalid and +custom-color stories; other audited states have no findings. Invalid submission +now visibly focuses the first editable segment. DatePicker and remaining +Uniform consumer styling are the next slices. + +Final `pnpm check` passed all six gates: 1,536 unit tests (one skipped), 466 browser +stories and seven packed consumers. The all-field-renderers example snapshot +was updated for the owned Time markup. + +## Owned DatePicker (2026-10-15) + +DatePicker now owns its segmented field, calendar popup, navigation, day cells +and time footer using React Aria. All public styling slots remain available and +merge through the configured styling instance. UTC string conversion, date +object output, timezone interpretation, 12/24-hour cycles, min/max calendar +limits and disabled submission omission are retained. Date/time fields explicitly +preserve the previous leading-zero formatting. The shared segment defaults are +private to Uniform; public recipe exports remain unchanged. + +Help and schema errors associate with editable segments; invalid submission +focuses the first segment. Partial and completed edits clear on explicit reset. +Tests cover UTC keyboard edits, date-object output, reset, required errors, +disabled fields, calendar limits and selection, popup focus return, keyboard +navigation and time-footer changes. Date-time selection keeps the popup open for time editing; date-only selection +closes it. The time footer retains its supplied time limits. All 24 light/dark field comparisons and eight +calendar dimensions match their baselines. The 36-state field/popup accessibility +audit records contrast findings in invalid/custom-color fields and the light +time footer; other audited states have no findings. + +Unused direct HeroUI date-input/date-picker dependencies and their orphaned +calendar entries are removed, with frozen-lockfile installation verified. +Production fields no longer invoke the public legacy presentation adapter. +Public adapter exports remain for compatibility; external Markdown editor work +is deferred. Internal Uniform consumer styling is next; Pixels/application and +final theme removal are outside this phase. + +Final `pnpm check` passed all six gates: 1,542 unit tests (one skipped), 470 browser +stories and seven packed consumers. Existing DatePicker/Time and all-field +consumer snapshots were updated for the owned markup and retained formatting. + +## Remaining Uniform styling consumers (2026-10-15) + +Form, its debug viewer, Grid, array element wrappers/actions/drag handles, +SubmitButton and the test-ID copy button now use the configured styling instance. +Every Uniform-owned runtime class merge follows the provider; exported recipes, +props and slots remain compatible. Pixels-backed controls still retain Pixels' +current renderer behavior, which belongs to its later migration phase. + +The new browser fixture verifies actual 18px consumer typography through Form, +Grid and nested array wrappers while preserving semantic color classes. Before +this pass, the same configured classes collapsed to the 14px default. The +uncustomized light/dark layout is unchanged in the captured evidence. The debug +viewer also has a regression at its Pixels Card boundary. The array drag handle +now has an accessible name, with a browser regression. The four-state audit +confirms the naming issue is resolved; remaining findings are contrast in the +Pixels submit button and the light custom-danger-color fixture. Existing form, +array-action, sorting, submit and debug subscription tests remain enabled. + +This completes the requested Uniform production-field, React Select integration +and internal consumer scope. Public `useInput`/legacy field adapter exports and +the RadioBox compatibility type remain intentionally available. The external +Markdown editor, Pixels/application migration and final HeroUI/theme removal +remain deferred. + +Final `pnpm check` passed all six gates: 1,543 unit tests (one skipped), 472 browser +stories and seven packed consumers. The only existing snapshot changes in this +slice add the accessible drag-handle name. + +## Breaking Uniform dependency removal (2026-10-15) + +The user explicitly authorized deleting the three public legacy hooks after +confirming that consumers do not directly use `useUniformField` or +`useUniformFieldArray`; the only known `useInput` consumer is the external +Markdown editor. Their exports, compatibility wrappers and presentation getter +types are deleted. Internal field/array state is private and retains its existing +registration, transform, validation, array initialization and reset coverage. +The custom-control browser fixture now tests private state rather than a removed +public API. Nine tests of the deleted third-party `useInput` re-export are removed; +all Uniform-owned behavior tests are retained. + +RadioBox's option contract now declares only owned supported props, eliminating +its HeroUI type dependency. All direct `@heroui/*`, `framer-motion` and +`@react-aria/visually-hidden` entries are removed from Uniform. Animation continues +through `pixel-motion`. Frozen-lockfile installation is verified. Packed-package +verification rejects direct HeroUI dependencies, runtime/declaration references, +and reintroduced legacy or private hook exports. + +This is an intentional breaking API removal and must be released accordingly. +Versions/changelogs remain owned by Release Please. Migration guidance is in the +[Uniform migration guide](./uniform/migration.md). Pixels no longer brings HeroUI transitively. Its runtime, application consumers and final theme/plugin cleanup +are complete, as recorded in the final repository cleanup below. No external Markdown editor source was changed. + +Final `pnpm check` passed all six gates: 1,534 unit tests (one skipped), 472 browser +stories and seven packed consumers. The unit-count change is exactly the nine +obsolete third-party re-export tests; field/array behavior coverage remains. +The packed Uniform independence guard passed against its actual tarball. + +## Input composition refactor (2026-10-15) + +Input is now split into a form-binding component and independent presentation. +`Input.tsx` owns field registration, transforms, debounced edits, reset/clear and +native-ref forwarding. Public props are declared in `Input.tsx` and remain +exported from the same module/entry point. The existing dedicated +`inputVariants.ts` remains the sole owner of the public and private recipes. + +Private components live in `Input/subcomponents/`. `InputControl` owns visual state and styling composition, `InputField` +encapsulates the React Aria text/number engines and root selectors, `InputWrapper` +contains the shared editable layout/addons, and `InputClearButton` and `InputStepper` each own their control. They receive values, associations and callbacks without +registering fields or reading form context. Shared `FieldControlProps` and +`FieldIds` types keep control associations consistent between field state and +presentation. Prop declarations are documented, and binding/interaction blocks +are separated with whitespace and purpose comments. No new public entry points +are added. + +All 24 existing Input tests pass without snapshot updates, as do all 18 existing +browser stories. Two added standalone tests verify that text editing, actual +input refs, clearing, controlled value updates and number-stepper context work +without a Uniform Form. This refactor intentionally changes no rendering, slots, +API, value formats, validation timing, reset or focus behavior. + +Final `pnpm check` passed all six gates: 1,536 unit tests (one skipped), 472 browser +stories and seven packed consumers. Input snapshots, styling recipes, package +exports and peer/dependency ranges are unchanged. + +### Shared radio composition + +Radios and RadioBoxes now compose a private `RadioGroupField`, `FieldLayout` and +`RadioControl` instead of RadioLayout. The group engine accepts explicit values, +state and callbacks, routes form focus to an enabled option and shares supporting +message IDs. The layout uses React Aria Label to preserve radiogroup naming. +Group defaults and option styling have their own variant recipes, with option +hover styling driven by React Aria render state. Public entry points, numeric +value conversion, disabled behavior and existing styling slots are retained. + +## Final repository cleanup + +The final shared-theme migration removes all remaining direct HeroUI dependencies +and prunes the lockfile. Pixels also drops its unused direct Framer Motion edge; +Pixel Motion retains the animation dependency it uses. Atelier and Megapixels +keep their existing implementations and consume the migrated primitives. + +The public `heroui` export is removed. Consumer migration uses the token CSS +import and built-package sources in the [styling guide](./styling.md#semantic-tokens). +No replacement JavaScript plugin is needed. Preserve historical sections above +as the implementation record; their pending notes describe the stage at the time. + +Package verification now rejects HeroUI dependencies and runtime/declaration +references across every published package, and rejects the removed plugin export. +The known external Markdown editor remains outside the repository and must adopt +the previously documented breaking hook migration when upgrading. diff --git a/docs/package-release-workflow.md b/docs/package-release-workflow.md index f76816e12..a6ab2de3c 100644 --- a/docs/package-release-workflow.md +++ b/docs/package-release-workflow.md @@ -119,10 +119,15 @@ their original cutoff. Never leave `last-release-sha` set permanently: it overrides release discovery and can repeatedly include already released commits. Review the resulting commits and push with `git push -u origin next`. -Bootstrapping does not push or publish. Run `pnpm test:release` to exercise the -bootstrap against temporary Git repositories, including repeat runs, remote -branch recovery, stable version advancement, legacy cutoff migration, and new -cycles that exclude already released history. +Bootstrapping does not push or publish. Its tests run in Vitest's `ci-scripts` +project as part of the unit suite. They exercise temporary Git repositories, +including repeat runs, remote branch recovery, stable version advancement, +legacy cutoff migration, and new cycles that exclude already released history. +For a focused run: + +```sh +pnpm exec vitest run --project=ci-scripts scripts/bootstrap-next-branch.test.mjs +``` ## Permissions diff --git a/docs/pixels/migration.md b/docs/pixels/migration.md new file mode 100644 index 000000000..02a7cdca0 --- /dev/null +++ b/docs/pixels/migration.md @@ -0,0 +1,388 @@ +# Pixels migration + +## Button and ButtonGroup + +Button uses React Aria Button for press, focus and disabled behavior. ButtonGroup +owns the group element and context. Both use owned Tailwind Variants recipes +resolved through the consumer's styling instance. + +Public imports, the native button ref, press callback, form association, loading +state and opt-in ripple remain supported. The button recipe still returns a +single class string, so existing recipe extensions remain valid. The recipes no +longer extend HeroUI or refer to its CSS variables. Small spinner and ripple +components stay under Button, the ripple hook owns overlapping animation state. + +### Compatibility + +Pixels previously applied its own button recipe on top of HeroUI's group-aware +recipe. The migration preserves that order: group width and edge styling remain, +while Pixels child defaults continue to override group color, size, variant and +disabled defaults. A group variant can still contribute classes that the child +variant does not replace, such as the border from `variant="bordered"`. +Button’s explicit `ripple` setting also retains precedence over group +`disableRipple`. + +This is existing behavior, not a new group-default contract. Configure each +Button explicitly when those properties matter. Normalizing this precedence +should be a separate API decision with consumer review. + +React Aria adds its own DOM IDs and state attributes. Existing `data-hover`, +`data-focus`, `data-focus-visible`, `data-pressed`, `data-disabled` and +`data-loading` styling hooks remain available. Ripple feedback and the loading +spinner respect reduced motion. + +### Migration status + +All Pixels component renderers now use owned code and styling. Shared HeroUI dependencies, plugin exports and source scanning are removed. +Workspace consumers and published packages are verified with owned tokens. No Pixels component imports HeroUI runtime components or +types. + +[Button](../../packages/pixels/src/Button/README.md) · [ButtonGroup](../../packages/pixels/src/ButtonGroup/README.md) · [Styling](../styling.md) · [Migration inventory](../heroui-removal.md) + +## Popover + +Popover uses React Aria DialogTrigger and Dialog, with the overlay and positioning +hooks for non-modal dismissal. A small overlay component owns positioning, +portalling, animation and focus restoration. The trigger and section layout live +in their own subcomponents, all slot defaults live in `popoverVariants.ts`. + +Using the hooks preserves outside-press dismissal, independent scroll blocking +and the existing `Element` portal contract without a deprecated portal prop. +Overlay isolates the trigger's press context from buttons inside the dialog. +Header content now labels the dialog, with the trigger as the fallback name. +Entry and exit use the existing scale/fade timing and respect reduced motion. + +The existing placement names, controlled-state object, trigger button props and +styling slots remain supported. The `base` and `overlay` slots additionally expose +the arrow surface and animated wrapper. Styled triggers preserve their previous +success and warning foregrounds while reusing Pixels Button. + +Pixels no longer declares `@heroui/popover` directly. Other HeroUI components may +still bring it in transitively. Popover's scrollable body uses the owned ScrollShadow component. +Menu now uses Pixels Button, so its former HeroUI button dependency is removed. + +[Popover usage and slots](../../packages/pixels/src/Popover/README.md) + +## ScrollShadow + +ScrollShadow uses a native scroll container and owned mask styles. Its local hook +updates the existing edge attributes on scroll, content changes and resize, without +adding a wrapper around children. Horizontal RTL scrolling uses physical left and +right edges. Explicit visibility modes remain independent of overflow. + +The native ref, inline styles, shadow size and visibility API remain supported. +Pixels no longer declares `@heroui/scroll-shadow` directly, though other HeroUI +components can still depend on it transitively. + +[ScrollShadow usage](../../packages/pixels/src/ScrollShadow/README.md) + +## Progress + +Progress uses React Aria's progress-bar semantics and owned track, label and +indicator recipes. The activity indicator owns its repeating motion, while normal +value updates retain the half-second transition and skip the initial animation. +Reduced motion and `disableAnimation` show a static activity segment. + +The percentage, completion color, formatter and public styling slots remain +supported. Rich formatter content renders visually while ARIA receives the numeric +percentage, rather than serializing a React element into an attribute. +Pixels no longer declares `@heroui/progress`, both progress components now own their rendering. + +[Progress usage](../../packages/pixels/src/Progress/README.md) + +## ProgressCircular + +ProgressCircular uses the same React Aria range semantics as Progress. Its ring +subcomponent owns SVG geometry and size-dependent stroke widths, with the existing +five sizes, rounded caps and track appearance. The main component chooses normal, +completed or error content, and the owned recipe exposes a `statusIcon` slot in +addition to the existing slots. + +Errors take precedence over completion. Rich values and status icons no longer +become serialized objects in ARIA attributes, errors announce "Error" and normal +progress keeps a plain value description. Initial rendering and disabled/reduced +motion remain static, subsequent value changes retain the half-second transition. + +Both Progress components now have no HeroUI imports or types. Pixels' direct +`@heroui/progress` dependency is removed. + +[ProgressCircular usage](../../packages/pixels/src/ProgressCircular/README.md) + +## Tooltip + +Tooltip uses React Aria trigger and tooltip behavior, with a span preserving its +existing trigger wrapper. Focusable children support keyboard focus, Escape and +blur. The owned recipe extends Popover's arrow geometry while preserving Tooltip's +medium radius, small shadow, section widths and existing slots. Entry and exit +feedback respect reduced motion. Pixels no longer declares `@heroui/tooltip`. + +[Tooltip usage](../../packages/pixels/src/Tooltip/README.md) + +## Menu + +Menu uses React Aria's menu trigger and action collection, with overlay and positioning hooks +for positioning, scroll blocking and dismissal. The popup remains non-modal, so +outside controls can receive the interaction that dismisses it. The public Overlay portal +keeps the Element container contract. Trigger, collection and popup responsibilities +live in separate subcomponents, with owned recipes and explicit styling slots. + +Sections remain labelled groups rather than nested submenus. Disabled options, +textValue typeahead, global key callbacks and item press events remain supported. +Styled triggers reuse Pixels Button with independent group defaults. Pixels no +longer declares `@heroui/button` or `@heroui/dropdown` directly. + +[Menu usage](../../packages/pixels/src/Menu/README.md) + +## Modal + +Modal uses React Aria's dialog and modal overlay hooks with owned surface styles. +The visibility bridge retains closing content for exit feedback, while the dialog +engine owns focus containment, Escape, outside presses and page scroll locking. +Heading, body, footer and close control remain separate presentation components. + +The controlled `isOpen`/`onClose` contract, imperative `modal` store, Element portal +container, sizes and existing styling slots remain supported. Body IDs are now +unique and point to the rendered body, fixing the previous description association. +Dialogs without a header receive a fallback accessible name. Reduced motion and +`disableAnimation` skip feedback. The eight captured light/dark states retain +their original dialog geometry. + +Pixels no longer declares `@heroui/modal` directly or brings it in through Drawer. + +Stacking tests open the next dialog above an already mounted one. Mounting +multiple initially open dialogs in the same render retains the previous engine’s +screen-reader visibility limitation, verified against the legacy Drawer engine. + +[Modal usage](../../packages/pixels/src/Modal/README.md) + +## Drawer + +Drawer reuses Modal's dialog engine, sections and close control, with its own +edge placement, size, radius and backdrop recipe. Its controlled callbacks, +custom close button, Element portal and existing slots remain supported. +Disabling backdrop dismissal still leaves Escape available. + +The shared engine keeps focus inside when pressing a nondismissible backdrop. +Its local viewport hook preserves visual-viewport sizing for mobile keyboards, +with window sizing as the fallback. Custom close elements retain their content +and handlers without nesting buttons. The `DefaultOpen` story now honors its +initial open state, and keyboard and dismissal scenarios live under their own +sidebar folders. + +Pixels no longer declares `@heroui/drawer` directly. Modal and Drawer have no +HeroUI runtime or type imports. `pnpm check` now runs Pixels' direct TypeScript +check over component source, tests and stories as well as its declaration build. + +[Drawer usage](../../packages/pixels/src/Drawer/README.md) + +## Avatar + +Avatar now owns image loading, its placeholder and fallback presentation. A new +source resets loading without allowing a previous request to reveal the next +image. Border, radius, size, disabled appearance and existing slots remain +available through the styling context. Presentational avatars remain outside +the tab order, while enclosing interactive wrappers keep their own semantics. + +AvatarGroup supplies owned group membership for overlap and hover styling. + +[Avatar usage](../../packages/pixels/src/Avatar/README.md) + +## AvatarGroup + +AvatarGroup now owns the overlapping row, optional wrappers and overflow indicator. +The `max={0}` unlimited mode, per-avatar disabled state, group sizing and +wrapper callbacks remain supported. Unlimited mode now omits the redundant +overflow indicator previously shown even when every avatar was visible. The compact visible plus retains its full +`+N` accessible name. Custom avatar slots now survive grouping instead of being +overwritten by the previous engine's child cloning. + +The shared avatar dependency is removed. Both Avatar and AvatarGroup have no +HeroUI runtime or type imports. + +[AvatarGroup usage](../../packages/pixels/src/AvatarGroup/README.md) + +## Badge + +Badge now owns its positioning container and indicator recipe. Undefined content +still returns children directly; zero, empty-string dots, single-character circles, +rich content, outlines and all four placements retain their behavior. Interactive +children retain their native keyboard handling. The size showcase now uses the +valid `md` value instead of its previous trailing-space typo. + +The direct `@heroui/badge` dependency is removed. + +[Badge usage](../../packages/pixels/src/Badge/README.md) + +The light/dark comparison retains Avatar's 16 captured states and AvatarGroup's +geometry and colors across 14 states. Their WCAG audits found no violations in +those states. Badge retains its ten other captured states exactly; the two size +showcases reflect the corrected story argument. Its all-colors contrast findings +match the legacy baseline, with no new findings in the 12 audited states. + +## Label + +Label now owns its content, status dot, palette and treatment recipes. Public +props, callbacks and existing slots remain supported, with an additional +`endContent` slot for trailing element styling. The close action uses React Aria's +native Button, retaining the existing `close chip` name and press event callback. +The caller still owns removal, and closing never submits an enclosing form. + +The active-filter consumer now places its edit and close actions alongside each +other instead of nesting a close control inside an edit button. Its browser +regression covers opening with Enter, dismissal with Escape and removal without +reopening the editor. Consumers should likewise keep removable labels outside +other buttons. + +All 18 captured standard light/dark states and 12 palette/treatment matrix +screenshots match the legacy appearance. Accessibility audits of the standard +states retain the existing palette contrast findings, with no new violations. +The direct `@heroui/chip` dependency and Label's HeroUI theme import are removed. + +[Label usage](../../packages/pixels/src/Label/README.md) + +## Accordion + +Accordion now owns selection, heading associations, keyboard navigation and +animated panel presentation, using React Aria buttons. Single/multiple and +controlled/uncontrolled expansion, required selection and all four treatments +remain supported. String and numeric index keys resolve consistently. Panel +content unmounts after closing, and reduced motion skips height animation. + +The exposed `hideIndicator` option is now honored, fixing its previous omission. +`dividerProps` remains accepted with owned types, while the existing group-slot +borders continue to render dividers. The direct `@heroui/accordion` dependency +is removed. + +The 26 captured light/dark states retain their dimensions, colors and typography. +Accessibility findings match the baseline, including the existing dark custom-style +contrast finding. Keyboard stories exercise disabled-item skipping and expansion. + +[Accordion usage](../../packages/pixels/src/Accordion/README.md) + +## Alert + +Alert now owns its status icons, message layout, palette and dismissal state. +Both `closable` and the existing `onClose`-only behavior show a native React Aria +close action. Dismissal hides the message, calls the owner once and does not +submit enclosing forms. Titles, multiline descriptions, numeric zero, trailing +content and existing styling slots remain supported. The `icon` slot now reaches +the actual icon instead of being ignored by the previous wrapper. + +All 16 captured light/dark screenshots match the baseline. Accessibility findings +are unchanged, including existing contrast findings in the trailing-content and +background showcases. Keyboard dismissal is exercised in an interaction story. + +The direct `@heroui/alert` dependency and Alert's HeroUI theme import are removed. + +[Alert usage](../../packages/pixels/src/Alert/README.md) + +## Breadcrumb + +Breadcrumb now owns its navigation landmark, item rendering, collapse policy and +styling, with React Aria hooks for associations and focus. Public content-only +items, disabled behavior, treatments, sizes and separators remain supported. +The current page stays outside tab navigation. Long paths keep the first and +last two items, with the same collapse threshold as before. + +The collapsed indicator now has an accessible name and correctly receives the +public `ellipsis` styling slot, previously mapped to `separator` by the wrapper. +All 12 captured light/dark screenshots match the baseline. The collapsed-item +name fixes the previous accessibility finding, existing contrast findings are +unchanged. A keyboard story verifies Tab skips disabled items and the current page. +The direct `@heroui/breadcrumbs` dependency is removed. + +[Breadcrumb usage](../../packages/pixels/src/Breadcrumb/README.md) + +## Card + +Card now renders its own content surface, header, body, footer and native dividing +rules. Existing props, section test IDs, forwarded div refs and all five styling +slots remain supported. The outer surface accepts programmatic focus without +adding a tab stop, while contained controls keep their own keyboard interactions. +No additional wrapper or presentation-only subcomponent is introduced. + +All six captured light/dark screenshots match the baseline. Accessibility audits +retain the existing custom-styling contrast finding, with no new violations. +The keyboard story verifies the surface adds no tab stop and contained actions +remain operable. + +The direct `@heroui/card` and now-unused `@heroui/divider` dependencies are removed. + +[Card usage](../../packages/pixels/src/Card/README.md) + +## SearchInput + +SearchInput now owns its input surface and styling using React Aria TextField +and Button. Its local initial-value behavior, size options, test IDs and existing +slots remain supported. Explicit clearing reports `null`, ordinary deletion +reports an empty string. Clearing restores focus and does not submit forms. +The placeholder names the control, with `Search` as the fallback for an empty +placeholder, fixing the previous blank accessible name. + +All 12 captured light/dark screenshots match the baseline. Accessibility audits +confirm the previous blank-name findings are resolved, with no new violations. + +The direct `@heroui/input` dependency is removed. Existing `inputVariants` imports +remain valid and the recipe is now also exported from the component entry point. + +[SearchInput usage](../../packages/pixels/src/SearchInput/README.md) + +## Tabs + +Tabs now uses the React Aria tabs engine with owned list, control, cursor and panel +styles. Controlled/default selection, numeric keys, both disabled-item APIs, +placement, sizes and the seven existing slots remain supported. Inactive panels +unmount by default, retained panels remain inert while hidden. The selection +indicator uses React Aria's shared-element transition with reduced-motion support. + +The 16 captured light/dark states retain their layout, colors and typography. +Accessibility findings match the baseline, including existing contrast findings +in the dark radius, size and treatment showcases. + +The direct `@heroui/tabs` dependency is removed. Tabs without an explicit label +use `Tabs` as the accessible list name. + +[Tabs usage](../../packages/pixels/src/Tabs/README.md) + +## Table + +Table now owns its native table markup, recipes and optional scrolling surface. +React Aria hooks provide grid, column, row and cell associations and keyboard +navigation. The full collection remains registered during virtualization while +only a bounded row window and the focused row are mounted. Native spacer rows +preserve content-based column sizing and remain hidden from assistive technology. +Virtual defaults stay at 600 pixels for the surface and 40 pixels per data row. + +Existing columns, row values, separation treatments, wrapper/header options, +loading/empty content and slots remain supported. Loading now also marks the grid +busy and announces its content. Selection and sorting remain outside the public +API. The direct `@heroui/table` dependency is removed. + +All 14 captured light/dark screenshots match the baseline exactly. Accessibility +findings are unchanged, including the existing light-mode heading contrast +findings. Browser stories exercise cell navigation, bounded virtual scrolling, +end-to-end row/cell keyboard jumps and return to the column headings. + +[Table usage](../../packages/pixels/src/Table/README.md) + +## Final theme and dependency cleanup + +Pixels no longer declares HeroUI or an unused direct Framer Motion dependency. +Pixel Motion still owns its actual animation dependency. The shared Tailwind +configuration scans workspace source only, and pixel-utils no longer exports +`heroui` or bundles its types. The lockfile is pruned of HeroUI packages. + +This completes the repository migration. The setup change is intentionally +breaking: applications must import `@fuf-stack/pixel-utils/tokens.css` after +Tailwind and scan the built packages they use. Replace custom `--heroui-*` +overrides with `--pixel-*` variables. See the [styling setup](../styling.md#semantic-tokens) +and [pixel-utils migration](../../packages/pixel-utils/README.md#migration-from-the-heroui-plugin). + +All published tarballs are checked for HeroUI dependencies, runtime imports, +declaration references and CSS variables. Application and Uniform stories run +against the same plugin-free setup as Pixels. + +Before/after captures for 24 representative Pixels and Uniform light/dark states +are byte-identical, including a fresh Storybook process without the plugin. +Accessibility violation types and affected-element counts are unchanged, with +only existing contrast findings in these states. diff --git a/docs/styling.md b/docs/styling.md index ea32a9c35..135ee0a87 100644 --- a/docs/styling.md +++ b/docs/styling.md @@ -6,6 +6,93 @@ variants/slots pattern (the default), and how to ship a raw CSS file on the rare occasion a component needs one. The same conventions apply to every package that ships UI components. +For component layout, prop ownership, documentation and readability, see the +[code style guide](./code-style.md). This document owns the styling-specific +rules and examples; keep the two guides consistent when conventions change. + +## Tailwind CSS 4 setup + +The workspace uses CSS-first configuration in +[`tailwind.css`](../packages/config/tailwind-config/tailwind.css). There is no +JavaScript/TypeScript Tailwind config or `@config` compatibility layer. The Vite +plugin compiles this stylesheet for Storybook and browser tests. Prettier uses +this same entry through `tailwindStylesheet` to recognize semantic utilities. + +```css +@import 'tailwindcss' source(none); +@import '@fuf-stack/pixel-utils/tokens.css'; + +@source '../../pixels/src/**/*.{js,ts,jsx,tsx}'; +``` + +The shared entry explicitly registers Atelier, Megapixels, Pixels and Uniform. +Sources are relative to that stylesheet, so builds work from any package's +working directory. Include `.ts` recipes as well as `.tsx` components when +adding a package. Snapshots, generated output and dependencies are not scanned. +`source(none)` disables implicit discovery, add new workspace sources explicitly. + +Semantic colors, sizes, utilities and the class/data-attribute dark variant +live in `pixel-utils/src/tokens.css`, using `@theme`, `@utility` and +`@custom-variant`. Do not duplicate their defaults in the workspace entry. +Applications outside this repository use the [published token setup](#semantic-tokens) +and register the built packages they consume. + +## Recipe file ownership + +Keep a component's recipes in a dedicated `Variants.ts` file, such as +[`inputVariants.ts`](../packages/uniform/src/Input/inputVariants.ts). Define and +export `VariantProps`, `ClassName` and resolved slot types beside the recipes they +describe. Keep the component's main prop interface in its own `.tsx` file; +subcomponents own their prop interfaces in the same way. See +[type ownership](./code-style.md#types-live-with-their-owner) for concrete examples. + +Older wrappers may still inline recipes and aliases. Follow the dedicated-file +structure for new components and focused refactors without changing public +exports merely to reorganize implementation files. + +Keep shared recipes with a component that owns them, and import them directly +from related components. A separate shared styling folder is unnecessary for +reuse between two fields. Compose through `tv`, instead of spreading plain class +maps or interpolating slot strings: + +```ts +// DatePicker/datePickerVariants.ts +import { segmentedInputVariants } from '../Time/timeVariants'; + +// Time/timeVariants.ts +export const segmentedInputVariants = tv({ + slots: { + /** Bordered surface around the editable segments */ + inputWrapper: 'rounded-small border-2 border-default-200', + }, +}); + +export const datePickerVariants = tv({ + extend: segmentedInputVariants, + slots: { + /** Button opening the calendar popup */ + selectorButton: '', + }, +}); +``` + +Describe each recipe briefly by the elements or states it styles. Avoid migration +history or vague ownership language such as “owned defaults for existing slots”. +Export the component's main recipe from its `index.ts`, so consumers can reuse it +through the component's public import path. Keep internal helper recipes private +unless consumers need them. + +```ts +// TextArea/textAreaVariants.ts +/** Textarea slot styles with disabled, filled and invalid states */ +export const textAreaVariants = tv({ + // slots and variants +}); + +// TextArea/index.ts +export { textAreaVariants } from './textAreaVariants'; +``` + ## TL;DR - Style components with **Tailwind variants + slots** via `tv()` from @@ -24,24 +111,39 @@ ships UI components. Components are styled with [tailwind-variants](https://www.tailwind-variants.org/) re-exported as `tv` from `@fuf-stack/pixel-utils`. A component defines named **slots** (the stylable parts) and **variants** (the style axes), then maps the -resolved classes onto the underlying element(s) (often a HeroUI component). +resolved classes onto the underlying element(s) (native elements or React Aria components). ### 1) Define slots and variants with `tv()` +Give each slot definition a short JSDoc description of its role or contents, +without a trailing full stop. Explain what it wraps, displays or controls rather +than merely repeating the component and slot names. For example, use +`/** Supporting message below the title */` instead of `/** Alert description */`. +Describe the element, not its current CSS classes. Variant overrides reuse the +slot's description and do not need to repeat it. + ```tsx import type { TVClassName, TVProps } from '@fuf-stack/pixel-utils'; -import { tv, variantsToClassNames } from '@fuf-stack/pixel-utils'; +// alertVariants.ts +import { tv } from '@fuf-stack/pixel-utils'; export const alertVariants = tv({ // slots = the stylable parts of the component slots: { + /** Outer container for the icon, message and actions */ base: 'min-w-72 gap-3', + /** Button for dismissing the alert */ closeButton: '', + /** Supporting message below the title */ description: '', + /** Icon indicating the message type */ icon: '', + /** Container around the message icon */ iconWrapper: '', + /** Container grouping the title and supporting message */ mainWrapper: 'ms-0 items-stretch gap-1.5', + /** Heading above the supporting message */ title: '', }, // variants = the style axes callers (or internal logic) can toggle @@ -62,10 +164,60 @@ export const alertVariants = tv({ }); ``` +### Group long class lists by purpose + +Split long slot class lists into arrays of strings. Keep related utilities together, +for example control layout, border and background, typography, focus feedback or +pseudo-element layers. Add short lowercase comments describing each group's role. +Keep short, obvious slot styles as single strings, and avoid a comment per utility. +Use the same grouping for substantial overrides inside variants and compound variants. + +Preserve the original class order when splitting a list, including state selectors, +conflicting utilities and browser-specific rules. Do not sort the classes as part of +this cleanup. Keep slot keys alphabetical in defaults and all overrides, without +reordering variant axes or compound variant entries, which can affect precedence. + +```ts +inputWrapper: [ + // control dimensions and layout + 'relative inline-flex h-10 w-full items-center gap-3', + // bordered surface + 'rounded-small border-2 border-default-200 bg-content1 px-3', + // hover and focus borders + 'hover:border-default-400 focus-within:border-focus', + // reduced motion + 'transition-colors motion-reduce:transition-none', +], +``` + +When a component reuses only part of another recipe, reference those slot +and variant definitions in its own `tv` recipe. Resolve and merge the resulting +recipe once, rather than calling individual slot functions and `cn` inside JSX. +Use `extend` when all of the other recipe's defaults belong on the component. + +```ts +const radioBoxVariants = tv({ + slots: { + base: 'rounded-lg border-2 p-4', + control: radioControlVariants.slots.control, + labelWrapper: [radioControlVariants.slots.labelWrapper, 'grow'], + }, +}); +``` + +A small mapping between differently named slots can stay directly on a child's +`className` prop, for example `{ base: classNames.boxBase }`. It only forwards +resolved classes, without composing styles in JSX. + ### 2) Derive the public prop types from the variants -The variant definition is the single source of truth. Derive both the variant -props and the `className` type from it, so they never drift: +The variant definition is the single source of truth. Derive and export both +aliases from `alertVariants.ts`, so they stay with the recipe and never drift: + +For a small component-local recipe, keep the recipe and derived aliases directly +in the component file instead. A dedicated variants file is useful when styling +is substantial or shared. See the +[colocated recipe example](./code-style.md#types-live-with-their-owner). ```tsx // union of the variant options (e.g. `{ variant?: 'info' | 'danger' | ... }`) @@ -74,10 +226,17 @@ export type VariantProps = TVProps; // either a single class value or a per-slot record: // className="..." -> applied to the base slot // className={{ base: '...', title: '' }} -> applied per slot -type ClassName = TVClassName; +export type ClassName = TVClassName; +``` + +Import those types into the component file; keep its main prop definition there: + +```tsx +// Alert.tsx +import type { ClassName, VariantProps } from './alertVariants'; export interface AlertProps extends Omit { - /** CSS class name (string for base slot, or a per-slot record) */ + /** CSS class name */ className?: ClassName; variant?: VariantProps['variant']; // ... @@ -90,17 +249,47 @@ export interface AlertProps extends Omit { into a `{ slot: 'classes' }` object and merges the caller's `className`: - a **string/array** `className` is applied to the `baseSlot` (3rd arg), -- a **record** `className` is merged slot-by-slot. +- a **record** `className` is merged slot-by-slot, with `undefined` overrides + preserving the slot defaults. Conditional overrides such as + `{ label: hasSubline ? undefined : classNames.label }` are supported. + +Keep these two calls in their own block with `// classNames from slots` above them. +Use `variants` for the resolved slots and `classNames` for their merged strings, +including when resolving through `useStyling()`. Keep them as separate assignments, +do not inline `resolve(...)` inside `variantsToClassNames(...)`. + +Compose slot classes through Tailwind Variants, not standalone class maps or +literal slot overrides passed to partials. Put defaults in the owning recipe and +pass its resolved `classNames` to shared presentation: + +```tsx +const radioTabsVariants = tv({ + slots: { + /** Supporting text below the selection */ + description: 'text-tiny text-foreground-400', + /** Container for validation messages */ + errorMessage: 'text-tiny text-danger', + }, +}); + +// after resolving and merging the recipe +; +``` + +The shared [FieldLayout](../packages/uniform/src/partials/FieldLayout/README.md) +receives resolved slot classes and applies `className.base` to its default outer +`div`. Supply attributes, interactions and refs via `baseProps`; styling stays in +`className`. Use `baseAs` to select a field engine instead of the default `div`. +The layout passes `className.base` and `baseProps` to that engine, with the label, +control and supporting messages as its children. See the [layout examples](./code-style.md#component-files-and-responsibility). ```tsx const Alert = ({ className, variant = 'info', ...props }: AlertProps) => { - // 1. resolve slot classes for the active variants + // classNames from slots const variants = alertVariants({ variant }); - // 2. merge caller className; string className lands on the `base` slot const classNames = variantsToClassNames(variants, className, 'base'); - // 3. hand the per-slot classNames to the underlying element(s) - return ; + return
; }; ``` @@ -113,14 +302,149 @@ import { cn } from '@fuf-stack/pixel-utils';
; ``` +Keep slot keys alphabetical, including slot overrides within variants. Use one +recipe for a field's defaults and state variants. Split recipes only for distinct +rendered controls or actual shared defaults, such as segmented input styling used +by both Time and DatePicker. `VariantProps` describes every recipe variant without +filtering. Extend it only when the component intentionally exposes those variants. +Otherwise define component props explicitly, keeping derived state private: + +```ts +export const textAreaVariants = tv({ + slots: { + /** Supporting text below the textarea */ + description: 'text-tiny text-foreground-500', + /** Validation message container */ + errorMessage: 'text-tiny text-danger', + /** Editable multiline text */ + input: 'w-full bg-transparent', + }, + variants: { + invalid: { true: { input: 'text-danger' } }, + }, +}); + +export type VariantProps = TVProps; +``` + +```tsx +// TextArea.tsx +export interface TextAreaProps { + /** Disables the field */ + disabled?: boolean; + /** Field name */ + name: string; +} +``` + ### Conventions +- Expose slots for the styled parts consumers need to customize, including + labels, descriptions, validation messages and actions. Define them in the + public recipe so `TVClassName` accepts them. Keep state variants in the same + recipe and exclude private state axes from component props rather than from + the recipe’s `VariantProps`. See the + [Input slot example](../packages/uniform/src/Input/README.md#styling). + +- Use `className` for our component styling props, including private components + receiving resolved slot maps. Keep a resolved map's type separate from the + consumer override type. See the [code style examples](./code-style.md#types-live-with-their-owner). + - Keep slot keys stable and descriptive; empty string (`''`) is a valid default. - Put shared/base classes in the slot default; put axis-specific overrides under `variants`. - Prefer deriving prop types (`TVProps` / `TVClassName`) over hand-written unions. -- When wrapping HeroUI, map the resolved `classNames` onto its `classNames` prop - and translate our prop names to HeroUI's where they differ. +- Pass resolved slot classes to the elements or subcomponents that own them. + +## Isolated consumer class merging + +`createStyling` from `@fuf-stack/pixel-utils` accepts Tailwind Merge's +`extend`/`override` format. Its `cn`, `tv`, and slot resolution use one effective +configuration, including typography plugin groups. It uses the Tailwind Variants +lite engine so compound variants and extended recipes reach the final merge +without previously discarded classes. `TVProps` now comes directly from +Tailwind Variants rather than the HeroUI theme. + +```tsx +import { createStyling, tv } from '@fuf-stack/pixel-utils'; +import { StylingProvider, useStyling } from '@fuf-stack/pixels/StylingProvider'; + +const styling = createStyling({ + extend: { classGroups: { 'font-size': [{ text: ['body'] }] } }, +}); +const recipe = tv({ slots: { input: 'text-sm text-danger' } }); + +function Control() { + const helpers = useStyling(); + + // classNames from slots + const variants = helpers.resolve(recipe); + const classNames = helpers.variantsToClassNames(variants, { + input: 'text-body', + }); + + return ; +} + + + +; +``` + +Create instances outside rendering and reuse them in application helpers. +The provider follows the React tree through portals and allows nested providers, +independent roots, and separate server requests. With no provider, `useStyling` +returns `defaultStyling`. String, array, and per-slot record overrides keep their +existing meaning. For module-level recipes, call `resolve` before slot functions +are evaluated; applying a custom merge after default resolution cannot recover +discarded classes. + +The standalone `cn` export retains its historical merge behavior for compatibility. +Use `defaultStyling.cn` or an instance’s `cn` for semantic utilities such as +`text-medium`. All workspace component recipes resolve through their styling +instance. Tailwind must generate custom classes such as `text-body` separately, +for example through a `--text-body` theme token. + +## Semantic tokens + +The public stylesheet `@fuf-stack/pixel-utils/tokens.css` preserves the default +semantic color palettes, layout sizes, radii, shadows, opacity and transition +utilities. It contains owned `--pixel-*` variables and Tailwind v4 directives; +it does not load the HeroUI plugin or reference HeroUI variables. Import it +after Tailwind in the application's global CSS entry: + +```css +@import 'tailwindcss'; +@import '@fuf-stack/pixel-utils/tokens.css'; + +/* Tailwind normally excludes node_modules from automatic source discovery. */ +@source '../node_modules/@fuf-stack/pixels/dist'; +@source '../node_modules/@fuf-stack/uniform/dist'; + +@theme { + --text-body: 1.125rem; +} + +:root { + --pixel-color-primary: #006fee; +} +``` + +Paths in `@source` are relative to this stylesheet. Add Megapixels when used. +The tokens follow `.light`/`.dark` classes and `data-theme="light"`/`"dark"`, +including nested theme scopes. Theme overrides can target those same scopes. +Configure merge rules separately when a custom utility such as `text-body` +needs to conflict with a built-in utility; token generation alone is insufficient. + +The HeroUI plugin and its source scanning are removed. The old `heroui` export +from pixel-utils is removed as a breaking change. Consumers must replace plugin +setup with the CSS import and built-package sources above, and replace custom +`--heroui-*` overrides with their `--pixel-*` equivalents. The workspace uses +this same token stylesheet, without a separate component theme plugin. + +The stylesheet is shipped unchanged in `dist`, explicitly exported and marked +side-effectful. Packed-consumer verification compiles it without HeroUI tooling +and scans built component files for utility classes. ## When a component needs a real CSS file @@ -197,3 +521,55 @@ package-wide aggregate. An app that renders pixels components through - The ESM build strips the component's `import './Json.styles.css'` to a `/* empty css */` marker after extraction, so importing the component in Node never pulls a raw `.css`. + +## Field state attributes + +Uniform's private [getFieldDataAttributes helper](../packages/uniform/src/helpers/fieldProps/fieldProps.ts) maps disabled, invalid and +required state to `data-disabled`, `data-invalid` and `data-required`. Inactive +flags are omitted. Input, TextArea and Select use it on their field roots. +It shares the `fieldProps` module with `getFieldControlProps`, which derives +accessibility associations for the editable control or group. Call each helper +where its attributes belong, see the [helpers guide](../packages/uniform/src/helpers/README.md). +Interaction attributes remain with the control, and `data-slot` identifies the +rendered element. Other field-specific state remains local to its renderer. + +```tsx +
+``` + +Use selectors such as `group-data-[invalid=true]:text-danger` in the owning +Tailwind Variants recipe. `data-filled` reflects an actual value, including zero, +without counting the placeholder. Avoid speculative flags for content presence, +see the [component ownership rules](./code-style.md#component-files-and-responsibility). + +For interaction state that React Aria already exposes, use its attribute directly. +Input's Group owns hover on `inputWrapper`, whose recipe uses +`data-[hovered=true]:border-default-400`. Do not mirror that state in a parent or +add a second hover attribute for styling. See the +[interaction ownership example](./code-style.md#component-files-and-responsibility). + +Workspace Tailwind source globs include both `.ts` and `.tsx` files, so dedicated +variants files contribute their utilities. Keep both extensions when adding +another package to the shared source configuration. + +## Pixels buttons + +Button and ButtonGroup use owned recipes through `useStyling()`. Their recipes +return a single class string, preserving existing `className` and recipe-extension +usage. Use `styling.resolve(buttonVariants, props)` when resolving a recipe with +consumer merge rules. See [Button](../packages/pixels/src/Button/README.md) and +[Pixels migration notes](./pixels/migration.md) for group precedence. + +## Component and slot markers + +`data-component` identifies the public component on its outer DOM element using +lowercase kebab-case. `data-slot` identifies elements within that component. +For example, `[data-component="avatar-group"]` targets the group, while +`[data-component="input"] [data-slot="input"]` targets its editable control. +Use the documented `className` slots for styling overrides. Nested components +retain their own markers, and portaled surfaces live outside their triggers. +See the [code style convention](./code-style.md#component-and-slot-markers). diff --git a/docs/uniform/field-composition.md b/docs/uniform/field-composition.md new file mode 100644 index 000000000..4ee993278 --- /dev/null +++ b/docs/uniform/field-composition.md @@ -0,0 +1,249 @@ +# Uniform field composition + +Fields bind form state at the top level and pass a `field` bundle to their +control components. Presentation does not register another field or read +form context. Use a separate control component when it owns substantial rendering +or interactions, simple fields can keep those responsibilities inline. + +## Bound field contract + +`useUniformField` returns `{ field, control, debugMode, getValues, resetField }`. +The private `UniformField` type lives beside the hook. Its explicitly declared +properties belong to one field: + +- Value and interactions: `name`, `value`, `defaultValue`, `onChange`, `onBlur`, `ref` +- Content and validation: `label`, `description`, `error`, `errorMessage`, + `disabled`, `invalid`, `required` +- Associations and identification: `ariaLabel`, `customAriaLabel`, `hasLabel`, `hasErrors`, `ids`, `testId` + +Renderers call `getFieldControlProps(field)` where control associations are needed. +The helper shares [fieldProps](../../packages/uniform/src/helpers/fieldProps/fieldProps.ts) +with `getFieldDataAttributes`, which supplies styling flags for the relevant wrapper. +The hook returns metadata instead of DOM props. `customAriaLabel` preserves the +explicit override, `hasLabel` excludes debug label content, and `hasErrors` reflects +normalized validation messages. Form operations and debug configuration remain outside +`field`. No React Hook Form controller properties are spread into the bundle. +`errorMessage` remains rendered content, and `error` retains its existing raw/group +shape unless array-value normalization was requested. + +```tsx +const { field } = useUniformField({ ...uniformFieldProps, name }); + +return ( + +); +``` + +Bindings can adapt their own bundle where they coordinate form behavior: + +```tsx + +``` + +A control reads its values from `field`, without registering again or accessing +form context. It forwards only the relevant properties to DOM and UI engines, +never the whole bundle. `useUniformFieldArray` retains the same bundle alongside +its row operations. + +## Input + +```text +Input Form binding, transforms and debounce + InputControl Editable ref, focus, interactions and slot styling + FieldLayout Label, supplied control and supporting messages + InputField (baseAs) React Aria engine, root element and state selectors + FieldLabel Label and required indicator + InputWrapper Bordered Group, editable input and surrounding controls + InputClearButton Clear action + InputStepper Number increment and decrement controls + FieldDescription Help text + error-message Styled association wrapper around supplied messages +``` + +[Input](../../packages/uniform/src/Input/Input.tsx) owns its public props and +connects `useUniformField` to `useInputValueDebounce`. The hook updates the display +immediately, flushes pending edits on blur and cancels them before resetting. +Input passes its `clear` operation as `onReset`. `InputControl` calls that operation, +notifies `onClear` and restores focus. + +[InputField](../../packages/uniform/src/Input/subcomponents/InputField.tsx) +provides TextField or NumberField context. `InputWrapper` arranges the actual input, +addons and controls. DOM refs and focus handling stay in presentation. Recipes +and their derived types live in `inputVariants.ts`. Defaults and state variants +share one recipe, with slots in alphabetical order. + +## Layout and messages + +[FieldLayout](../../packages/uniform/src/partials/FieldLayout/README.md) owns the +base `div` and its `className.base` by default. RadioTabs, TextArea and Select pass +root attributes, interaction handlers and refs through `baseProps`: + +```tsx + + + +``` + +Input, Checkboxes, Radios and RadioBoxes select their React Aria engine with +`baseAs`. `FieldLayout` passes `baseProps`, `className.base` and the composed +children to it, keeping the engine context without adding another DOM wrapper: + +```tsx + + + +``` + +`baseProps` is typed for `baseAs` and does not accept `className`, replacement +children or raw HTML. Switch and specialized +layouts continue composing the [individual partials](../../packages/uniform/src/partials/README.md) +to preserve their distinct label and control arrangements. + +The private `useUniformField` hook returns a rendered `field.errorMessage` using +`FieldValidationError`. That component owns the message list and animation. +Field presentation chooses visibility and places the messages in a styled wrapper +with the shared error ID. Direct uses of `FieldValidationError` can supply `id` +for its optional association wrapper. The hook's `field.error` property retains raw/group +errors. Private hook names do not restore the removed public exports, see +[the migration notes](./migration.md#breaking-change-legacy-field-hooks-removed). + +## Radio groups + +Radios and RadioBoxes share the private `RadioGroupField` engine. It receives +explicit values, associations and callbacks, handles selection and disabled state, +and routes focus to an enabled option. Layout composition stays with the field: + +```tsx + + {options} + +``` + +Here `Label` comes from React Aria, preserving group naming without assigning a +native `htmlFor` to the group. `RadioControl` uses `RadioField` and `RadioButton` +for each option, combining group and option descriptions. Its field wrapper uses +`display: contents` to preserve the option layout. + +`RadioControl` and its recipe live under `Radios/subcomponents/`. `RadioBox` +imports them directly from Radios. Family-specific reuse keeps its primary owner, +while `partials/` holds presentation shared across field families. See the +[ownership rule and folder example](../code-style.md#component-files-and-responsibility). + +UI engine value adapters stay in the controls. RadioBoxes, Radios and RadioTabs +convert string keys back to their option value types before calling `field.onChange`. +SelectCombobox converts option objects into stored values and defers the first touched +update until after the selection commits. SliderControl commits its displayed +value at interaction completion when the stored field is still empty. Their +bindings use `useUniformField` and pass narrow `getIsTouched` or `getCurrentValue` +accessors, keeping current form-state reads available without coupling controls +to form context. These small handlers do not need separate binding hooks. + +RadioBox resolves one recipe containing its box surface and the shared radio +indicator styles. That recipe reuses slot and validation definitions from +`radioControlVariants`, so rendering receives one finished class map. + +## Related guides + +- [Code style](../code-style.md) covers component/type ownership, comments, tests and stories +- [Styling](../styling.md) covers slots, variants, class merging and state attributes +- [Input guide](../../packages/uniform/src/Input/README.md) documents usage and public styling slots + +## Other field renderers + +Each substantial renderer follows the same binding and presentation boundary. +The binding keeps registration, transforms and form-update timing. Its control +receives the `field` bundle and component-specific props, without receiving form +operations or the whole binding-hook result. + +| Renderer | Presentation owner | Additional responsibilities | +| ---------- | ------------------- | --------------------------------------------------------------------------- | +| Checkboxes | `CheckboxesControl` | `CheckboxControl` owns each option | +| DatePicker | `DatePickerControl` | `DatePickerInput` owns segments, `DatePickerCalendar` owns the popup | +| FieldArray | `FieldArrayControl` | The array hook binds row operations, presentation arranges rows and sorting | +| FieldCard | `FieldCardControl` | A card-local hook resolves child-validation visibility and required state | +| RadioBoxes | `RadioBoxesControl` | `RadioBox` reuses Radios' option control | +| Radios | `RadiosControl` | Custom option layouts receive the existing option descriptors | +| RadioTabs | `RadioTabsControl` | `RadioTabsList` owns tabs, panels and cursor geometry | +| Select | `SelectControl` | `SelectCombobox` owns engine configuration and option conversion | +| Slider | `SliderControl` | `SliderTrackControl` owns fill, markers and the editable thumb | +| Switch | `SwitchControl` | `SwitchTrack` owns thumb and icon rendering | +| SwitchBox | `SwitchBoxControl` | `SwitchBoxTrack` owns the track, reusing Switch's recipe | +| TextArea | `TextAreaControl` | `TextAreaInput` owns autosizing, editable refs and wrapper focus | +| Time | `TimeControl` | `TimeInput` renders segments, the control prepares displayed values | + +DatePicker and Time configure their storage transforms through `useUniformField`. +Their controls parse incoming display values, prepare placeholders and resolve +engine-specific options. Storage remains UTC strings, date/time objects or numeric +hours according to the existing props. Checkboxes normalizes its displayed +selection inside `CheckboxesControl`. + +`useUniformFieldArray` returns `items`, `appendItem` and `moveItem` alongside its +existing operations. Row callbacks read current form values when invoked, and +`moveItem` validates after moving. The row contract lives beside the hook and +contains data and callbacks only. FieldCard's local `hooks/useFieldCardValidation` hook +retains the child-required and touched/submitted error rules. + +The segmented fields pass a small reset bridge as engine content. `TimeReset` and +`DatePickerReset` subscribe to form resets inside the relevant React Aria context, +so partial edits clear even when the committed value stays empty. The controls +can render independently when that bridge is omitted. + +FieldCard and FieldArray labels and validation footers consume their owner's +existing field data. They do not register the same field again. Specialized +layouts retain their existing label and message arrangement. Simple wrappers +such as SubmitButton do not gain an extra control layer. + +## Date and time type compatibility + +Uniform and React Aria must resolve compatible `@internationalized/date` class +identities. Separate versions can produce `#private` errors on `placeholderValue`, +`minValue` and `maxValue`, even when the class names match. Keep their compatible +resolutions aligned in the lockfile rather than casting field props. + +Package verification compiles `test/dts-smoke/date-values.ts` against Uniform's +installed dependencies to catch this mismatch. + +## Control composition + +Split a control when an editable surface, option or visual track owns a distinct +job. Keep the engine context outside children that consume it, and preserve the +existing DOM arrangement. For example: + +```text +TextAreaControl Field layout and root interaction styles + FieldLayout Label and supporting messages + TextAreaInput Editable ref, text binding, autosizing and wrapper focus + +SelectControl Field layout and slot resolution + FieldLayout + SelectCombobox Option conversion, focus, menu configuration and touched timing +``` + +Time follows DatePicker’s input-surface split. Slider keeps geometry and markers +in `SliderTrackControl`, while RadioTabs delegates individual option rendering +to `RadioTab`. Smaller controls keep their existing composition. These are private +components under each field’s `subcomponents/`, not new public entry points. diff --git a/docs/uniform/migration-review.md b/docs/uniform/migration-review.md new file mode 100644 index 000000000..25648bdfb --- /dev/null +++ b/docs/uniform/migration-review.md @@ -0,0 +1,55 @@ +# Uniform migration completion review + +Reviewed on 2026-10-16. Uniform's direct HeroUI migration is complete: its +manifest, published JavaScript and declarations contain no HeroUI dependencies +or references. Package verification checks this boundary on every full check. +The later Pixels and foundation migrations also removed transitive HeroUI +dependencies. Installing Uniform now produces a HeroUI-free dependency tree. +See the [completed migration inventory](../heroui-removal.md). + +## Final cleanup + +- Removed stale HeroUI implementation comments and corrected the styling guide's + migration status. +- Added short layout-section comments to composed controls and `FieldLayout`, + with a concrete example in the [code style guide](../code-style.md). +- Removed the unsupported `invalid` recipe argument from DatePicker. React Aria + still owns its invalid attributes, which drive the existing styling. A packed + declaration fixture checks resolution of its supported `disabled` variant. + +No styling classes, snapshots, field values or interaction behavior changed in +this cleanup. + +## Verification + +- `pnpm check`: all six checks passed, 1,687 unit tests passed, one skipped, + and 504 Storybook tests passed. +- Rebuilt Uniform after the DatePicker fix and reran packed-package verification, + including the recipe type fixture and all seven published packages. +- Chromium 153: reviewed the all-field form in light and dark themes at 1,000px, + and its single-column layout at 390px. No clipping or layout overlap observed. +- Additional browser interactions confirmed clear-button focus restoration, + checkbox toggling with Space, and calendar dismissal with Escape restoring + focus to its trigger. +- Axe 4.12.1 scanned 26 states: the all-field form and the invalid stories for + twelve field renderers, each in light and dark themes. Only color-contrast + violations were reported. This is not a screen-reader or cross-browser audit. + +The [verification summary](./migration-verification.md) consolidates the earlier +field audits and outstanding accessibility findings. Raw migration images and +JSON reports are no longer retained in the working tree. + +## Remaining release and theme work + +The contrast findings are not resolved by the dependency migration. They include +white text on the Pixels success button, validation text/segment focus colors +in DatePicker and Time, and light-theme invalid text in RadioTabs and Slider. +Earlier renderer audits recorded these issues too. Address them in the shared +semantic-color and component contrast work, including light/dark, focus and +consumer-customized colors. Do not describe the current theme as WCAG AA compliant. + +The removed public hooks require a breaking release. The external Markdown +editor's `useInput` consumer remains deferred and must migrate before adopting +that release. Release Please owns version and changelog changes. See +[migration instructions](./migration.md) and the remaining +[Pixels/foundation inventory](../heroui-removal.md). diff --git a/docs/uniform/migration-verification.md b/docs/uniform/migration-verification.md new file mode 100644 index 000000000..2b9a7f35a --- /dev/null +++ b/docs/uniform/migration-verification.md @@ -0,0 +1,45 @@ +# Migration verification summary + +The HeroUI removal and Tailwind 4 migration were checked with automated tests, +package-consumer verification and targeted Chromium visual/accessibility reviews. +One-time migration screenshots and JSON reports have been removed from the +working tree. They were review evidence, not automated visual-test baselines. +Existing DOM snapshots, behavioral tests and browser stories remain in place. + +## Verification retained + +- Before/after field reviews compared light/dark geometry, typography, colors, + borders and spacing across normal, invalid, disabled and custom-styling states +- Interaction tests cover keyboard navigation, focus containment/restoration, + label/help/error associations, clearing, resets, transforms and submission +- Package checks verify all seven published packages, HeroUI independence, + React 18 AvatarGroup rendering and CSS-first Tailwind 4 consumer compilation +- The latest full local check passed all seven gates, including 588 browser + stories and 1,994 unit tests, with one skipped unit test + +Visual evidence was captured in Chromium, including 900×650 field comparisons. +The completion review also covered light/dark layouts at 1,000px and 390px. +These historical checks do not constitute a current cross-browser or +screen-reader audit. See the [completion review](./migration-review.md). + +## Accessibility findings still open + +Historical axe WCAG 2 A/AA and 2.1 AA audits recorded the following findings. +Removing their raw reports does not resolve them or establish WCAG conformance. + +| Area | Recorded findings | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| Input and TextArea | Invalid text contrast in both themes, including Input label/error text in light mode | +| Select | Light invalid-text and dark search-input contrast, loading listbox lacks required option children (`aria-required-children`) in both themes | +| Switch and SwitchBox | Light invalid-state contrast | +| FieldArray and FieldCard | Invalid-array and object-error text contrast in both themes | +| Radios, RadioBoxes and RadioTabs | Light invalid-state contrast, RadioTabs keyboard/error fixture contrast in both themes | +| Checkboxes | Light invalid-state contrast, line-through, sublabel, custom layout and keyboard/error fixture contrast in both themes | +| Slider | Light invalid/disabled text contrast | +| DatePicker and Time | Invalid/segment-focus and composition-story contrast in both themes, earlier time-enabled calendar checks also found light-theme contrast | +| Shared form consumers | Contrast in default/configured form compositions in both themes, including the success button's white text | + +Other sampled states without findings do not certify whole components. Recheck +these cases in light/dark themes, focus states and consumer-customized colors +when changing the semantic palette. See the +[migration guide](./migration.md) for remaining consumer/release requirements. diff --git a/docs/uniform/migration.md b/docs/uniform/migration.md new file mode 100644 index 000000000..5cdd8f203 --- /dev/null +++ b/docs/uniform/migration.md @@ -0,0 +1,166 @@ +# Uniform migration notes + +Compatibility changes from the HeroUI removal and field composition refactor. +For current component ownership, see [field composition](./field-composition.md). +See the [completion review](./migration-review.md) for verification and remaining +shared-theme limitations. +The [migration inventory](../heroui-removal.md) tracks remaining workspace work. + +## Breaking change: legacy field hooks removed + +The following package exports are removed: + +- `@fuf-stack/uniform/hooks/useInput` +- `@fuf-stack/uniform/hooks/useUniformField` +- `@fuf-stack/uniform/hooks/useUniformFieldArray` + +Their associated public types and presentation getters (`getLabelProps`, +`getHelperWrapperProps`, `getErrorMessageProps`) are removed too. The field and +array state used by Uniform components is now private implementation code. +Do not import it through source or distribution paths. + +The private implementations are named `useUniformField` and +`useUniformFieldArray`. These internal names do not restore the removed public +exports or their former API. + +For custom controls, use the existing public +`@fuf-stack/uniform/hooks/useController` and +`@fuf-stack/uniform/hooks/useFormContext` APIs for registration and form state. +Own the label, description and error markup, create stable IDs with React's +`useId`, and apply accessible associations and the registered ref to the actual +editable element. For CodeMirror, use editor content attributes and route the +ref to its focus method. The Markdown editor's old `useInput` import must be +replaced before adopting this breaking release; its external source is not +part of this repository. + +Use `FieldArray` for rendered arrays. Advanced consumers can pair React Hook +Form's `useFieldArray` with the control returned by public `useFormContext` and +own their presentation and initialization/reset rules. Public +`useInputValueTransform` and `useInputValueDebounce` remain available. + +RadioBoxes retains its existing component props and options. Its internal option +contract no longer inherits unused HeroUI props/types. All production field +rendering, form values, validation timing, reset behavior and public styling +slots remain unchanged by this removal. + +Uniform now declares no direct HeroUI dependencies and its published runtime +and declarations contain no HeroUI references. The Pixels/foundation cleanup is also complete, so +HeroUI is no longer brought in transitively. Consumers must adopt the token CSS +setup in the [styling guide](../styling.md#semantic-tokens). See [the migration inventory](../heroui-removal.md). + +## Numeric empty values + +Number inputs display `null`, `undefined`, and empty strings as an empty control; +`0` remains a visible numeric value. Clearing resets the field to an empty state +without displaying zero. Existing schema/empty-field submission normalization is +unchanged: optional empty fields are omitted from submitted values. + +## Debounced custom controls + +`useInputValueDebounce` updates the displayed value immediately and flushes a +pending edit on blur. A programmatic form reset cancels queued edits, including +when the reset restores the same committed value. Configure `emptyValue: ''` +for a text control and call the returned `clear()` operation to cancel pending +edits and reset the form field to `null`. Without `emptyValue`, the display +follows the value supplied after the form reset. `cancelPendingChange(value)` +remains available for display-only cancellation without resetting the field. + +For a text control bound through public `useController`, connect its field value +and callbacks to the debounce hook: + +```tsx +const { value, onChange, onBlur, clear } = useInputValueDebounce({ + name, + emptyValue: '', + value: field.value, + onChange: field.onChange, + onBlur: field.onBlur, +}); + +// pass value, onChange and onBlur to the control, call clear for its clear action +``` + +## Owned input rendering + +`Input` uses React Aria text and number controls with Uniform's Tailwind styling. +Existing props, exports, transforms and styling slots are preserved. Labels, +descriptions, validation messages and number steppers can also be styled through +`className`. See the [Input slot reference](../../packages/uniform/src/Input/README.md#styling). + +Consumer styling instances apply to the input, including custom font-size +utilities that replace the default font size while retaining text colors. +Import the packaged token stylesheet as described in +[the styling guide](../styling.md). + +Labels and optional `description` text use the shared field association IDs; +`testId` remains the editable control's `data-testid`. Use the label or that test +attribute to find a control rather than relying on its old DOM ID. Markup is +owned by Uniform, so selectors into undocumented HeroUI wrappers should migrate +to supported slots. Required and invalid state continue to come from RHF/Veto. + +For clearable text/password fields, `endContent` is the custom clear-button +content, as before. Numeric fields retain separate end content and a clear +button. Clearing discards pending edits, resets to the existing empty state, +calls `onClear` once and restores focus to the editable control. + +`TextArea` also uses owned presentation and React Aria field behavior. It retains +three-to-eight-row autosizing through the same `react-textarea-autosize` engine, +its existing exported props and four slot names, controlled values, disabled +state and debounce/reset timing. Its `clearButton` slot remains accepted, but +TextArea has no clear action. Optional `description` text and visible errors are +associated with the actual textarea; consumer styling instances apply before +merging its slot classes. Both field renderers keep RHF/Veto validation +authoritative and use `aria-required` without competing native validation. + +## Field state selectors + +Input, TextArea and Select share a private +[getFieldDataAttributes helper](../../packages/uniform/src/helpers/fieldProps/fieldProps.ts) for `data-disabled`, +`data-invalid` and `data-required`, with inactive flags omitted. Focus and hover +remain owned by each control. Input and TextArea use `data-filled` only when a +value is present, including numeric zero, without counting placeholder text. + +Input's `inputWrapper` uses React Aria Group's `data-hovered` attribute. Its +former `data-hover` attributes on the wrapper and root are removed. Consumer +hover styles should use `data-[hovered=true]` on the `inputWrapper` slot. + +The unused legacy attributes `data-has-helper`, `data-has-elements`, +`data-has-label`, `data-has-value`, `data-filled-within`, `data-focus-within` and +TextArea's static `data-has-multiple-rows` marker are removed. Consumer selectors +using them need updating, use the exposed styling slots or the supported field +state attributes. React Aria's own state attributes and accessible associations +remain intact. See the [styling guide](../styling.md#field-state-attributes). + +## Owned Select integration + +`Select` keeps React Select as its interaction engine and now uses the shared +Uniform field-state core without HeroUI's select or input-presentation hooks. +Visible labels, explicit accessible names, required state, optional `description` +and visible errors are applied to the actual search input. React Select's +placeholder/live-region descriptions remain alongside these field associations. +Control and announcement IDs are distinct even when separate forms repeat a +field name; `data-testid` values remain unchanged. + +Option values, numeric conversion, fallback options, loading/search callbacks, +single/multi selection, clearing, transforms and deferred touched notification +retain their existing contracts. All class resolution uses the current styling +instance. Consumer single-value and multi-value label classes merge after owned +defaults. The declared `crossIcon`, `downChevron`, `multiValueContainer`, `group` +and `selectContainer` slots now reach their React Select elements as well. + +## Owned Switch rendering + +`Switch` uses React Aria toggle, hover and focus hooks with owned presentation. +One native switch input carries the field name, RHF ref, checked state and +label/help/error associations; the duplicate hidden form input and visual-focus +proxy are removed. Boolean values, controlled defaults/reset and supported +sizes and styling slots are retained. Space toggles the same editable control +that receives invalid-submission focus. Veto remains authoritative: schema +required state uses `aria-required`, so a valid boolean `false` is not rejected +by native checkbox validation. + +The thumb callback retains its declared `width`, `height`, `className`, +`isSelected` and `data-checked` shape without referencing HeroUI types. The +required marker is hidden from the accessible name. Optional `description` and +visible errors describe the actual input. Use its field name or switch role +rather than the old visual input's internal `*_switch` name. diff --git a/docs/visual-coverage.md b/docs/visual-coverage.md new file mode 100644 index 000000000..d7e780fd3 --- /dev/null +++ b/docs/visual-coverage.md @@ -0,0 +1,101 @@ +# Visual coverage inventory + +Every public component with a rendered surface has a representative story below. +Additional stories protect distinct variants, states and compositions according to +[the visual selection rule](./visual-regression.md#which-stories-get-visual-coverage). +Both light and dark themes run automatically. Update this inventory when changing +visual tags, keep capture settings in stories and baseline PNGs with their owner. + +A component does not need a story named Default when a gallery or composition +already shows its normal appearance. These are selected states, not a requirement +to snapshot every possible prop combination. + +The current set contains **120 stories / 240 theme comparisons**. + +## Pixels + +| Component | Captured stories | +| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| [Accordion](../packages/pixels/src/Accordion/stories/) | All Variants, Default Open | +| [Alert](../packages/pixels/src/Alert/stories/) | All Variants, Multiline Children | +| [Avatar](../packages/pixels/src/Avatar/stories/) | All Border Radii, Custom Styling | +| [AvatarGroup](../packages/pixels/src/AvatarGroup/stories/) | Default, All Disabled | +| [Badge](../packages/pixels/src/Badge/stories/) | All Colors, All Sizes | +| [Breadcrumb](../packages/pixels/src/Breadcrumb/stories/) | With Max Items, All Variants | +| [Button](../packages/pixels/src/Button/stories/) | Disabled, Icon Only, Loading, All Colors, All Sizes, All Variants, Keyboard Activation | +| [ButtonGroup](../packages/pixels/src/ButtonGroup/stories/) | Default, Full Width | +| [Card](../packages/pixels/src/Card/stories/) | Footer And Header | +| [Drawer](../packages/pixels/src/Drawer/stories/) | Default Open | +| [Json](../packages/pixels/src/Json/stories/) | With Json Object, Invalid Json | +| [Label](../packages/pixels/src/Label/stories/) | Icon And Content, All Colors, All Sizes | +| [Menu](../packages/pixels/src/Menu/stories/) | Without Trigger | +| [Modal](../packages/pixels/src/Modal/stories/) | With Header And Footer, Scroll Long Content, With Modal Host | +| [Popover](../packages/pixels/src/Popover/stories/) | All Sections | +| [Progress](../packages/pixels/src/Progress/stories/) | Finished, All Colors, All Sizes | +| [ProgressCircular](../packages/pixels/src/ProgressCircular/stories/) | Finished, Has Error, All Sizes | +| [ScrollShadow](../packages/pixels/src/ScrollShadow/stories/) | Vertical, Horizontal | +| [SearchInput](../packages/pixels/src/SearchInput/stories/) | Clearable, All Sizes | +| [Table](../packages/pixels/src/Table/stories/) | Default, All Separators, Sticky Header | +| [Tabs](../packages/pixels/src/Tabs/stories/) | Default, Vertical Layout | +| [Toast](../packages/pixels/src/Toast/stories/) | Custom Width, Simple | +| [Tooltip](../packages/pixels/src/Tooltip/stories/) | Default Open | + +## Uniform + +| Component | Captured stories | +| ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| [Checkboxes](../packages/uniform/src/Checkboxes/stories/) | With Initial Value, Disabled Option, Invalid | +| [DatePicker](../packages/uniform/src/DatePicker/stories/) | Disabled, Invalid, With Utc Time Zone, Open Calendar | +| [FieldArray](../packages/uniform/src/FieldArray/stories/) | With Initial Value, Invalid, All Features | +| [FieldCard](../packages/uniform/src/FieldCard/stories/) | Default, Object Level Error | +| [Form](../packages/uniform/src/Form/stories/) | Default Layout | +| [Grid](../packages/uniform/src/Grid/stories/) | Responsive Columns, Narrow Columns | +| [Input](../packages/uniform/src/Input/stories/) | With Label, Disabled, Invalid, Number, Password, All Sizes | +| [RadioBoxes](../packages/uniform/src/RadioBoxes/stories/) | With Initial Value, Invalid, With Icons | +| [RadioTabs](../packages/uniform/src/RadioTabs/stories/) | With Content, Disabled Option, All Variants | +| [Radios](../packages/uniform/src/Radios/stories/) | With Initial Value, Disabled Option, Invalid | +| [Select](../packages/uniform/src/Select/stories/) | Open Menu, Multi Select Initial Value, Invalid, Disabled | +| [Slider](../packages/uniform/src/Slider/stories/) | Disabled, Invalid, With Steps | +| [SubmitButton](../packages/uniform/src/SubmitButton/stories/) | Default, Icon Only | +| [Switch](../packages/uniform/src/Switch/stories/) | With Initial Value, Disabled, All Sizes | +| [SwitchBox](../packages/uniform/src/SwitchBox/stories/) | With Initial Value, Disabled, Invalid | +| [TextArea](../packages/uniform/src/TextArea/stories/) | With Initial Value, Invalid, Disabled | +| [Time](../packages/uniform/src/Time/stories/) | Disabled, Invalid, With Utc Time Zone | +| [partials/FieldCopyTestIdButton](../packages/uniform/src/partials/FieldCopyTestIdButton/stories/) | Default | +| [partials/FieldValidationError](../packages/uniform/src/partials/FieldValidationError/stories/) | Multiple Errors | + +## Megapixels + +| Component | Captured stories | +| ---------------------------------------------------------------- | ----------------------------------------------------------------------- | +| [DataTable](../packages/megapixels/src/DataTable/stories/) | Default, Expandable Rows, Row Selection, Loading, Empty After Filtering | +| [Filter](../packages/megapixels/src/Filter/stories/) | With Search, With Initial Value | +| [Notification](../packages/megapixels/src/Notification/stories/) | Simple | +| [VirtualList](../packages/megapixels/src/VirtualList/stories/) | Default, Dynamic Item Heights | + +## Atelier + +| Component | Captured stories | +| ------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| [AtelierFrame](../packages/atelier/src/AtelierFrame/stories/) | Default, Logged Out, Session Loading, Resource Empty, Resource Error, Mobile Layout | + +## APIs without a separate surface + +- `StylingProvider` adds no DOM or visual surface. Consumer styling is covered by + custom Avatar styling and other component captures; its utility-merging + contract remains covered by the provider's interaction and unit tests +- Hooks, field helpers, Atelier core configuration and types have no independent + visual baseline. Test their behavior and types; capture their rendered consumers +- `toast` and `notification` are imperative APIs. Their Simple stories capture + actual toast content through Toaster and NotificationHost. The Toaster Custom + Width story also covers its host configuration +- `modal` and `ModalHost` share Modal's surface. With Modal Host captures the + imperative path alongside Modal's normal and long-content captures +- `createWithSuspense` composes consumer content and fallbacks. Its existing + stories test resolution and errors; it has no separately styled public surface +- Compound table elements and component subcomponents are covered through their + owning component's compositions. Private field partials do not each need a + separate baseline; the public FieldCopyTestIdButton and FieldValidationError do + +No public visual component is intentionally left uncovered. A new public visual +component should add a representative story and baselines in the same change. diff --git a/docs/visual-regression.md b/docs/visual-regression.md new file mode 100644 index 000000000..24b214a62 --- /dev/null +++ b/docs/visual-regression.md @@ -0,0 +1,219 @@ +# Storybook visual regression + +Visual comparisons reuse selected Storybook stories and run in light and dark +themes. They complement interaction and accessibility tests, they do not replace +them. Coverage grows with meaningful visual states rather than the story count. + +## Which stories get visual coverage + +Tag a story when its final rendered state protects a distinct appearance that +interaction assertions would not catch. Do not tag every story automatically. +Every public component with a rendered surface needs at least one representative +baseline. A gallery or composition can serve as that baseline, it does not have +to be a story named Default. Add further states where the layout or styling +changes materially: + +| Story kind | Capture when | Example | +| ------------ | ---------------------------------------------------------------------- | --------------------------------------- | +| Normal usage | It establishes the component's representative appearance | Input with label | +| Variants | A compact gallery covers different colors, sizes or shapes | Alert variants, Switch sizes | +| State | Selection, focus, disabled or validation changes the visible treatment | Selected table rows, invalid TextArea | +| Composition | Wrapping, slots or custom styling could regress independently | Multiline Alert, custom Avatar slots | +| Overlay | The open surface, positioning or backdrop is the contract | Open Select and Tooltip, Modal backdrop | +| Responsive | A specific width exposes overflow or a different arrangement | Narrow DataTable | +| Edge case | The unusual data has a meaningful visible result | DataTable empty after filtering | + +Reuse one gallery instead of capturing each equivalent variant separately. Add a +second story only for an appearance it does not cover. For example, a default +Switch adds little beside the size gallery, while its disabled treatment does. +A callback-only story, value-transform example or repeated prop combination +usually needs interaction assertions rather than another PNG. An interaction +that closes or removes the component also needs a separate stable open-state +story if the visible surface is what should be protected. + +Use fixed data, local assets and explicit dates/time zones. Avoid live requests, +relative dates and random values. Let `play` establish and assert the state before +capture; screenshots record the final state, not every intermediate interaction. +For example, TextArea's autosizing story clears its content at the end, so tag a +populated multiline story to protect the expanded layout instead. Keep focus +visible when that is the state under test. Inspect the initial PNG to confirm the +intended state is actually captured, a passing `play` assertion alone does not +prove that focus or selection remains in that state afterward. Prefer a stable +state story if an interaction returns to the initial appearance. Do not tag an +animation until it has a deterministic resting state. + +The [coverage inventory](./visual-coverage.md) lists selected stories by component +across Pixels, Uniform, Megapixels and Atelier, plus the explicit exceptions for +APIs without their own rendered surface. Use it when reviewing a new component, +and update it whenever stories are added to or removed from visual coverage. + +When adding or changing a component, review the table above and add or update the +smallest set of stories that protects its distinct appearances. New public visual +components need a baseline in the same change. For a behavior-only API, document +which visual component or composition covers its output instead. Both themes run +automatically, do not create separate light/dark story copies. + +## Run and review + +Visual comparisons run with four Playwright workers, each test gets an isolated +browser context. Light and dark cases share that worker pool. On a constrained +machine, reduce concurrency without changing the configuration: + +```sh +pnpm test:visual:docker --workers=2 +``` + +The Docker helper enables colors in a local terminal and forwards explicit +`FORCE_COLOR` levels. Set `NO_COLOR=1` to disable colors. Piped output stays plain +unless colors are explicitly requested. + +Run only the visual category, including dependency setup, workspace builds and a +fresh static Storybook build: + +```sh +make test-visual +``` + +`make test` runs unit tests, parallel Storybook browser tests, then builds static +Storybook and runs these visual comparisons in Docker. Its final report includes +each category's result and runtime, plus total elapsed time. Docker must be +running. `make test-visual` runs just the visual category. `make check` keeps its +existing verification stages; visual comparisons have their own CI job. + +For an already installed workspace, run the underlying commands directly. + +Build the workspace first if its published packages have changed: + +```sh +pnpm build +pnpm --filter @repo/storybook-config storybook:build +pnpm test:visual:docker +``` + +Docker runs Chromium and the test runner in the same pinned Linux arm64 Playwright +image as CI, which uses `ubuntu-24.04-arm`. The static Storybook server runs +inside the container on port 6008, without publishing a host port. Docker must be +running. The command uses the workspace's installed Playwright version, so it +does not download a separate runner inside the container. Use Apple Silicon or +an ARM Linux host for native rendering, x64 hosts need working ARM emulation. + +`pnpm test:visual` runs directly in the current environment and is intended for +the CI container. Use the Docker command on macOS to compare and update the Linux +baselines. Other operating systems and browser versions can render different pixels. + +CI has a separate **Visual regression tests** job with colored list-reporter +output in the Actions log. Its artifact contains the HTML report, failure traces +and expected/actual/difference images. Download and extract +`visual-regression-report`, then serve its extracted `playwright-report` folder: + +```sh +pnpm --filter @repo/storybook-config exec playwright show-report /absolute/path/to/playwright-report +``` + +For a local run, omit the path to open the package's latest report. A missing +baseline fails the check, just like an unexpected visual change. + +## Update a baseline + +After inspecting a difference and confirming the intended appearance: + +```sh +# Update one state in both themes +pnpm test:visual:docker --update-snapshots --grep 'uniform-input--invalid$' + +# Verify it again without update mode +pnpm test:visual:docker +``` + +Review the changed PNGs under +`packages//src//__snapshots__/visual/` and include them with the +component change. Do not update baselines just to make CI green. Reports, +traces, actual/difference images and the built Storybook are ignored generated +files; only approved baseline PNGs belong in Git. + +## Tag an existing story + +Reuse stories rather than adding separate component visual test files. Add the +`visual` tag to a story and declare its capture settings in `parameters.visual`: + +```tsx +export const Invalid: Story = { + args, + tags: ['visual'], + parameters: { + visual: { target: '[data-component="input"]' }, + }, + // keep the interaction and assertions in the existing play function +}; +``` + +Declare `tags: ['visual']` explicitly on each selected story, including stories +that spread another story. The static index does not infer inherited tags from +object spreads. The shared runner discovers tagged stories from the built +Storybook index. +Rebuild with `pnpm --filter @repo/storybook-config storybook:build` after adding a +tag or changing a story. Baseline names follow the story ID's final segment, for example `invalid.png`: + +```text +Input/ + Input.test.tsx + stories/ + Input.stories.tsx + __snapshots__/ + Input.test.tsx.snap + visual/ + dark/invalid.png + light/invalid.png +``` + +The runner waits for the story's resolved settings, applies its viewport, then +releases a test-only loader so rendering and `play` execute at the correct size. +It requires Storybook's successful `storyFinished` event, waits for fonts, and +uses fixed locale/time zone, device scale and reduced motion. Normal Storybook +browsing and its interaction tests do not wait for this visual-test handshake. + +The default rendering viewport is 1280 × 900. Override it per story when layout +at a particular size matters: + +```tsx +parameters: { + visual: { + target: '[data-component="data-table"]', + viewport: { width: 390, height: 844 }, + }, +}, +``` + +The viewport controls layout, not PNG dimensions. Captures crop to the union of +matching elements with 16px padding for focus rings and shadows. Bounds are +clipped at the viewport edge, so narrow tables still check horizontal overflow. +For a portaled select menu, target both elements: + +```tsx +visual: { target: '[data-component="select"], [role="listbox"]' }, +``` + +When backdrop and placement matter, capture the viewport instead: + +```tsx +visual: { capture: 'viewport' }, +``` + +Use either `target` or `capture: 'viewport'`. Both support an optional `viewport`. +Screenshot comparisons disable animations and hide the caret. Keep the baseline +PNGs with the component owning the source `stories/` folder; shared configuration, +serving and reporting stay in the tooling package. + +No pixel differences are allowed. If a case proves unstable, +fix its data, animation or readiness condition before considering a narrowly +justified tolerance. Avoid masking the component being tested. + +The Docker helper reads the image directly from the CI workflow's `visual-tests` +job. Change the pin there, there is no separate local image pin to update. +Renovate groups `playwright`, `@playwright/test`, `playwright-core` and the official +Playwright image into one **Playwright runtime** update PR. Review the proposed +versions together, regenerate baselines when needed and inspect the differences. + +[Playwright visual comparisons](https://playwright.dev/docs/test-snapshots) · +[Playwright Docker environment](https://playwright.dev/docs/docker) · +[Story workflows](./agent-workflows.md#storybook-stories-and-edge-cases) diff --git a/eslint.config.mjs b/eslint.config.mjs index 84ccd774f..5f30bd869 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -34,6 +34,10 @@ export default [ 'packages/uniform/vitest.config.mts', 'packages/veto/vitest.config.mts', 'packages/config/storybook-config/.storybook/preview.tsx', + 'packages/config/storybook-config/playwright.config.mjs', + 'packages/config/storybook-config/visual/docker.mjs', + 'packages/config/storybook-config/visual/getVisualTestImage.mjs', + 'packages/config/storybook-config/visual/serve.mjs', 'packages/megapixels/.storybook/main.ts', 'packages/megapixels/.storybook/preview.tsx', 'packages/pixels/.storybook/main.ts', @@ -42,6 +46,8 @@ export default [ 'packages/uniform/.storybook/preview.tsx', 'scripts/check-dependency-boundaries.mjs', 'scripts/check.mjs', + 'scripts/ci-snapshot-autofix.mjs', + 'scripts/ci-snapshot-autofix.test.mjs', 'scripts/verify-packages.mjs', ], maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 20, @@ -55,6 +61,9 @@ export default [ 'packages/*/.storybook/*.{ts,tsx}', 'packages/config/storybook-config/.storybook/*.{ts,tsx}', 'scripts/*.mjs', + 'packages/config/storybook-config/playwright.config.mjs', + 'packages/config/storybook-config/visual/*.mjs', + 'packages/config/storybook-config/visual/*.ts', ], rules: { // Storybook/Vitest configuration executes development-only packages. @@ -69,4 +78,11 @@ export default [ 'no-use-before-define': 'off', }, }, + { + files: ['**/stories/*.storySetup.tsx'], + rules: { + // shared story fixtures use the same development dependencies as CSF files + 'import-x/no-extraneous-dependencies': 'off', + }, + }, ]; diff --git a/package.json b/package.json index 24033f63e..d69bda12e 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,12 @@ "lint": "eslint ./packages", "lint:debug": "eslint --debug ./packages", "test": "FORCE_COLOR=1 vitest run --coverage $VITEST_EXTRA_OPTS", - "test:release": "node --test scripts/bootstrap-next-branch.test.mjs", - "test:storybook": "pnpm --filter @repo/storybook-config test:storybook", - "typecheck": "pnpm build", + "test:storybook": "node scripts/test.mjs storybook", + "test:unit": "pnpm test --project='!@repo/storybook-config*'", + "test:visual": "pnpm --filter @repo/storybook-config test:visual", + "test:visual:docker": "node packages/config/storybook-config/visual/docker.mjs", "turbo": "TURBO_TELEMETRY_DISABLED=1 FORCE_COLOR=1 turbo --cache-dir ./.turbo-cache --color", + "typecheck": "pnpm --filter @fuf-stack/pixels test:types && pnpm --filter @fuf-stack/uniform test:types && pnpm build", "verify:packages": "node scripts/verify-packages.mjs" }, "devDependencies": { @@ -31,7 +33,9 @@ "color2k": "2.0.4", "jsdom": "30.0.1", "tsx": "4.23.15", - "typescript": "6.0.3" + "typescript": "6.0.3", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3" }, "workspaces": [ "packages/*" diff --git a/packages/atelier/src/AtelierFrame/AtelierFrame.test.tsx b/packages/atelier/src/AtelierFrame/AtelierFrame.test.tsx index 6cc893487..702fade14 100644 --- a/packages/atelier/src/AtelierFrame/AtelierFrame.test.tsx +++ b/packages/atelier/src/AtelierFrame/AtelierFrame.test.tsx @@ -4,7 +4,7 @@ import storySnapshots from '@repo/storybook-config/story-snapshots'; import { fireEvent, render, screen } from '@testing-library/react'; import AtelierFrame from './AtelierFrame'; -import * as stories from './AtelierFrame.stories'; +import * as stories from './stories/AtelierFrame.stories'; describe('Story Snapshots', () => { storySnapshots(stories); diff --git a/packages/atelier/src/AtelierFrame/AtelierFrame.tsx b/packages/atelier/src/AtelierFrame/AtelierFrame.tsx index 82ccf150b..03847326a 100644 --- a/packages/atelier/src/AtelierFrame/AtelierFrame.tsx +++ b/packages/atelier/src/AtelierFrame/AtelierFrame.tsx @@ -205,7 +205,11 @@ const AtelierFrame = ({ }; return ( -
+
{withMobileDrawer ? ( diff --git a/packages/atelier/src/AtelierFrame/__snapshots__/AtelierFrame.test.tsx.snap b/packages/atelier/src/AtelierFrame/__snapshots__/AtelierFrame.test.tsx.snap index fc855f4ea..ea9b1c842 100644 --- a/packages/atelier/src/AtelierFrame/__snapshots__/AtelierFrame.test.tsx.snap +++ b/packages/atelier/src/AtelierFrame/__snapshots__/AtelierFrame.test.tsx.snap @@ -4,6 +4,7 @@ exports[`Story Snapshots > AdminView 1`] = `
AdminView 1`] = ` class="flex items-center gap-2 text-xs text-default-500" > + +
+ +
+
+ Projects +
+
+ Table renderer showing + 3 + rows +
+
+
+
+
+`; + +exports[`Story Snapshots > MobileLayout 1`] = ` +
+
+
+
+ + + fuf atelier + + + status: + authenticated + +
+
+
+
+
RowSelection 1`] = ` data-slot="td" >
Delivered @@ -6230,23 +6817,29 @@ exports[`Story Snapshots > RowSelection 1`] = ` class="flex justify-center" data-slot="selection-cell-content" > - - +
+ +
RowSelection 1`] = ` data-slot="td" >
Packing @@ -6315,23 +6911,29 @@ exports[`Story Snapshots > RowSelection 1`] = ` 1
-
- + + + + + ) : ( +
+ {flexRender(header.column.columnDef.header, header.getContext())} +
+ )} + + ); +}; + +export default DataTableHeaderCell; diff --git a/packages/megapixels/src/DataTable/subcomponents/DataTableHeaderRows.tsx b/packages/megapixels/src/DataTable/subcomponents/DataTableHeaderRows.tsx new file mode 100644 index 000000000..865d1615b --- /dev/null +++ b/packages/megapixels/src/DataTable/subcomponents/DataTableHeaderRows.tsx @@ -0,0 +1,49 @@ +import type { Table } from '@tanstack/react-table'; +import type { DataTableIcons } from '../DataTable'; +import type { DataTableClassNames } from '../dataTableVariants'; + +import DataTableHeaderCell from './DataTableHeaderCell'; + +interface DataTableHeaderRowsProps { + /** Resolved CSS class names */ + className: DataTableClassNames; + /** Sort indicator overrides */ + sortIcons?: DataTableIcons['sort']; + /** Table state and header groups */ + table: Table; + /** Uses flex layout for virtual columns */ + virtualized?: boolean; +} + +/** Column header groups and sorting controls for either table layout */ +const DataTableHeaderRows = ({ + className: classNames, + sortIcons = undefined, + table, + virtualized = false, +}: DataTableHeaderRowsProps) => { + return table.getHeaderGroups().map((headerGroup) => { + return ( + + {headerGroup.headers.map((header) => { + return ( + + ); + })} + + ); + }); +}; + +export default DataTableHeaderRows; diff --git a/packages/megapixels/src/DataTable/Subcomponents/DataTablePagination.tsx b/packages/megapixels/src/DataTable/subcomponents/DataTablePagination.tsx similarity index 90% rename from packages/megapixels/src/DataTable/Subcomponents/DataTablePagination.tsx rename to packages/megapixels/src/DataTable/subcomponents/DataTablePagination.tsx index 3877ac9a8..6c1ab998e 100644 --- a/packages/megapixels/src/DataTable/Subcomponents/DataTablePagination.tsx +++ b/packages/megapixels/src/DataTable/subcomponents/DataTablePagination.tsx @@ -34,23 +34,23 @@ const ChevronDownIcon = () => { }; export interface DataTablePaginationProps { - /** Slot-driven class names forwarded from DataTable variants. */ - classNames: PaginationClassNames; - /** Available rows-per-page options. */ + /** Slot-driven class names forwarded from DataTable variants */ + className: PaginationClassNames; + /** Available rows-per-page options */ pageSizeOptions: number[]; - /** Shows the rows-per-page selector when pagination is enabled. */ + /** Shows the rows-per-page selector when pagination is enabled */ showRowsPerPage: boolean; - /** Shows selected-row summary when row selection is enabled. */ + /** Shows selected-row summary when row selection is enabled */ showSelectionSummary: boolean; - /** TanStack table instance used for paging and counts. */ + /** TanStack table instance used for paging and counts */ table: Table; - /** Optional test id prefix used for control ids. */ + /** Optional test id prefix used for control ids */ testId?: string; } -/** Footer controls for paging, page size, and selected-row summary. */ +/** Footer controls for paging, page size, and selected-row summary */ const DataTablePagination = ({ - classNames, + className: classNames, pageSizeOptions, showRowsPerPage, showSelectionSummary, @@ -99,7 +99,7 @@ const DataTablePagination = ({ ariaLabel="Rows per page" items={pageSizeItems} onAction={(key) => { - // Keep page-size source of truth inside TanStack state. + // keep page-size source of truth inside TanStack state table.setPageSize(Number(key)); }} placement="top" diff --git a/packages/megapixels/src/DataTable/subcomponents/DataTableSearchInput.tsx b/packages/megapixels/src/DataTable/subcomponents/DataTableSearchInput.tsx new file mode 100644 index 000000000..83949cd8f --- /dev/null +++ b/packages/megapixels/src/DataTable/subcomponents/DataTableSearchInput.tsx @@ -0,0 +1,71 @@ +import type { Table } from '@tanstack/react-table'; +import type { ChangeEvent, RefObject } from 'react'; +import type { + DataTableInfiniteScrollFeature, + DataTableSearchField, +} from '../DataTable'; + +interface DataTableSearchInputProps { + /** CSS class name */ + className: string; + /** Server-side search configuration */ + infiniteScroll?: DataTableInfiniteScrollFeature; + /** Scroll viewport reset after a server-side search */ + scrollContainerRef: RefObject; + /** Column and placeholder for this search field */ + searchField: DataTableSearchField; + /** Number of configured search columns */ + searchFieldCount: number; + /** Table filter state */ + table: Table; + /** Test ID prefix for the owning table */ + testId?: string; +} + +/** Column search input that commits filters and resets server-side scrolling */ +const DataTableSearchInput = ({ + className, + infiniteScroll: resolvedInfiniteScroll = undefined, + scrollContainerRef, + searchFieldCount, + searchField, + table, + testId = undefined, +}: DataTableSearchInputProps) => { + const searchValue = + (table.getColumn(searchField.column)?.getFilterValue() as + string | undefined) ?? ''; + let searchTestId: string | undefined; + if (testId) { + searchTestId = + searchFieldCount === 1 + ? `${testId}-search` + : `${testId}-search-${searchField.column}`; + } + + // commit the column filter before requesting server-side results + const handleChange = (event: ChangeEvent) => { + const { value } = event.target; + table.getColumn(searchField.column)?.setFilterValue(value); + if (resolvedInfiniteScroll) { + resolvedInfiniteScroll.onSearchChange?.(searchField.column, value); + scrollContainerRef.current?.scrollTo({ + top: 0, + }); + } + }; + + return ( + + ); +}; + +export default DataTableSearchInput; diff --git a/packages/megapixels/src/DataTable/subcomponents/DataTableStateRows.tsx b/packages/megapixels/src/DataTable/subcomponents/DataTableStateRows.tsx new file mode 100644 index 000000000..dc776d244 --- /dev/null +++ b/packages/megapixels/src/DataTable/subcomponents/DataTableStateRows.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from 'react'; +import type { DataTableClassNames } from '../dataTableVariants'; + +interface DataTableStateRowsProps { + /** Resolved CSS class names */ + className: DataTableClassNames; + /** Content when there are no matching rows */ + emptyContent: ReactNode; + /** Shows the initial loading state */ + loading: boolean; + /** Content during initial loading */ + loadingContent: ReactNode; + /** Number of visible table rows */ + rowCount: number; + /** Number of columns spanned by status cells */ + visibleColumnCount: number; +} + +/** Full-width loading and empty rows without replacing the table structure */ +const DataTableStateRows = ({ + className: classNames, + emptyContent, + loading, + loadingContent, + rowCount, + visibleColumnCount, +}: DataTableStateRowsProps) => { + return ( + <> + {loading ? ( + + + {loadingContent} + + + ) : null} + {!loading && rowCount === 0 ? ( + + + {emptyContent} + + + ) : null} + + ); +}; + +export default DataTableStateRows; diff --git a/packages/megapixels/src/DataTable/subcomponents/DataTableSurface.tsx b/packages/megapixels/src/DataTable/subcomponents/DataTableSurface.tsx new file mode 100644 index 000000000..3c1d4c637 --- /dev/null +++ b/packages/megapixels/src/DataTable/subcomponents/DataTableSurface.tsx @@ -0,0 +1,167 @@ +import type { Table } from '@tanstack/react-table'; +import type { RefObject } from 'react'; +import type { + DataTableFeatures, + DataTableProps, + DataTableVirtualizationFeature, +} from '../DataTable'; +import type { DataTableClassNames } from '../dataTableVariants'; + +import { ScrollShadow } from '@fuf-stack/pixels/ScrollShadow'; + +import DataTableBodyRows from './DataTableBodyRows'; +import DataTableHeaderRows from './DataTableHeaderRows'; +import DataTableStateRows from './DataTableStateRows'; +import { DataTableVirtualBodyRows } from './DataTableVirtualBodyRows'; + +interface DataTableSurfaceProps extends Pick< + DataTableProps, + 'ariaLabel' | 'emptyContent' | 'icons' | 'loading' | 'loadingContent' +> { + /** Resolved CSS class names */ + className: DataTableClassNames; + /** Expanded detail and child-row configuration */ + expandableRows?: DataTableFeatures['expandableRows']; + /** Cursor-based page loading configuration */ + infiniteScroll?: DataTableFeatures['infiniteScroll']; + /** Scroll viewport shared with sorting and search */ + scrollContainerRef: RefObject; + /** Table state and visible rows */ + table: Table; + /** Virtual viewport configuration */ + virtualization?: DataTableVirtualizationFeature; + /** Uses a separate header and virtualized scroll body */ + virtualized: boolean; +} + +/** Semantic table surface with standard or virtualized row composition */ +const DataTableSurface = ({ + ariaLabel = undefined, + className: classNames, + emptyContent = undefined, + expandableRows: resolvedExpandableRows = undefined, + icons = undefined, + infiniteScroll: resolvedInfiniteScroll = undefined, + loading = false, + loadingContent = undefined, + scrollContainerRef, + table, + virtualization: virtualConfig = undefined, + virtualized, +}: DataTableSurfaceProps) => { + const tableRows = table.getRowModel().rows; + const visibleColumnCount = table.getVisibleLeafColumns().length; + + if (virtualized) { + return ( + <> + {/* Keep header outside the scroll container so body owns scrollbar. */} + + + + +
+ + + + + {loading ? null : ( + + )} + + +
+
+ + ); + } + + return ( + + + {/* Header groups come from TanStack and support sorting per column. */} + + + + {loading ? null : ( + + )} + {/* Loading and empty states reserve full table width via visible column span. */} + + +
+ ); +}; + +export default DataTableSurface; diff --git a/packages/megapixels/src/DataTable/subcomponents/DataTableToolbar.tsx b/packages/megapixels/src/DataTable/subcomponents/DataTableToolbar.tsx new file mode 100644 index 000000000..d2467ec03 --- /dev/null +++ b/packages/megapixels/src/DataTable/subcomponents/DataTableToolbar.tsx @@ -0,0 +1,93 @@ +import type { Table } from '@tanstack/react-table'; +import type { RefObject } from 'react'; +import type { DataTableProps, DataTableSearchField } from '../DataTable'; +import type { DataTableClassNames } from '../dataTableVariants'; + +import DataTableSearchInput from './DataTableSearchInput'; +import DataTableViewOptions from './DataTableViewOptions'; + +interface DataTableToolbarProps extends Pick< + DataTableProps, + 'icons' | 'testId' | 'toolbarContent' +> { + /** Resolved CSS class names */ + className: DataTableClassNames; + /** Enables column visibility controls */ + columnVisibility: boolean; + /** Server-side search configuration */ + infiniteScroll?: NonNullable< + DataTableProps['features'] + >['infiniteScroll']; + /** Scroll viewport reset after a server-side search */ + scrollContainerRef: RefObject; + /** Configured search columns */ + searchFields: DataTableSearchField[]; + /** Table filter and visibility state */ + table: Table; +} + +/** Search, custom actions and column visibility controls above the table */ +const DataTableToolbar = ({ + className: classNames, + columnVisibility: resolvedEnableColumnVisibility, + icons = undefined, + infiniteScroll: resolvedInfiniteScroll = undefined, + scrollContainerRef, + searchFields: resolvedSearchFields, + table, + testId = undefined, + toolbarContent = undefined, +}: DataTableToolbarProps) => { + const showToolbar = + resolvedSearchFields.length > 0 || + resolvedEnableColumnVisibility || + Boolean(toolbarContent); + + return ( + <> + {/* Optional toolbar area for built-in search, custom content, and column visibility controls. */} + {showToolbar ? ( +
+ {toolbarContent ? ( +
+ {toolbarContent} +
+ ) : null} + {resolvedSearchFields.map((searchField) => { + return ( + + ); + })} + + {resolvedEnableColumnVisibility ? ( + + ) : null} +
+ ) : null} + + ); +}; + +export default DataTableToolbar; diff --git a/packages/megapixels/src/DataTable/Subcomponents/DataTableViewOptions.tsx b/packages/megapixels/src/DataTable/subcomponents/DataTableViewOptions.tsx similarity index 82% rename from packages/megapixels/src/DataTable/Subcomponents/DataTableViewOptions.tsx rename to packages/megapixels/src/DataTable/subcomponents/DataTableViewOptions.tsx index 2efa1761d..935a7062c 100644 --- a/packages/megapixels/src/DataTable/Subcomponents/DataTableViewOptions.tsx +++ b/packages/megapixels/src/DataTable/subcomponents/DataTableViewOptions.tsx @@ -21,32 +21,32 @@ interface ViewOptionsClassNames { } export interface DataTableViewOptionsProps { - /** Slot-driven class names forwarded from DataTable variants. */ - classNames: ViewOptionsClassNames; - /** TanStack table instance used to enumerate/toggle columns. */ + /** Slot-driven class names forwarded from DataTable variants */ + className: ViewOptionsClassNames; + /** TanStack table instance used to enumerate/toggle columns */ table: Table; - /** Optional test id prefix used for the trigger. */ + /** Optional test id prefix used for the trigger */ testId?: string; - /** Icon shown in the trigger; pass `false` to hide. */ + /** Icon shown in the trigger; pass `false` to hide */ triggerIcon?: ReactNode | false; - /** Icon shown for visible columns; pass `false` to hide. */ + /** Icon shown for visible columns; pass `false` to hide */ visibleIcon?: ReactNode | false; } -/** Dropdown for toggling table column visibility. */ +/** Dropdown for toggling table column visibility */ const DataTableViewOptions = ({ - classNames, + className: classNames, table, triggerIcon = , visibleIcon = '✓', testId = undefined, }: DataTableViewOptionsProps) => { - // Only show columns TanStack allows to be hidden. + // only show columns TanStack allows to be hidden const hideableColumns = table.getAllColumns().filter((column) => { return column.getCanHide(); }); - // Fast lookup for onAction callbacks from menu key -> column instance. + // fast lookup for onAction callbacks from menu key -> column instance const columnsById = new Map( hideableColumns.map((column) => { return [column.id, column] as const; diff --git a/packages/megapixels/src/DataTable/Subcomponents/DataTableVirtualBodyRows.tsx b/packages/megapixels/src/DataTable/subcomponents/DataTableVirtualBodyRows.tsx similarity index 85% rename from packages/megapixels/src/DataTable/Subcomponents/DataTableVirtualBodyRows.tsx rename to packages/megapixels/src/DataTable/subcomponents/DataTableVirtualBodyRows.tsx index e71a35fb3..5fde3dc7c 100644 --- a/packages/megapixels/src/DataTable/Subcomponents/DataTableVirtualBodyRows.tsx +++ b/packages/megapixels/src/DataTable/subcomponents/DataTableVirtualBodyRows.tsx @@ -7,40 +7,40 @@ import type { DataTableInfiniteScrollFeature, DataTableVirtualizationFeature, } from '../DataTable'; -import type { DataTableClassNames } from './DataTableCells'; +import type { DataTableClassNames } from '../dataTableVariants'; import { cn } from '@fuf-stack/pixel-utils'; import { Button } from '@fuf-stack/pixels/Button'; import { useDataTableVirtualizer } from '../hooks/useDataTableVirtualizer'; +import DataTableBodyCell from './DataTableBodyCell'; import { - DataTableBodyCell, getColumnKind, getVirtualCellStyle, virtualFullWidthCellStyle, virtualFullWidthRowStyle, -} from './DataTableCells'; +} from './dataTableCellHelpers'; interface DataTableVirtualBodyRowsProps { - /** Slot classes resolved by `dataTableVariants`. */ - classNames: DataTableClassNames; - /** Optional expanded-row behavior and content renderer. */ + /** Slot classes resolved by `dataTableVariants` */ + className: DataTableClassNames; + /** Optional expanded-row behavior and content renderer */ expandableRows?: DataTableExpandableRowsFeature; - /** Optional cursor-based infinite-scroll state/callbacks. */ + /** Optional cursor-based infinite-scroll state/callbacks */ infiniteScroll?: DataTableInfiniteScrollFeature; - /** Content shown in the load-more row while next page is fetching. */ + /** Content shown in the load-more row while next page is fetching */ loadingMoreContent?: ReactNode; - /** Retry button label/content when load-more fails. */ + /** Retry button label/content when load-more fails */ retryContent?: ReactNode; - /** TanStack rows currently loaded in the table model. */ + /** TanStack rows currently loaded in the table model */ rows: Row[]; - /** Scroll container consumed by the shared virtualizer hook. */ + /** Scroll container consumed by the shared virtualizer hook */ scrollElementRef?: RefObject; - /** Virtualizer options (maxHeight, estimate, dynamic sizing, overscan). */ + /** Virtualizer options (maxHeight, estimate, dynamic sizing, overscan) */ virtualization?: DataTableVirtualizationFeature; - /** Enables virtualized row/cell sizing behavior. */ + /** Enables virtualized row/cell sizing behavior */ virtualized: boolean; - /** Current visible column count for full-width helper rows. */ + /** Current visible column count for full-width helper rows */ visibleColumnCount: number; } @@ -51,7 +51,7 @@ interface DataTableVirtualBodyRowsProps { * positioned `` elements and delegates load-more retry handling. */ export const DataTableVirtualBodyRows = ({ - classNames, + className: classNames, expandableRows = undefined, infiniteScroll = undefined, loadingMoreContent = 'Loading more...', @@ -62,7 +62,7 @@ export const DataTableVirtualBodyRows = ({ virtualization = undefined, visibleColumnCount, }: DataTableVirtualBodyRowsProps) => { - // Shared hook flattens rows + expanded rows + placeholders + sentinel. + // shared hook flattens rows + expanded rows + placeholders + sentinel const { getItem, measureElement, retry, totalSize, virtualItems } = useDataTableVirtualizer({ expandableRows, @@ -84,7 +84,7 @@ export const DataTableVirtualBodyRows = ({ {virtualItems.map((virtualItem) => { - // Resolve the view model item backing this virtual row index. + // resolve the view model item backing this virtual row index const item = getItem(virtualItem); if (!item) { return null; @@ -111,7 +111,7 @@ export const DataTableVirtualBodyRows = ({ ); @@ -121,7 +121,7 @@ export const DataTableVirtualBodyRows = ({ } if (item.kind === 'expanded') { - // Expanded content consumes full table width as a dedicated row. + // expanded content consumes full table width as a dedicated row return ( ({ } if (item.kind === 'placeholder') { - // Placeholder rows reserve scroll height in total-count mode. + // placeholder rows reserve scroll height in total-count mode return ( { + if (columnId === EXPANSION_COLUMN_ID) { + return 'expansion'; + } + if (columnId === SELECTION_COLUMN_ID) { + return 'selection'; + } + return 'data'; +}; + +export const getCellDataSlot = (columnKind: DataTableColumnKind) => { + if (columnKind === 'expansion') { + return 'expansion-cell'; + } + if (columnKind === 'selection') { + return 'selection-cell'; + } + return 'td'; +}; + +// Header slots mirror body slots for control columns and use `th` for data columns. +export const getHeaderDataSlot = (columnKind: DataTableColumnKind) => { + if (columnKind === 'data') { + return 'th'; + } + return getCellDataSlot(columnKind); +}; + +// Selection/expansion controls use distinct inner wrappers for styling hooks. +export const getControlCellContentDataSlot = ( + columnKind: DataTableColumnKind, +) => { + if (columnKind === 'expansion') { + return 'expansion-cell-content'; + } + return 'selection-cell-content'; +}; + +/** + * Flex sizing for cells in virtualized mode. + * + * Because virtual rows are absolutely positioned, we cannot rely on native + * table layout for column width sync; header/body cells must apply matching + * explicit sizing rules. + */ +export const getVirtualCellStyle = ( + columnKind: DataTableColumnKind, + size: number, +): CSSProperties => { + if (columnKind === 'data') { + return { flex: `1 1 ${size}px`, minWidth: 0, width: size }; + } + return { flex: `0 0 ${size}px`, width: size }; +}; + +/** Shared row positioning style for full-width virtual rows. */ +export const virtualFullWidthRowStyle = (start: number): CSSProperties => { + return { + display: 'flex', + position: 'absolute', + transform: `translateY(${start}px)`, + width: '100%', + }; +}; + +/** Shared single-cell style for full-width virtual rows. */ +export const virtualFullWidthCellStyle: CSSProperties = { + flex: '1 1 auto', + width: '100%', +}; diff --git a/packages/megapixels/src/Filter/Filter.test.tsx b/packages/megapixels/src/Filter/Filter.test.tsx index d1ab1c0e1..d6855e2e6 100644 --- a/packages/megapixels/src/Filter/Filter.test.tsx +++ b/packages/megapixels/src/Filter/Filter.test.tsx @@ -4,10 +4,14 @@ import storySnapshots from '@repo/storybook-config/story-snapshots'; import { fireEvent, render, waitFor, within } from '@testing-library/react'; import Filter, { filters as f } from '.'; -import * as stories from './Filter.stories'; +import * as edgeCases from './stories/Filter.edge-cases.stories'; +import * as interactions from './stories/Filter.interactions.stories'; +import * as stories from './stories/Filter.stories'; describe('Story Snapshots', () => { storySnapshots(stories); + storySnapshots(interactions); + storySnapshots(edgeCases); }); describe('Coverage', () => { diff --git a/packages/megapixels/src/Filter/Filter.tsx b/packages/megapixels/src/Filter/Filter.tsx index 085af7805..69ebc6211 100644 --- a/packages/megapixels/src/Filter/Filter.tsx +++ b/packages/megapixels/src/Filter/Filter.tsx @@ -1,58 +1,20 @@ -import type { TVClassName } from '@fuf-stack/pixel-utils'; import type { VetoInput } from '@fuf-stack/veto'; import type { ReactNode } from 'react'; import type { FiltersConfiguration } from './filters/types'; -import type { SearchConfiguration } from './Subcomponents/SearchInput'; +import type { ClassName } from './filterVariants'; +import type { SearchConfiguration } from './subcomponents/SearchInput'; import createDebug from 'debug'; -import { tv, variantsToClassNames } from '@fuf-stack/pixel-utils'; -import Form from '@fuf-stack/uniform/Form'; +import { useStyling } from '@fuf-stack/pixels/StylingProvider'; +import { filterVariants } from './filterVariants'; import { useFilterValidation } from './hooks/useFilterValidation'; -import ActiveFilters from './Subcomponents/ActiveFilters'; -import AddFilterMenu from './Subcomponents/AddFilterMenu'; -import FilterModal from './Subcomponents/FilterModal'; -import { FiltersContextProvider } from './Subcomponents/FiltersContext'; -import SearchInput from './Subcomponents/SearchInput'; +import FilterControl from './subcomponents/FilterControl'; -const debug = createDebug('megapixels:Filter'); - -// filter styling variants -export const filterVariants = tv({ - slots: { - // outer wrapper - base: 'flex flex-auto flex-col', - // add filter menu trigger button - addFilterMenuButton: '', - // add filter menu item - addFilterMenuItem: '', - // active filter label - activeFilterLabel: 'h-8 cursor-pointer rounded-md dark:text-foreground', - // filter modal body - filterModalBody: '', - // filter modal header - filterModalHeader: 'flex items-center gap-3 text-default-700', - // filter modal footer - filterModalFooter: 'justify-between', - // form element - form: 'mb-3 flex flex-wrap gap-3', - // search input field - searchInput: '', - // search input wrapper (inner control) - searchInputWrapper: '', - // search motion container - searchMotionDiv: 'flex w-72 gap-2', - // search show button - searchShowButton: '', - // search submit button - searchSubmitButton: '', - // search wrapper - searchWrapper: 'flex items-center', - }, -}); +export { filterVariants } from './filterVariants'; -type ClassName = TVClassName; +const debug = createDebug('megapixels:Filter'); export interface FilterValues { filter?: string | Record | null; @@ -64,23 +26,6 @@ export type FilterChildRenderFn = (values: { filter?: Record; }) => ReactNode; -/** - * Filter - * - * Controlled, form-driven filter UI. - * - * Responsibilities - * - Derives initial form values from the controlled `values` prop - * - Builds a composite validation schema from the filter registry (and optional search) - * - Exposes ergonomic UI: active filters list, add/remove actions, and per-filter modal - * - Commits changes by invoking the controlled `onChange` callback on submit - * - * Structure - * - Owns an ex-forms `Form` that wraps the entire filter experience - * - Optionally renders a search input bound to the `search` field - * - Renders ActiveFilters, AddFilterMenu, and FilterModal inside a shared context - * - Optionally renders children as a render-prop with the resolved `values` - */ export interface FilterProps { /** Optional render-prop that receives the resolved, controlled values */ children?: FilterChildRenderFn; @@ -98,7 +43,7 @@ export interface FilterProps { }; /** Disables the add-filter menu and modal open/close animations */ disableAnimation?: boolean; - /** ex-forms form instance name. Defaults to "filterComponentForm". */ + /** Uniform form instance name. Defaults to "filterComponentForm". */ formName?: string; /** Controlled setter invoked on submit with the next canonical values */ onChange: (nextValues: FilterValues) => void; @@ -113,9 +58,14 @@ export interface FilterProps { } /** - * Renders the filter UI bound to a single ex-forms `Form`. - * The form is the source of truth during user interaction; the committed - * state is controlled by the parent via `values`/`onChange`. + * Configurable search and filter controls for narrowing a result set + * + * Shows active filters with actions to add, edit and remove them using the + * configured filter registry and validation schemas + * + * Edits stay in the form until submission, which commits changes through + * `onChange`. The parent controls committed state through `values` + * Optional render-prop children receive the resolved search and filter values */ const Filter = ({ children = undefined, @@ -127,13 +77,9 @@ const Filter = ({ portalContainer = undefined, values, }: FilterProps) => { - // Submit handler: map form state back into the controlled `values` shape - const handleSubmit = (nextValues: Record) => { - debug('handleSubmit', { nextValues }); - onChange(nextValues); - }; + const styling = useStyling(); - // Build validation schema for all configured filters (and optional search) + // combine filter schemas with the optional search field const validation = useFilterValidation( config.filters, Boolean(config.search), @@ -147,8 +93,8 @@ const Filter = ({ } = validation.validate(values as VetoInput); // classNames from slots - const variants = filterVariants(); - const classNames = variantsToClassNames(variants, className, 'base'); + const variants = styling.resolve(filterVariants); + const classNames = styling.variantsToClassNames(variants, className, 'base'); debug('render', { props: { config, values }, @@ -156,71 +102,18 @@ const Filter = ({ }); return ( -
- {/* - Uniform Form wrapper - - initialValues derive from controlled props (with optional defaults) - - validation is built from the registry for all configured filters - - onSubmit maps form values back into values/onChange - */} - - {/* Render search if search config is provided */} - {config.search ? ( - - ) : null} - {/* - FiltersContextProvider exposes a minimal API for the UI layer: - - activeFilters/unusedFilters by name - - helpers to get merged config, value, components, and field names - - methods to add/remove filters and show/close the modal - */} - - - - - - - {/* Children can consume derived search string and parsed filter object */} - {children?.(valuesValidated ?? {})} -
+ + {children} + ); }; diff --git a/packages/megapixels/src/Filter/README.md b/packages/megapixels/src/Filter/README.md new file mode 100644 index 000000000..7095a85ec --- /dev/null +++ b/packages/megapixels/src/Filter/README.md @@ -0,0 +1,18 @@ +# Filter + +Controlled search and filter editing with active labels and per-filter modals. + +```tsx + +``` + +`Filter` binds the composite validation schema and controlled values. +`FilterControl` composes the form, search, active filters and modal actions. +The portaled modal submit button targets the form by its existing ID. +Recipes live in `filterVariants.ts`, internal components in `subcomponents/`. + +Normal, interaction and edge-case stories cover configured filters, editing, +removal and an empty filter registry. + +[Props](./Filter.tsx) · [Stories](./stories/Filter.stories.tsx) · +[Styling](../../../../docs/styling.md) diff --git a/packages/megapixels/src/Filter/Subcomponents/ActiveFilters.tsx b/packages/megapixels/src/Filter/Subcomponents/ActiveFilters.tsx deleted file mode 100644 index 9bc60de9f..000000000 --- a/packages/megapixels/src/Filter/Subcomponents/ActiveFilters.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Label } from '@fuf-stack/pixels/Label'; - -import { useFilters } from './FiltersContext'; - -/** - * ActiveFilters - * - * Shows the list of currently applied filters as clickable chips that open - * the edit modal. Each chip can be removed via its close action. - */ -interface ActiveFiltersProps { - /** CSS class name to apply to each label */ - className?: string; -} - -const ActiveFilters = ({ className = undefined }: ActiveFiltersProps) => { - const { - activeFilters, - getFilterValueByName, - getFilterInstanceByName, - hasError, - removeFilter, - showFilterModal, - } = useFilters(); - return ( - <> - {activeFilters.map((name) => { - const instance = getFilterInstanceByName(name); - const value = getFilterValueByName(name); - - // get the display component from the instance - const DisplayComponent = instance.components.Display; - - return ( - - ); - })} - - ); -}; - -export default ActiveFilters; diff --git a/packages/megapixels/src/Filter/__snapshots__/Filter.test.tsx.snap b/packages/megapixels/src/Filter/__snapshots__/Filter.test.tsx.snap index 01b8366ba..ab4ee2f5b 100644 --- a/packages/megapixels/src/Filter/__snapshots__/Filter.test.tsx.snap +++ b/packages/megapixels/src/Filter/__snapshots__/Filter.test.tsx.snap @@ -4,9 +4,11 @@ exports[`Story Snapshots > AddFilterInteraction 1`] = `
AddFilterInteraction 1`] = ` name="filterComponentForm" novalidate="" > -
- + + + +
- - + +
-
- CheckboxesWithReactNodeLabels 1`] = ` - - + +
- + + + +
+ + + + +`; + exports[`Story Snapshots > FiltersOnly 1`] = `
FiltersOnly 1`] = ` aria-expanded="false" aria-haspopup="true" aria-label="Add Filter" - class="group relative inline-flex items-center justify-center box-border appearance-none select-none whitespace-nowrap font-normal overflow-hidden tap-highlight-transparent transform-gpu data-[pressed=true]:scale-[0.97] cursor-pointer outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2 border-medium px-3 min-w-16 h-8 text-tiny gap-2 rounded-small [&>svg]:max-w-[theme(spacing.8)] transition-transform-colors-opacity motion-reduce:transition-none bg-transparent border-default text-foreground data-[hover=true]:opacity-hover aria-expanded:scale-[0.97] aria-expanded:opacity-70 subpixel-antialiased z-auto" + class="group tap-highlight-transparent data-[hover=true]:opacity-hover group tap-highlight-transparent data-[hover=true]:opacity-hover group relative box-border inline-flex items-center justify-center select-none appearance-none whitespace-nowrap font-normal transform-gpu cursor-pointer overflow-hidden tap-highlight-transparent data-[pressed=true]:scale-[0.97] outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-offset-2 data-[focus-visible=true]:outline-focus border-medium h-8 min-w-16 gap-2 rounded-small px-3 text-tiny [&>svg]:max-w-[theme(spacing.8)] transition-transform-colors-opacity motion-reduce:transition-none border-default bg-transparent text-foreground data-[hover=true]:opacity-hover z-auto subpixel-antialiased aria-expanded:!scale-[0.97] aria-expanded:!opacity-70" + data-component="button" + data-rac="" data-react-aria-pressable="true" data-slot="trigger" data-testid="add_filter_button" @@ -357,13 +443,86 @@ exports[`Story Snapshots > FiltersOnly 1`] = `
`; +exports[`Story Snapshots > NoConfiguredFilters 1`] = ` +
+
+ +
+
+ + + +
+
+
+
+`; + exports[`Story Snapshots > WithCustomStyles 1`] = `
WithCustomStyles 1`] = ` style="opacity: 1;" >
-
- - +
+
-
- WithCustomStyles 1`] = ` 🤖 - - + +
- - +
+
-
- WithCustomStyles 1`] = ` 🍿 - - + +
- + + + +
-
+ + + - - + +
-
- WithInitialValue 1`] = ` 😊 - - + +
- - +
+
-
- WithInitialValue 1`] = ` 🍫 - - + +
- + + + +
+ + ); + })} + + ); +}; + +export default ActiveFilters; diff --git a/packages/megapixels/src/Filter/Subcomponents/AddFilterMenu.tsx b/packages/megapixels/src/Filter/subcomponents/AddFilterMenu.tsx similarity index 97% rename from packages/megapixels/src/Filter/Subcomponents/AddFilterMenu.tsx rename to packages/megapixels/src/Filter/subcomponents/AddFilterMenu.tsx index 7270126d8..80e4de5b3 100644 --- a/packages/megapixels/src/Filter/Subcomponents/AddFilterMenu.tsx +++ b/packages/megapixels/src/Filter/subcomponents/AddFilterMenu.tsx @@ -6,7 +6,7 @@ import { useFilters } from './FiltersContext'; interface AddFilterMenuProps { /** CSS class name map for slots */ - classNames?: Partial<{ + className?: Partial<{ addFilterMenuItem: string; addFilterMenuButton: string; }>; @@ -23,7 +23,7 @@ interface AddFilterMenuProps { * item triggers the parent to seed a default value and open the modal. */ const AddFilterMenu = ({ - classNames = {}, + className: classNames = {}, disableAnimation = false, portalContainer = undefined, }: AddFilterMenuProps) => { diff --git a/packages/megapixels/src/Filter/subcomponents/FilterControl.tsx b/packages/megapixels/src/Filter/subcomponents/FilterControl.tsx new file mode 100644 index 000000000..beeb82d34 --- /dev/null +++ b/packages/megapixels/src/Filter/subcomponents/FilterControl.tsx @@ -0,0 +1,103 @@ +import type { FilterChildRenderFn, FilterProps } from '../Filter'; +import type { FilterClassNames } from '../filterVariants'; +import type { useFilterValidation } from '../hooks/useFilterValidation'; + +import createDebug from 'debug'; + +import Form from '@fuf-stack/uniform/Form'; + +import ActiveFilters from './ActiveFilters'; +import AddFilterMenu from './AddFilterMenu'; +import FilterModal from './FilterModal'; +import { FiltersContextProvider } from './FiltersContext'; +import SearchInput from './SearchInput'; + +const debug = createDebug('megapixels:Filter'); + +interface FilterControlProps extends Omit { + /** Resolved CSS class names */ + className: FilterClassNames; + /** Combined filter and search schema */ + validation: ReturnType; + /** Parsed values used to initialize the form and render children */ + values: Parameters[0]; +} + +/** Search, active filter actions and modal editing inside their bound form */ +const FilterControl = ({ + children = undefined, + className: classNames, + config, + disableAnimation = undefined, + formName = undefined, + onChange, + portalContainer = undefined, + validation, + values, +}: FilterControlProps) => { + // commit validated form values to the controlled owner + const handleSubmit = (nextValues: Record) => { + debug('handleSubmit', { nextValues }); + onChange(nextValues); + }; + + return ( +
+ {/* bound search and filter form */} + + {/* optional search */} + {config.search ? ( + + ) : null} + {/* active filters, add menu and modal editing */} + + + + + + + {/* controlled results */} + {children?.(values ?? {})} +
+ ); +}; + +export default FilterControl; diff --git a/packages/megapixels/src/Filter/Subcomponents/FilterModal.tsx b/packages/megapixels/src/Filter/subcomponents/FilterModal.tsx similarity index 89% rename from packages/megapixels/src/Filter/Subcomponents/FilterModal.tsx rename to packages/megapixels/src/Filter/subcomponents/FilterModal.tsx index bd01d59d0..8a70c4c9c 100644 --- a/packages/megapixels/src/Filter/Subcomponents/FilterModal.tsx +++ b/packages/megapixels/src/Filter/subcomponents/FilterModal.tsx @@ -8,7 +8,8 @@ import SubmitButton from '@fuf-stack/uniform/SubmitButton'; import { useFilters } from './FiltersContext'; interface FilterModalProps { - classNames?: Partial<{ + /** CSS class name */ + className?: Partial<{ header: string; footer: string; body: string; @@ -23,8 +24,9 @@ interface FilterModalProps { remoteFormId?: string; } +/** Modal editor for the active filter and its apply/remove actions */ const FilterModal = ({ - classNames = {}, + className: classNames = {}, disableAnimation = false, portalContainer = undefined, remoteFormId = undefined, @@ -48,6 +50,11 @@ const FilterModal = ({ // get the form component from the instance const FormComponent = instance.components.Form; + // removing the edited filter also closes its modal + const handleRemove = () => { + removeFilter(modalFilterName); + }; + return ( { - removeFilter(modalFilterName); - }} + onClick={handleRemove} testId="remove_filter_button" variant="flat" > diff --git a/packages/megapixels/src/Filter/Subcomponents/FiltersContext.test.tsx b/packages/megapixels/src/Filter/subcomponents/FiltersContext.test.tsx similarity index 100% rename from packages/megapixels/src/Filter/Subcomponents/FiltersContext.test.tsx rename to packages/megapixels/src/Filter/subcomponents/FiltersContext.test.tsx diff --git a/packages/megapixels/src/Filter/Subcomponents/FiltersContext.tsx b/packages/megapixels/src/Filter/subcomponents/FiltersContext.tsx similarity index 94% rename from packages/megapixels/src/Filter/Subcomponents/FiltersContext.tsx rename to packages/megapixels/src/Filter/subcomponents/FiltersContext.tsx index 74f488d3f..aabf4469c 100644 --- a/packages/megapixels/src/Filter/Subcomponents/FiltersContext.tsx +++ b/packages/megapixels/src/Filter/subcomponents/FiltersContext.tsx @@ -16,9 +16,9 @@ import { useFormContext } from '@fuf-stack/uniform/hooks/useFormContext'; interface FiltersContextValue { /** Active filters (names only) */ activeFilters: string[]; - /** Seed default value and open modal for a filter usage. */ + /** Seed default value and open modal for a filter usage */ addFilter: (name: string) => void; - /** Close the modal. */ + /** Close the modal */ closeFilterModal: () => void; /** Build fully-qualified form field path for a filter name */ getFilterFormFieldName: (name: string) => string; @@ -26,13 +26,13 @@ interface FiltersContextValue { getFilterInstanceByName: (name: string) => FilterInstance; /** Get current form value for a given filter name */ getFilterValueByName: (name: string) => unknown; - /** Validation helper for a specific filter field. */ + /** Validation helper for a specific filter field */ hasError: (name: string) => boolean; /** Name of the current filter that has its modal open */ modalFilterName: string | undefined; - /** Remove a filter from the form. */ + /** Remove a filter from the form */ removeFilter: (name: string) => void; - /** Open the modal for a given filter name. */ + /** Open the modal for a given filter name */ showFilterModal: (name: string) => void; /** Filters that are not active (names only) */ unusedFilters: string[]; @@ -105,7 +105,7 @@ export const FiltersContextProvider = ({ previousValue: unknown; } | null>(null); - // Read current filter values from the form as the live edit buffer + // read current filter values from the form as the live edit buffer const filterValue = watch('filter', {}); /** @@ -130,7 +130,7 @@ export const FiltersContextProvider = ({ [filterValue], ); - /** Open the filter edit modal for the given filter name. */ + /** Open the filter edit modal for the given filter name */ const showFilterModal = useCallback( (name: string) => { const prev = getFilterValueByName(name); @@ -143,7 +143,7 @@ export const FiltersContextProvider = ({ [getFilterValueByName], ); - /** Close the filter edit modal. Rollback un-applied edits to controlled state. */ + /** Close the filter edit modal. Rollback un-applied edits to controlled state */ const closeFilterModal = useCallback(() => { if (currentModalFilter?.name) { const fieldName = getFilterFormFieldName(currentModalFilter.name); @@ -172,7 +172,7 @@ export const FiltersContextProvider = ({ formState.submitCount !== lastSubmitCountRef.current && formState.isSubmitSuccessful ) { - // On successful submit, close without rollback + // on successful submit, close without rollback setCurrentModalFilter(null); } lastSubmitCountRef.current = formState.submitCount; @@ -192,8 +192,8 @@ export const FiltersContextProvider = ({ const activeFilters = useMemo(() => { return config .filter((f) => { - // Keep compatibility with the shared Node 16.0 lint target, which - // predates Object.hasOwn. + // keep compatibility with the shared Node 16.0 lint target, which + // predates Object.hasOwn // eslint-disable-next-line prefer-object-has-own return Object.prototype.hasOwnProperty.call(filterValue ?? {}, f.name); }) @@ -267,7 +267,7 @@ export const FiltersContextProvider = ({ unregister(getFilterFormFieldName(name)); // close filter modal if open if (currentModalFilter?.name === name) { - // Explicit removal: close without rollback + // explicit removal: close without rollback setCurrentModalFilter(null); } // trigger form submit (to update filter state) diff --git a/packages/megapixels/src/Filter/Subcomponents/SearchInput.tsx b/packages/megapixels/src/Filter/subcomponents/SearchInput.tsx similarity index 79% rename from packages/megapixels/src/Filter/Subcomponents/SearchInput.tsx rename to packages/megapixels/src/Filter/subcomponents/SearchInput.tsx index 0a7ff1838..d873f90b5 100644 --- a/packages/megapixels/src/Filter/Subcomponents/SearchInput.tsx +++ b/packages/megapixels/src/Filter/subcomponents/SearchInput.tsx @@ -16,7 +16,7 @@ export type SearchConfiguration = interface SearchInputProps { /** Slots class names passed from parent variants */ - classNames?: Partial<{ + className?: Partial<{ searchWrapper: string; searchShowButton: string; searchMotionDiv: string; @@ -34,16 +34,36 @@ interface SearchInputProps { * By default renders only a search button. When clicked, the text input animates in * and a trailing submit button is shown. */ -const SearchInput = ({ classNames = {}, config }: SearchInputProps) => { +const SearchInput = ({ + className: classNames = {}, + config, +}: SearchInputProps) => { const { formState, setFocus, triggerSubmit } = useFormContext(); - // Auto-open if there is an initial or externally set search value + // auto-open if there is an initial or externally set search value const isInitiallyVisible = !!formState?.defaultValues?.search; const [isVisible, setIsVisible] = useState(isInitiallyVisible); const placeholder = typeof config === 'object' ? config.placeholder : undefined; + // reveal the search field before moving focus into it + const handleShow = () => { + setIsVisible(true); + }; + + // wait until the reveal finishes so focus does not interrupt the animation + const handleAnimationComplete = () => { + if (!isInitiallyVisible) { + setFocus('search'); + } + }; + + // clearing commits the empty search immediately + const handleClear = () => { + triggerSubmit(); + }; + return (
{!isVisible && ( @@ -51,9 +71,7 @@ const SearchInput = ({ classNames = {}, config }: SearchInputProps) => { ariaLabel="Show search input" className={classNames.searchShowButton} icon={} - onClick={() => { - setIsVisible(true); - }} + onClick={handleShow} size="sm" testId="show_search_input_button" variant="bordered" @@ -66,12 +84,7 @@ const SearchInput = ({ classNames = {}, config }: SearchInputProps) => { className={classNames.searchMotionDiv} // if the input was not initially visible, animate in initial={!isInitiallyVisible ? { opacity: 0.5 } : false} - onAnimationComplete={() => { - // if the input was not initially visible, focus it - if (!isInitiallyVisible) { - setFocus('search'); - } - }} + onAnimationComplete={handleAnimationComplete} transition={{ duration: 0.3, ease: 'circOut', @@ -88,9 +101,7 @@ const SearchInput = ({ classNames = {}, config }: SearchInputProps) => { size="sm" name="search" // submit on clear - onClear={() => { - triggerSubmit(); - }} + onClear={handleClear} /> { - // Stacking layers (see notification.tsx for the modal side): - // 1. normal app-level modal — HeroUI default z-50 - // 2. notification / Toaster — z-60 (this), so notifications appear ABOVE - // a normal modal that is already open - // 3. modal opened FROM a notification — z-70 (set in notification.tsx), - // so it appears above the notification again - // Sonner defaults the Toaster to z-index 999999999, so we override it here. - // `--width` is passed inline because Sonner sets its default `--width` inline - // on the same element, which beats class-based overrides. + // toasts sit above normal modals, notification-owned modals use z-70 + // Sonner defines width inline, so its override must also be inline const toasterStyle = { zIndex: 60, ...(width != null && { '--width': typeof width === 'number' ? `${width}px` : width, }), } as CSSProperties; + return ( <> diff --git a/packages/megapixels/src/Notification/README.md b/packages/megapixels/src/Notification/README.md new file mode 100644 index 000000000..635e62e0d --- /dev/null +++ b/packages/megapixels/src/Notification/README.md @@ -0,0 +1,19 @@ +# Notification + +Toast notifications with optional actions and related modal content. + +```tsx +; + +notification.success('Your changes are ready', { title: 'Saved' }); +``` + +Mount `NotificationHost` once. It composes the Pixels Toaster and ModalHost +without adding a DOM wrapper. Its width and stacking styles stay inline because +they override Sonner's runtime styles. It does not need a control subcomponent +or an additional variants recipe. + +Normal stories cover variants and actions, edge-case stories verify notification +and modal stacking in both directions. + +[API](./notification.tsx) · [Stories](./stories/notification.stories.tsx) diff --git a/packages/megapixels/src/Notification/__snapshots__/notification.test.tsx.snap b/packages/megapixels/src/Notification/__snapshots__/notification.test.tsx.snap index 4068005f4..3975058be 100644 --- a/packages/megapixels/src/Notification/__snapshots__/notification.test.tsx.snap +++ b/packages/megapixels/src/Notification/__snapshots__/notification.test.tsx.snap @@ -7,6 +7,7 @@ exports[`Story Snapshots > CustomWidth 1`] = ` aria-label="Notifications alt+T" aria-live="polite" aria-relevant="additions text" + data-component="toaster" data-react-aria-top-layer="true" tabindex="-1" > @@ -57,18 +58,21 @@ exports[`Story Snapshots > CustomWidth 1`] = ` data-title="" > -
- Normal modal -
- + -
- + + + +
+ Normal modal +
+ + +
+
+