Compare commits

..
Author SHA1 Message Date
Ken a2a09f074a feat(release): Split gpr and npm release into separate jobs 2026-09-25 13:42:18 +02:00
288 changed files with 5783 additions and 7665 deletions

No files matched your search

+5 -10
View File
@@ -5,18 +5,17 @@
- Narrow search space; don't replace reading code.
- Before editing: read target file + nearest related files (`index.ts`, story, test, exports).
- Prefer known paths/scripts over broad searches.
- User often edits files between turns. Before reviewing/refactoring, re-read the files (or `git diff`); never rely on earlier reads.
- Ignore release/deploy/changelog/ops scripts unless asked.
## Behavior
- Only respond in same language as the input. Prefer English always.
- Use caveman-skill unless asked not to.
- Only respond in same language as the input. Prefer english always.
- Use caveman-skill unless asked not to
- No apologies, no "I think", no change summaries.
- Answer-first. 0-2 sentences max.
- No thinking aloud; do next edit. Explain only when asked or non-obvious.
- Show only what changes. Minimal diffs; don't paste whole files.
- Edit only the files the change needs (see Definition of done); no unrelated files. Ask only when truly blocked.
- Edits scoped to one file unless asked. Ask only when truly blocked.
- Favor perf/security; avoid re-renders, top-level side effects, unsafe HTML, env var leaks.
## Repo summary
@@ -28,13 +27,12 @@
## Start and validate
1. `corepack enable`
2. `corepack yarn install` (needs `NPM_AUTH_TOKEN` with `read:packages`). Without a token: `corepack yarn workspaces focus aksel @navikt/aksel-icons @navikt/ds-tokens @navikt/ds-css @navikt/ds-react @navikt/ds-tailwind @navikt/aksel @navikt/aksel-stylelint`
2. `corepack yarn install` (needs `NPM_AUTH_TOKEN` with `read:packages`). Without a token: `corepack yarn workspaces focus @navikt/aksel-icons @navikt/ds-tokens @navikt/ds-css @navikt/ds-react @navikt/ds-tailwind @navikt/aksel @navikt/aksel-stylelint`
3. `corepack yarn boot` (builds all public packages; rerun after `corepack yarn clean`)
- Run agent commands through `corepack yarn`. Order: existing root/workspace script → Yarn-local binary → add a pinned dependency only when new tooling is required.
- Never use `npx`, `pnpx`, `pnpm dlx`, `yarn dlx`, global binaries or curl-piped installers. Public consumer docs (`npx @navikt/aksel`) are intentional; keep them.
- If Corepack/Yarn fails because of user-level configuration, report the exact error. Never edit user-home config.
- Under the `navikt/cplt` sandbox, `Parse error when loading ~/.yarnrc.yml` means the home rc file is unreadable (EPERM), not a repo issue. Stop retrying, report it and ask the user to fix it outside the sandbox. Don't touch the repo `.yarnrc.yml`.
- Choosing checks: use the `aksel-local-validation` skill. Changed-file lint first, then the affected workspace, then root suites only when needed.
## Definition of done
@@ -49,8 +47,7 @@
- `corepack yarn tsc --noEmit --incremental false --project <tsconfig>` - targeted type-check
- `corepack yarn workspace <name> <script>` - e.g. `@navikt/ds-react test` (Vitest + tsc), `website test`
- `corepack yarn lint` / `corepack yarn test` / `corepack yarn boot` - root suites (slow; cross-workspace changes only)
- `corepack yarn storybook` (:6006) / `corepack yarn storybook:aksel` (website examples, :6007, no secrets) / `corepack yarn storybook:test` (play tests, Playwright Firefox; Chromium when `COPILOT_CLI` is set) / `corepack yarn dev` (website, :3000, needs Sanity token)
- Targeted play tests: `corepack yarn vitest run --config ./vitest.config.ts --project=storybook <path/to/X.tests.stories.tsx>` (repo root). Run after changing play tests. Under Copilot CLI this runs Chromium; if the browser can't launch, report tests as not run with the exact command.
- `corepack yarn storybook` (:6006) / `corepack yarn storybook:aksel` (website examples, :6007, no secrets) / `corepack yarn storybook:test` (play tests, Playwright Firefox) / `corepack yarn dev` (website, :3000, needs Sanity token)
## Where to edit
@@ -83,10 +80,8 @@ Path-specific rules load automatically from `.github/instructions/` (see each `a
## Coding defaults
- Small diffs. No drive-by refactors or new deps unless asked.
- Propose the simplest maintainable solution first (explicit lists, member-name checks). No TS compiler API/AST tricks in tests (slow). Ask before escalating complexity.
- Copy nearest existing pattern before writing new code.
- Stable public APIs. Prefer additive props/exports.
- `node:` imports for Node builtins. Never import from `esm/`/`cjs/` output.
- Preserve JSDoc on public props/components when changing public APIs.
- Reuse helpers: `cl`, `composeEventHandlers`, `useId`, `omit`, `useClientLayoutEffect`, `useEventListener`.
- No tests for trivial logic. Only tests for complex logic that has a higher risk of breaking.
-45
View File
@@ -15,14 +15,7 @@ updates:
registries:
- npm-github
groups:
next:
applies-to: version-updates
patterns:
- "@next/eslint-plugin-next"
- "@next/bundle-analyzer"
- "next"
sanity:
applies-to: version-updates
patterns:
- "sanity"
- "sanity-*"
@@ -30,55 +23,17 @@ updates:
- "next-sanity"
- "@portabletext/*"
eslint:
applies-to: version-updates
patterns:
- "eslint"
- "@eslint/*"
react:
applies-to: version-updates
patterns:
- "react"
- "react-dom"
storybook:
applies-to: version-updates
patterns:
- "*storybook*"
vitest:
applies-to: version-updates
patterns:
- "vitest"
- "@vitest/*"
# Security updates ignore version-update groups, so mirror them to keep lockstep packages together.
next-security:
applies-to: security-updates
patterns:
- "@next/eslint-plugin-next"
- "@next/bundle-analyzer"
- "next"
sanity-security:
applies-to: security-updates
patterns:
- "sanity"
- "sanity-*"
- "@sanity/*"
- "next-sanity"
- "@portabletext/*"
eslint-security:
applies-to: security-updates
patterns:
- "eslint"
- "@eslint/*"
react-security:
applies-to: security-updates
patterns:
- "react"
- "react-dom"
storybook-security:
applies-to: security-updates
patterns:
- "*storybook*"
vitest-security:
applies-to: security-updates
patterns:
- "vitest"
- "@vitest/*"
+1 -1
View File
@@ -12,4 +12,4 @@ applyTo: "**/*.test.ts,**/*.test.tsx,**/*.stories.tsx"
- Query by role and accessible name (`getByRole("button", { name })`). Avoid snapshots unless nearby tests already use them.
- Cover the main path plus 1-2 meaningful edge cases. A behavior change updates the nearest story and test in the same change.
- ds-react unit tests run with `TZ=UTC`. Date logic must not depend on the local timezone.
- Browser tests need Playwright Firefox (Chromium under Copilot CLI, see `vitest.config.ts`). Yarn has `enableScripts: false`, so if the browser is missing, report it instead of installing globally.
- Browser tests need Playwright Firefox. Yarn has `enableScripts: false`, so if the browser is missing, report it instead of installing globally.
+1 -1
View File
@@ -69,7 +69,7 @@ Skip anything ESLint, Stylelint, Biome, Prettier or TypeScript already catches.
### Tests and docs (🟡)
- Complex logic with a higher risk of breaking has a story `play` test or Vitest test proving the user-facing contract. Queries use role and accessible name.
- A behavior change has a story `play` test or Vitest test proving the user-facing contract. Queries use role and accessible name.
- Tests are deterministic: no dependence on timezone, current time or randomness.
- Edge cases that are easy to miss: controlled vs. uncontrolled, `ref`/`className`/`...rest` forwarding, `as` prop, empty and very long content.
- Website examples (`aksel.nav.no/website/pages/eksempler/`) are updated when usage changes.
-1
View File
@@ -17,7 +17,6 @@ jobs:
uses: actions/checkout@v7
with:
fetch-depth: 1
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v7
+3 -8
View File
@@ -14,15 +14,15 @@ concurrency:
cancel-in-progress: true
permissions:
actions: read
contents: read
packages: write
id-token: write
jobs:
prebuild-app-for-playwright:
name: Build app for Playwright
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # Push E2E image to GHCR
env:
IMAGE_NAME: ${{ github.repository }}-e2e
REGISTRY: ghcr.io
@@ -31,7 +31,6 @@ jobs:
uses: actions/checkout@v7
with:
fetch-depth: 1
persist-credentials: false
- name: Setup and build Next.js application
uses: ./.github/actions/build-website
@@ -62,9 +61,6 @@ jobs:
needs: [prebuild-app-for-playwright]
name: Playwright shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: read # Pull E2E image from GHCR
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
services:
@@ -85,7 +81,6 @@ jobs:
uses: actions/checkout@v7
with:
fetch-depth: 1
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v7
-4
View File
@@ -24,7 +24,6 @@ jobs:
uses: actions/checkout@v7
with:
fetch-depth: 1
persist-credentials: false
- name: Setup project
uses: ./.github/actions/setup
@@ -42,7 +41,6 @@ jobs:
uses: actions/checkout@v7
with:
fetch-depth: 1
persist-credentials: false
- name: Setup project
uses: ./.github/actions/setup
@@ -60,7 +58,6 @@ jobs:
uses: actions/checkout@v7
with:
fetch-depth: 1
persist-credentials: false
- name: Setup project
uses: ./.github/actions/setup
@@ -81,7 +78,6 @@ jobs:
uses: actions/checkout@v7
with:
fetch-depth: 1
persist-credentials: false
- name: Setup project
uses: ./.github/actions/setup
@@ -22,14 +22,14 @@ jobs:
name: Run Chromatic
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- name: Setup project
uses: ./.github/actions/setup
-3
View File
@@ -33,7 +33,6 @@ jobs:
uses: actions/checkout@v7
with:
fetch-depth: 1
persist-credentials: false
ref: main
- name: Setup project
@@ -62,7 +61,6 @@ jobs:
uses: actions/checkout@v7
with:
fetch-depth: 1
persist-credentials: false
- name: Setup project
uses: ./.github/actions/setup
@@ -89,7 +87,6 @@ jobs:
uses: actions/checkout@v7
with:
fetch-depth: 1
persist-credentials: false
- name: Setup project
uses: ./.github/actions/setup
-1
View File
@@ -33,7 +33,6 @@ jobs:
uses: actions/checkout@v7
with:
fetch-depth: 1
persist-credentials: false
- name: Setup project
uses: ./.github/actions/setup
-2
View File
@@ -12,7 +12,6 @@ jobs:
upload:
name: Build and upload token config
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
permissions:
contents: "read"
@@ -23,7 +22,6 @@ jobs:
uses: actions/checkout@v7
with:
fetch-depth: 1
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v7
-3
View File
@@ -12,7 +12,6 @@ jobs:
upload:
name: Upload fonts to CDN
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
permissions:
contents: read
@@ -21,8 +20,6 @@ jobs:
steps:
- name: Checkout code
uses: "actions/checkout@v7"
with:
persist-credentials: false
- name: Upload fonts to CDN
id: upload
+3 -2
View File
@@ -15,14 +15,15 @@ jobs:
name: Build and deploy to dev-gcp
runs-on: ubuntu-latest
permissions:
packages: "write"
contents: "read"
id-token: "write"
deployments: "write"
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 1
persist-credentials: false
- name: Setup and build Next.js application
uses: ./.github/actions/build-website
@@ -45,7 +46,7 @@ jobs:
identity_provider: ${{ secrets.NAIS_WORKLOAD_IDENTITY_PROVIDER }}
- name: Deploy to dev-gcp
uses: nais/deploy/actions/deploy@v3
uses: nais/deploy/actions/deploy@v2
env:
RESOURCE: aksel.nav.no/nais-dev.yaml
CLUSTER: dev-gcp
+1 -2
View File
@@ -26,7 +26,6 @@ jobs:
uses: actions/checkout@v7
with:
fetch-depth: 1
persist-credentials: false
- name: Setup project
uses: ./.github/actions/setup
@@ -49,7 +48,7 @@ jobs:
identity_provider: ${{ secrets.NAIS_WORKLOAD_IDENTITY_PROVIDER }}
- name: Deploy to prod-gcp
uses: nais/deploy/actions/deploy@v3
uses: nais/deploy/actions/deploy@v2
env:
RESOURCE: apps/mcp-server/.nais/app.yaml
CLUSTER: prod-gcp
+3 -5
View File
@@ -6,7 +6,6 @@ on:
- main
paths:
- "aksel.nav.no/**"
- "apps/playroom/**"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -24,8 +23,6 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Setup project
uses: ./.github/actions/setup
@@ -45,14 +42,15 @@ jobs:
name: Build and deploy to prod-gcp
runs-on: ubuntu-latest
permissions:
packages: "write"
contents: "read"
id-token: "write"
deployments: "write"
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 1
persist-credentials: false
- name: Setup and build Next.js application
uses: ./.github/actions/build-website
@@ -78,7 +76,7 @@ jobs:
identity_provider: ${{ secrets.NAIS_WORKLOAD_IDENTITY_PROVIDER }}
- name: Deploy to prod-gcp
uses: nais/deploy/actions/deploy@v3
uses: nais/deploy/actions/deploy@v2
env:
RESOURCE: aksel.nav.no/nais-prod.yaml
CLUSTER: prod-gcp
+2 -2
View File
@@ -16,6 +16,7 @@ jobs:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/v7-aksel.nav.no'
permissions:
packages: "write"
contents: "read"
id-token: "write"
steps:
@@ -23,7 +24,6 @@ jobs:
uses: actions/checkout@v7
with:
fetch-depth: 1
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v7
@@ -54,7 +54,7 @@ jobs:
identity_provider: ${{ secrets.NAIS_WORKLOAD_IDENTITY_PROVIDER }}
- name: Deploy to prod-gcp
uses: nais/deploy/actions/deploy@v3
uses: nais/deploy/actions/deploy@v2
env:
RESOURCE: v7-aksel.nav.no/nais-prod.yaml
CLUSTER: prod-gcp
+91 -34
View File
@@ -12,16 +12,15 @@ on:
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
build:
name: Release packages
# Only publish from main, also when dispatched manually
if: github.ref == 'refs/heads/main'
version:
name: Create release Pull Request
if: ${{ !contains(github.event.head_commit.message, '[ci skip]') }}
runs-on: ubuntu-latest
permissions:
id-token: "write"
contents: "write"
packages: "write"
pull-requests: "write"
outputs:
has-changesets: ${{ steps.changesets.outputs.has-changesets }}
steps:
- name: Checkout code
uses: actions/checkout@v7
@@ -40,40 +39,60 @@ jobs:
env:
NPM_AUTH_TOKEN: ${{ secrets.READER_TOKEN }}
- name: Create release Pull Request or publish to NPM
# Without `publish-script` the action only creates/updates the release PR
- name: Create or update release Pull Request
id: changesets
uses: changesets/action@v1
uses: changesets/action@v2
with:
version: yarn create-version
publish: yarn release
createGithubReleases: false
version-script: yarn create-version
env:
# Use OIDC for npm authentication instead of NPM_TOKEN
# https://github.com/changesets/changesets/issues/1152#issuecomment-3190884868
NPM_TOKEN: ""
# Needed for push to main
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Create .npmrc for GPR
run: |
cat << EOF > "$HOME/.npmrc"
@navikt:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=$NPM_TOKEN
EOF
env:
NPM_TOKEN: ${{ secrets.GIT_REPO }}
- name: Publish to GPR
if: steps.changesets.outputs.published == 'true'
id: changesets-gpr
uses: changesets/action@v1
# Both publish jobs are idempotent: `changeset publish` only publishes versions missing from the target registry.
# Re-run a failed job to retry that registry without affecting the other.
publish-npm:
name: Publish to npm
needs: version
if: ${{ needs.version.outputs.has-changesets == 'false' }}
runs-on: ubuntu-latest
permissions:
id-token: "write"
contents: "write"
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
publish: yarn changeset publish
createGithubReleases: false
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: lts/*
cache: yarn
cache-dependency-path: "yarn.lock"
- name: Install dependencies
run: yarn
env:
NPM_AUTH_TOKEN: ${{ secrets.READER_TOKEN }}
# changeset v3 uses `yarn npm info`, which reads the @navikt scope registry (GPR) from .yarnrc.yml.
# Only changes this job's checkout, after install.
- name: Point @navikt scope to npm
run: yarn config set npmScopes.navikt.npmRegistryServer "https://registry.npmjs.org"
- name: Publish to npm
id: changesets
uses: changesets/action@v2
with:
publish-script: yarn release
create-github-releases: false
env:
# No token: `yarn npm publish` falls back to npm OIDC (trusted publishing)
NPM_TOKEN: ""
# Needed for push to main
GITHUB_TOKEN: ${{ secrets.GIT_REPO }}
# `yarn npm publish` ignores the scope's npmRegistryServer and defaults to registry.yarnpkg.com
YARN_NPM_PUBLISH_REGISTRY: https://registry.npmjs.org
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Generate icon.zip
if: steps.changesets.outputs.published == 'true'
@@ -90,10 +109,11 @@ jobs:
identity_provider: ${{ secrets.NAIS_WORKLOAD_IDENTITY_PROVIDER }}
- name: Get @navikt/ds-css version
if: steps.changesets.outputs.published == 'true'
run: |
VERSION=$(yarn css:get-version)
echo "version=$VERSION" >> $GITHUB_ENV
echo "Version of @navikt/ds-css: ${{ env.version }}"
echo "Version of @navikt/ds-css: $VERSION"
- name: Upload CSS to Nav CDN
if: steps.changesets.outputs.published == 'true'
@@ -104,3 +124,40 @@ jobs:
destination: "@navikt/ds-css"
project_id: ${{ vars.NAIS_MANAGEMENT_PROJECT_ID }}
identity_provider: ${{ secrets.NAIS_WORKLOAD_IDENTITY_PROVIDER }}
publish-gpr:
name: Publish to GitHub Packages
needs: version
if: ${{ needs.version.outputs.has-changesets == 'false' }}
runs-on: ubuntu-latest
permissions:
# Needed for `publishConfig.provenance`
id-token: "write"
contents: "read"
packages: "write"
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: lts/*
cache: yarn
cache-dependency-path: "yarn.lock"
- name: Install dependencies
run: yarn
env:
NPM_AUTH_TOKEN: ${{ secrets.READER_TOKEN }}
- name: Build packages
run: yarn build:release
# Git tags and GitHub releases are handled by the npm job
- name: Publish to GitHub Packages
run: yarn changeset publish --no-git-tag
env:
NPM_AUTH_TOKEN: ${{ secrets.GIT_REPO }}
# `yarn npm publish` ignores the scope's npmRegistryServer and defaults to registry.yarnpkg.com
YARN_NPM_PUBLISH_REGISTRY: https://npm.pkg.github.com
+3 -10
View File
@@ -1,13 +1,6 @@
CHANGED=`git diff $1 $2 --stat -- yarn.lock | wc -l`
if [ $CHANGED -gt 0 ];
then
printf '\n\033[1;33m'
printf ' ╔══════════════════════════════════════════╗\n'
printf ' ║ ║\n'
printf ' ║ !! yarn.lock has changed !! ║\n'
printf ' ║ ║\n'
printf ' ║ Run: yarn install ║\n'
printf ' ║ ║\n'
printf ' ╚══════════════════════════════════════════╝\n'
printf '\033[0m\n'
fi
echo
echo "*** 🟡 yarn.lock has changed 🟡 ***"
fi
+3 -10
View File
@@ -1,13 +1,6 @@
CHANGED=`git diff HEAD@{1} --stat -- yarn.lock | wc -l`
if [ $CHANGED -gt 0 ];
then
printf '\n\033[1;33m'
printf ' ╔══════════════════════════════════════════╗\n'
printf ' ║ ║\n'
printf ' ║ !! yarn.lock has changed !! ║\n'
printf ' ║ ║\n'
printf ' ║ Run: yarn install ║\n'
printf ' ║ ║\n'
printf ' ╚══════════════════════════════════════════╝\n'
printf '\033[0m\n'
fi
echo
echo "*** 🟡 yarn.lock has changed 🟡 ***"
fi
+1 -2
View File
@@ -1,2 +1 @@
# Runs the local lint-staged checks before committing
./node_modules/.bin/lint-staged
yarn lint-staged
-1
View File
@@ -17,7 +17,6 @@ defaultSemverRangePrefix: ""
npmMinimalAgeGate: 7d
npmPreapprovedPackages:
- "@navikt/*"
enableTelemetry: false
yarnPath: .yarn/releases/yarn-4.18.0.cjs
-20
View File
@@ -1,25 +1,5 @@
# @navikt/aksel-icons
## 8.20.0
## 8.19.2
## 8.19.1
## 8.19.0
### Minor Changes
- Icons: Added new icon MenuHamburgerMagnifyingGlass ([#5368](https://github.com/navikt/aksel/pull/5368))
## 8.18.0
## 8.17.2
### Patch Changes
- Package: Fixed regression where package was published without dist-files ([`9673de4`](https://github.com/navikt/aksel/commit/9673de4c264479dd8ca04897003532595acad255))
## 8.17.1
## 8.17.0
@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.5 3.25C17.9518 3.25 20.75 6.04822 20.75 9.5C20.75 11.6221 19.6911 13.4953 18.0742 14.625L20.667 19.6562C20.8567 20.0244 20.7119 20.4773 20.3438 20.667C19.9756 20.8567 19.5227 20.7119 19.333 20.3438L16.751 15.3301C16.0524 15.6 15.2938 15.75 14.5 15.75C11.0482 15.75 8.25 12.9518 8.25 9.5C8.25 6.04822 11.0482 3.25 14.5 3.25ZM15.8994 17.25C15.993 17.25 16.0792 17.3026 16.1221 17.3857L16.6377 18.3857C16.723 18.552 16.6021 18.75 16.415 18.75H3.5C3.08579 18.75 2.75 18.4142 2.75 18C2.75 17.5858 3.08579 17.25 3.5 17.25H15.8994ZM14.5 4.75C11.8766 4.75 9.75 6.87665 9.75 9.5C9.75 12.1234 11.8766 14.25 14.5 14.25C17.1234 14.25 19.25 12.1234 19.25 9.5C19.25 6.87665 17.1234 4.75 14.5 4.75ZM6.75195 11.25C6.86836 11.25 6.96896 11.3306 6.99805 11.4434C7.08182 11.7679 7.1863 12.084 7.30957 12.3906C7.37768 12.56 7.2558 12.75 7.07324 12.75H3.5C3.08579 12.75 2.75 12.4142 2.75 12C2.75 11.5858 3.08579 11.25 3.5 11.25H6.75195ZM7.55664 5.25C7.75557 5.25 7.8765 5.47207 7.77734 5.64453C7.60333 5.94718 7.4499 6.26318 7.31738 6.58984C7.27831 6.68596 7.18482 6.75 7.08105 6.75H3.5C3.08579 6.75 2.75 6.41421 2.75 6C2.75 5.58579 3.08579 5.25 3.5 5.25H7.55664Z" fill="#202733"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

@@ -1,12 +0,0 @@
name: MenuHamburgerMagnifyingGlass
category: Interface
sub_category: Menu
keywords:
- Menu
- meny
- hamburger
- search
- søk
- forstørrelsesglass
variant: Stroke
created_at: 07.10.2026
+6 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-icons",
"version": "8.20.0",
"version": "8.17.1",
"author": "Aksel | Nav",
"homepage": "https://aksel.nav.no/ikoner",
"repository": {
@@ -75,20 +75,19 @@
"@figma/rest-api-spec": "^0.41.0",
"@svgr/cli": "8.1.0",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "4.17.25",
"@types/react": "19.2.14",
"adm-zip": "^0.6.1",
"concurrently": "10.0.6",
"concurrently": "10.0.4",
"copyfiles": "^2.4.1",
"fast-glob": "3.3.3",
"hast-util-select": "6.0.4",
"js-yaml": "^5.5.0",
"js-yaml": "^5.4.2",
"lodash": "4.18.1",
"prettier": "3.9.6",
"react": "19.3.0",
"react": "19.2.8",
"rehype-parse": "9.0.1",
"tsc-alias": "1.9.7",
"tsx": "^4.23.15",
"tsc-alias": "1.9.1",
"tsx": "^4.23.1",
"typescript": "6.0.3",
"unified": "11.0.5",
"vitest": "4.1.11"
-12
View File
@@ -1,17 +1,5 @@
# @navikt/aksel-stylelint
## 8.20.0
## 8.19.2
## 8.19.1
## 8.19.0
## 8.18.0
## 8.17.2
## 8.17.1
## 8.17.0
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-stylelint",
"version": "8.20.0",
"version": "8.17.1",
"type": "module",
"engines": {
"node": ">=18.12.0"
@@ -43,14 +43,14 @@
"dev": "yarn watch:lint"
},
"devDependencies": {
"@navikt/ds-css": "^8.20.0",
"@navikt/ds-tokens": "^8.20.0",
"concurrently": "10.0.6",
"postcss-selector-parser": "^7.1.7",
"@navikt/ds-css": "^8.17.1",
"@navikt/ds-tokens": "^8.17.1",
"concurrently": "10.0.4",
"postcss-selector-parser": "^7.1.5",
"postcss-value-parser": "^4.2.0",
"stylelint": "^17.15.0",
"stylelint": "^17.14.1",
"stylelint-test-rule-node": "^1.2.0",
"tsx": "^4.23.15",
"tsx": "^4.23.1",
"typescript": "6.0.3"
}
}
-24
View File
@@ -1,29 +1,5 @@
# @navikt/aksel
## 8.20.0
## 8.19.2
## 8.19.1
## 8.19.0
### Patch Changes
- CLI: Update clipboardy to v5.3.2 ([#5357](https://github.com/navikt/aksel/pull/5357))
- CLI: Update figlet to 1.11.3 ([#5360](https://github.com/navikt/aksel/pull/5360))
## 8.18.0
### Patch Changes
- Codemod: Migrations now run in folders that are not git repositories, and a missing git install gets its own error message. `codemod v8` no longer stops before `v8-tokens` because of changes made by earlier migrations in the same run. ([`470a820`](https://github.com/navikt/aksel/commit/470a8207f23aee71243b986ef2836967c2498e71))
- Codemod: `v8-tokens` no longer removes whitespace before replaced tokens or rewrites longer names that contain a token name. ([#5322](https://github.com/navikt/aksel/pull/5322))
## 8.17.2
## 8.17.1
## 8.17.0
+3 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel",
"version": "8.20.0",
"version": "8.17.1",
"description": "Aksel command line interface. Codemods and other utilities for Aksel users.",
"author": "Aksel | Nav designsystem team",
"license": "MIT",
@@ -42,11 +42,11 @@
"dependencies": {
"chalk": "5.6.2",
"cli-progress": "3.12.0",
"clipboardy": "5.3.2",
"clipboardy": "5.3.1",
"commander": "15.0.0",
"enquirer": "2.4.1",
"fast-glob": "3.3.3",
"figlet": "1.12.0",
"figlet": "1.11.0",
"is-git-clean": "1.1.0",
"jscodeshift": "17.4.0",
"lodash": "4.18.1"
@@ -55,7 +55,6 @@
"@types/cli-progress": "3.11.6",
"@types/is-git-clean": "1.1.2",
"@types/jscodeshift": "17.3.0",
"@types/lodash": "4.17.25",
"rimraf": "6.1.3",
"typescript": "6.0.3",
"vitest": "4.1.11"
@@ -1,91 +0,0 @@
import type { Command } from "commander";
import isGitClean from "is-git-clean";
import { afterEach, describe, expect, test, vi } from "vitest";
import { validateGit } from "../validation";
vi.mock("is-git-clean", () => ({ default: { sync: vi.fn() } }));
const gitSync = vi.mocked(isGitClean.sync);
function createProgram() {
const error = vi.fn((message: string) => {
throw new Error(message);
});
return { program: { error } as unknown as Command, error };
}
function gitError(props: Record<string, unknown>) {
return Object.assign(new Error("Command failed: git status"), props);
}
describe("validateGit", () => {
afterEach(() => {
vi.clearAllMocks();
});
test("passes when the working tree is clean", () => {
gitSync.mockReturnValue(true);
const { program, error } = createProgram();
validateGit({}, program);
expect(error).not.toHaveBeenCalled();
});
test("fails when the working tree has changes", () => {
gitSync.mockReturnValue(false);
const { program, error } = createProgram();
expect(() => validateGit({}, program)).toThrow(/stash or commit/);
expect(error).toHaveBeenCalledTimes(1);
});
test.each([
"fatal: not a git repository (or any of the parent directories): .git\n",
"fatal: Not a git repository (or any of the parent directories): .git\n",
])("passes outside a git repository (%j)", (stderr) => {
gitSync.mockImplementation(() => {
throw gitError({ status: 128, stderr });
});
const { program, error } = createProgram();
validateGit({}, program);
expect(error).not.toHaveBeenCalled();
});
test("reports a missing git binary instead of asking to stash", () => {
gitSync.mockImplementation(() => {
throw gitError({ code: "ENOENT" });
});
const { program, error } = createProgram();
expect(() => validateGit({}, program)).toThrow(/git/i);
expect(error.mock.calls[0][0]).not.toMatch(/stash or commit/);
expect(error.mock.calls[0][0]).toMatch(/--force/);
});
test("fails on other git errors", () => {
gitSync.mockImplementation(() => {
throw gitError({
status: 128,
stderr: "fatal: detected dubious ownership",
});
});
const { program } = createProgram();
expect(() => validateGit({}, program)).toThrow(/stash or commit/);
});
test.each([{ dryRun: true }, { force: true }])(
"skips the check with force",
(options) => {
const { program, error } = createProgram();
validateGit(options, program);
expect(gitSync).not.toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
},
);
});
@@ -1,29 +0,0 @@
/* @ts-expect-error No avaliable types for testUtils */
import { applyTransform } from "jscodeshift/dist/testUtils";
import { describe, expect, test } from "vitest";
import * as transform from "../update-js-tokens";
const run = (source: string) =>
applyTransform({ ...transform, parser: "tsx" }, {}, { source });
describe("update-js-tokens keeps boundaries intact", () => {
test("does not replace inside longer identifiers", () => {
expect(
run(
`import { NavdsGlobalColorGray100 } from "@navikt/ds-tokens/dist/tokens";\n\nconst a = [NavdsGlobalColorGray100, MyNavdsGlobalColorGray100, NavdsGlobalColorGray1000];`,
),
).toBe(
`import { AGray100 } from "@navikt/ds-tokens/dist/tokens";\n\nconst a = [AGray100, MyNavdsGlobalColorGray100, NavdsGlobalColorGray1000];`,
);
});
test("does not replace similar names when aliased", () => {
expect(
run(
`import { NavdsGlobalColorGray100 as gray } from "@navikt/ds-tokens/dist/tokens";\n\nconst a = [gray, grayish, mygray];`,
),
).toBe(
`import { AGray100 } from "@navikt/ds-tokens/dist/tokens";\n\nconst a = [AGray100, grayish, mygray];`,
);
});
});
@@ -1,6 +1,5 @@
import type { API, JSCodeshift } from "jscodeshift";
import { tokens } from "../../../tokens-map.js";
import { createIdentifierRegex } from "../../../utils/identifier-regex";
import {
getImportSpecifier,
getImportSpecifierName,
@@ -61,7 +60,8 @@ export default function transformer(file: JSCodeshift, api: API) {
let code = root.toSource(getLineTerminator(file.source));
code = code.replace(createIdentifierRegex(String(localName)), out);
const rgx = new RegExp(`(${localName})`, "gm");
code = code.replace(rgx, out);
root = j(code);
}
});
+1
View File
@@ -2,6 +2,7 @@
import { applyTransform } from "jscodeshift/dist/testUtils";
import fs from "node:fs";
import path from "node:path";
// eslint-disable-next-line import/default
import prettier from "prettier";
import { describe, expect, test } from "vitest";
@@ -1,11 +0,0 @@
/**
* Matches a JS identifier as a whole word.
* Lookarounds are zero-width, so replacing the match never removes surrounding whitespace,
* and longer identifiers containing the name (`MyToken`, `Token2`, `$Token`) are left untouched.
*/
function createIdentifierRegex(identifier: string) {
const escaped = identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`(?<![\\w$])${escaped}(?![\\w$])`, "gm");
}
export { createIdentifierRegex };
@@ -218,25 +218,8 @@ describe("getTokenRegex JS", () => {
expect("ATextRed200").not.toMatch(regex);
expect("AATextRed200").not.toMatch(regex);
expect("AATextRed").not.toMatch(regex);
expect("$ATextRed").not.toMatch(regex);
});
test("should not include surrounding whitespace in the match", () => {
const regex = getTokenRegex("--a-text-red", "js");
expect(" ATextRed ".match(regex)).toEqual(["ATextRed"]);
});
});
describe("getTokenRegex does not consume leading whitespace", () => {
test.each([
["css", " var(--a-text-red)", "--a-text-red"],
["scss", "margin: 0 $a-text-red", "$a-text-red"],
["less", "margin: 0 @a-text-red", "@a-text-red"],
] as const)("%s", (format, input, expected) => {
expect(input.match(getTokenRegex("--a-text-red", format))).toEqual([
expected,
]);
/* Since we cant do a positive lookbehind, these cases will be matched */
expect("AATextRed").toMatch(regex);
});
});
@@ -39,29 +39,26 @@ function getFrameworkRegexes({
return regexes;
}
/**
* Transforms replace the full match. We use lookarounds so that the regex does not consume surrounding characters.
*/
function getTokenRegex(
variable: string,
format: "css" | "scss" | "less" | "js",
) {
switch (format) {
case "css":
return new RegExp(`(?<![\\w-])(${variable})(?![\\w-])`, "gm");
return new RegExp(`(\\s|^)?(${variable})(?=\\s|$|[^\\w-])`, "gm");
case "scss":
return new RegExp(
`(?<![\\w-])(\\${translateToken(variable, "scss")})(?![\\w-])`,
`(\\s|^)?(\\${translateToken(variable, "scss")})(?=\\s|$|[^\\w-])`,
"gm",
);
case "less":
return new RegExp(
`(?<![\\w-])(${translateToken(variable, "less")})(?![\\w-])`,
`(\\s|^)?(${translateToken(variable, "less")})(?=\\s|$|[^\\w-])`,
"gm",
);
case "js":
return new RegExp(
`(?<![\\w$])(${translateToken(variable, "js")})(?![\\w$])`,
`(\\s|^)?(${translateToken(variable, "js")})(?=\\s|$|[^\\w-])`,
"gm",
);
}
+2 -5
View File
@@ -4,7 +4,7 @@ import { runTooling } from "./run-tooling.js";
const program = new Command();
function v8TokensCommand({ skipGitCheck = false } = {}) {
function v8TokensCommand() {
program.allowExcessArguments();
program.name(`${chalk.blueBright(`npx @navikt/aksel v8-tokens`)}`);
@@ -24,10 +24,7 @@ function v8TokensCommand({ skipGitCheck = false } = {}) {
program.parse();
const options = program.opts();
return runTooling(
{ ...(options as Parameters<typeof runTooling>["0"]), skipGitCheck },
program,
);
runTooling(options as Parameters<typeof runTooling>["0"], program);
}
export { v8TokensCommand };
@@ -31,11 +31,6 @@ type ToolingOptions = {
dryRun: boolean;
glob: string;
ext: string;
/**
* Set when the caller has already validated git (e.g. `codemod v8`),
* since earlier migrations in the same run leave the tree dirty.
*/
skipGitCheck?: boolean;
};
type CodeshiftOptions = {
@@ -95,15 +90,6 @@ export async function runTooling(
// Show initial status
const initialStatus = getStatus(filepaths);
// Only check git before the first write: later tasks see our own changes.
let gitChecked = !!options.skipGitCheck;
const ensureGitChecked = () => {
if (!gitChecked) {
validateGit(options, program);
gitChecked = true;
}
};
// Task execution loop
let task: TaskName = await getNextTask(initialStatus.status);
let currentStatus = initialStatus;
@@ -119,7 +105,6 @@ export async function runTooling(
program,
currentStatus,
() => getStatus(filepaths, "no-print"),
ensureGitChecked,
);
} catch (error) {
program.error(
@@ -132,6 +117,8 @@ export async function runTooling(
task = await getNextTask(currentStatus.status);
}
process.exit(0);
}
/**
@@ -144,7 +131,6 @@ async function executeTask(
program: Command,
statusStore: TokenStatus,
updateStatus: () => TokenStatus,
ensureGitChecked: () => void,
): Promise<TokenStatus> {
switch (task) {
case "status":
@@ -161,7 +147,9 @@ async function executeTask(
case "less-tokens":
case "js-tokens":
case "tailwind-tokens": {
ensureGitChecked();
if (!options.force) {
validateGit(options, program);
}
const scopedFiles = getScopedFilesForTask(
task,
filepaths,
@@ -191,7 +179,9 @@ async function executeTask(
"tailwind-tokens",
] as const;
ensureGitChecked();
if (!options.force) {
validateGit(options, program);
}
let currentStatus = statusStore;
const summaryData: {
@@ -1,73 +0,0 @@
import type { Command } from "commander";
import Enquirer from "enquirer";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { validateGit } from "../../validation";
import { TokenStatus } from "../config/TokenStatus";
import { runTooling } from "../run-tooling";
vi.mock("enquirer", () => ({ default: { prompt: vi.fn() } }));
vi.mock("fast-glob", () => ({ default: vi.fn(async () => []) }));
vi.mock("jscodeshift/src/Runner", () => ({
run: vi.fn(async () => ({ error: 0, ok: 0, nochange: 0, skip: 0 })),
}));
vi.mock("../../validation", () => ({ validateGit: vi.fn() }));
vi.mock("../tasks/status", () => ({
getStatus: vi.fn(() => new TokenStatus()),
}));
vi.mock("../tasks/print-remaining", () => ({ printRemaining: vi.fn() }));
const prompt = vi.mocked(Enquirer.prompt);
const program = { error: vi.fn() } as unknown as Command;
const baseOptions = { force: false, dryRun: false, glob: "**/*.css", ext: "" };
function selectTasks(...tasks: string[]) {
for (const task of [...tasks, "exit"]) {
prompt.mockResolvedValueOnce({ task });
}
}
describe("runTooling", () => {
beforeEach(() => {
vi.spyOn(console, "info").mockImplementation(() => {});
vi.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`process.exit(${code})`);
});
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
test("returns instead of exiting the process when the user exits", async () => {
selectTasks();
await expect(runTooling(baseOptions, program)).resolves.toBeUndefined();
expect(process.exit).not.toHaveBeenCalled();
});
test("checks git once, even when several migrations run in a row", async () => {
selectTasks("css-tokens", "scss-tokens", "run-all-migrations");
await runTooling(baseOptions, program);
expect(validateGit).toHaveBeenCalledTimes(1);
expect(program.error).not.toHaveBeenCalled();
});
test("skips the git check when the caller already did it", async () => {
selectTasks("css-tokens", "run-all-migrations");
await runTooling({ ...baseOptions, skipGitCheck: true }, program);
expect(validateGit).not.toHaveBeenCalled();
});
test("does not check git for read-only tasks", async () => {
selectTasks("status", "print-remaining-tokens");
await runTooling(baseOptions, program);
expect(validateGit).not.toHaveBeenCalled();
});
});
@@ -1,85 +0,0 @@
/* @ts-expect-error No avaliable types for testUtils */
import { applyTransform } from "jscodeshift/dist/testUtils";
import { describe, expect, test } from "vitest";
import * as jsTransform from "../v8-tokens-js";
import lessTransform from "../v8-tokens-less";
import scssTransform from "../v8-tokens-scss";
/*
* Fixture tests are compared after Prettier, which hides whitespace bugs.
* These tests compare the exact output.
*/
const runJs = (source: string) =>
applyTransform({ ...jsTransform, parser: "tsx" }, {}, { source });
const runStyle = (
transformer: typeof scssTransform | typeof lessTransform,
source: string,
) => transformer({ path: "test", source });
describe("v8-tokens-js keeps boundaries intact", () => {
const importLine = `import { ASurfaceDefault } from "@navikt/ds-tokens/dist/tokens";\n`;
const expectedImport = `import { BgDefault } from "@navikt/ds-tokens/js";\n`;
test.each([
["return ASurfaceDefault;", "return BgDefault;"],
["const a = ASurfaceDefault;", "const a = BgDefault;"],
["const a = [ASurfaceDefault];", "const a = [BgDefault];"],
["const a = {...ASurfaceDefault};", "const a = {...BgDefault};"],
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing token inside template literal
["const a = `${ASurfaceDefault}`;", "const a = `${BgDefault}`;"],
])("%s", (input, output) => {
expect(runJs(`${importLine}\n${input}`)).toBe(
`${expectedImport}\n${output}`,
);
});
test("does not replace inside longer identifiers", () => {
expect(
runJs(
`${importLine}\nconst a = [MyASurfaceDefault, ASurfaceDefault2, $ASurfaceDefault];`,
),
).toBe(
`${expectedImport}\nconst a = [MyASurfaceDefault, ASurfaceDefault2, $ASurfaceDefault];`,
);
});
test("replaces aliased imports without touching similar names", () => {
expect(
runJs(
`import { ASurfaceDefault as surf } from "@navikt/ds-tokens/dist/tokens";\n\nconst a = [surf, surface, mysurf];`,
),
).toBe(`${expectedImport}\nconst a = [BgDefault, surface, mysurf];`);
});
});
describe("v8-tokens-scss keeps whitespace", () => {
test.each([
["margin: 0 $a-spacing-4;", "margin: 0 $ax-space-16;"],
["color: $a-text-default;", "color: $ax-text-neutral;"],
["color:$a-text-default;", "color:$ax-text-neutral;"],
["$a-text-default", "$ax-text-neutral"],
["\n\t$a-text-default", "\n\t$ax-text-neutral"],
["color: common.$a-text-default;", "color: common.$ax-text-neutral;"],
[
"padding: $a-spacing-4 $a-spacing-4;",
"padding: $ax-space-16 $ax-space-16;",
],
])("%s", (input, output) => {
expect(runStyle(scssTransform, input)).toBe(output);
});
});
describe("v8-tokens-less keeps whitespace", () => {
test.each([
["margin: 0 @a-spacing-4;", "margin: 0 @ax-space-16;"],
["color: @a-text-default;", "color: @ax-text-neutral;"],
["color:@a-text-default;", "color:@ax-text-neutral;"],
[
"padding: @a-spacing-4 @a-spacing-4;",
"padding: @ax-space-16 @ax-space-16;",
],
])("%s", (input, output) => {
expect(runStyle(lessTransform, input)).toBe(output);
});
});
@@ -1,5 +1,4 @@
import type { API, FileInfo } from "jscodeshift";
import { createIdentifierRegex } from "../../utils/identifier-regex";
import { getImportSpecifier } from "../../utils/imports";
import { getLineTerminator } from "../../utils/lineterminator";
import moveAndRenameImport from "../../utils/packageImports";
@@ -64,9 +63,8 @@ export default function transformer(file: FileInfo, api: API) {
let code = root.toSource(getLineTerminator(src));
if (localName) {
code = code.replace(createIdentifierRegex(localName), jsToken);
}
const rgx = new RegExp(`(\\s|^)?(${localName})(?=\\s|$|[^\\w-])`, "gm");
code = code.replace(rgx, jsToken);
src = code;
root = j(code);
+1 -8
View File
@@ -43,14 +43,7 @@ export function validateGit(options: any, program: Command) {
try {
clean = isGitClean.sync(process.cwd());
} catch (err: any) {
if (err?.code === "ENOENT") {
program.error(
`${chalk.yellow(
"\nCould not find git, so we can't check for uncommitted changes.",
)}${"\nInstall git, or use the --force flag to override this safety check."}`,
);
}
if (/not a git repository/i.test(String(err?.stderr ?? ""))) {
if (err?.stderr && err.stderr.indexOf("Not a git repository") >= 0) {
clean = true;
}
}
+4 -5
View File
@@ -27,14 +27,13 @@ async function run() {
if (args[0] === "codemod") {
if (args.includes("v8-tokens")) {
await v8TokensCommand();
process.exit(0);
v8TokensCommand();
return;
}
codemodCommand(async (migration) => {
codemodCommand((migration) => {
if (migration === "v8-tokens") {
/* `codemod v8` has already checked git before running earlier migrations */
await v8TokensCommand({ skipGitCheck: true });
v8TokensCommand();
}
});
return;
-38
View File
@@ -1,43 +1,5 @@
# @navikt/ds-css
## 8.20.0
### Minor Changes
- Checkbox: Add `CheckboxCard` and `CheckboxCardGroup` components ([#5364](https://github.com/navikt/aksel/pull/5364))
### Patch Changes
- ToggleGroup: Add xsmall as size ([#5411](https://github.com/navikt/aksel/pull/5411))
- RadioCard, CheckboxCard: Updated border-color in unchecked error-state, input-color in checked disabled state ([#5425](https://github.com/navikt/aksel/pull/5425))
## 8.19.2
### Patch Changes
- Search: Icon more visible in forced-colors mode ([#5386](https://github.com/navikt/aksel/pull/5386))
## 8.19.1
### Patch Changes
- Accordion: `@media print` now auto-opens every Accordion ([#5363](https://github.com/navikt/aksel/pull/5363))
- ExpansionCard: `@media print` now auto-opens every ExpansionCard ([#5363](https://github.com/navikt/aksel/pull/5363))
- ReadMore: `@media print` now auto-opens every ReadMore ([#5363](https://github.com/navikt/aksel/pull/5363))
## 8.19.0
## 8.18.0
### Patch Changes
- ActionMenu: Faster and smoother open transition. ([#5296](https://github.com/navikt/aksel/pull/5296))
## 8.17.2
## 8.17.1
## 8.17.0
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-css",
"version": "8.20.0",
"version": "8.17.1",
"description": "CSS for Nav Designsystem",
"author": "Aksel | Nav designsystem team",
"keywords": [
@@ -32,12 +32,12 @@
"css:get-version": "node config/get-version.js"
},
"devDependencies": {
"@navikt/ds-tokens": "^8.20.0",
"@navikt/ds-tokens": "^8.17.1",
"browserslist": "^4.28.7",
"esbuild": "^0.28.2",
"fast-glob": "3.3.3",
"lightningcss": "^1.33.0",
"tsx": "^4.23.15"
"tsx": "^4.23.1"
},
"browserslist": [
">= 0.2% in NO",
-17
View File
@@ -123,11 +123,6 @@
.aksel-accordion--indent > .aksel-accordion__item > .aksel-accordion__content {
box-shadow: -2px 0 0 0 var(--ax-border-neutral-subtleA);
@media print {
border-left: 1px solid black;
margin-left: calc(var(--ax-space-24) - 2px);
}
}
.aksel-accordion__item {
@@ -168,16 +163,4 @@
border-bottom: 1px solid var(--ax-border-neutral-subtleA);
}
}
@media print {
& > .aksel-accordion__content {
visibility: visible;
/* Override grid layout for print. Grid causes element to stretch unexpectedly across printed pages in FF */
display: block;
margin-block: var(--ax-space-8);
margin-block-end: var(--ax-space-24);
opacity: 1;
}
}
}
+4 -4
View File
@@ -21,13 +21,13 @@
/* stylelint-disable-next-line csstools/value-no-unknown-custom-properties */
transform-origin: var(--__axc-action-menu-content-transform-origin);
transition:
transform 100ms cubic-bezier(0.25, 0.46, 0.45, 0.94) allow-discrete,
opacity 100ms cubic-bezier(0.25, 0.46, 0.45, 0.94) allow-discrete;
transform 150ms ease allow-discrete,
opacity 150ms ease allow-discrete;
&[data-state="open"] {
@starting-style {
opacity: 0.1;
transform: scale3d(0.95, 0.95, 0.95);
opacity: 0.8;
transform: scale3d(0.94, 0.94, 0.94);
}
}
}
-15
View File
@@ -19,10 +19,6 @@
box-shadow: 0 0 0 1px var(--ax-border-default);
border-color: var(--ax-border-default);
}
@media print {
border-color: black;
}
}
/* ------------------------ ExpansionCard Small-size ------------------------ */
@@ -170,17 +166,6 @@
opacity: 1;
}
}
@media print {
/* Override grid layout for print. Grid causes element to stretch unexpectedly across printed pages in FF */
display: block;
visibility: visible;
padding-block: var(--__axc-expansioncard-padding-block);
& .aksel-expansioncard__content-inner {
opacity: 1;
}
}
}
.aksel-expansioncard__content-inner {
+4 -11
View File
@@ -35,7 +35,7 @@
color: var(--ax-text-neutral);
width: 100%;
min-height: 3rem;
display: block;
display: inline-block;
position: relative;
padding: var(--ax-space-8);
padding-right: var(--ax-space-40);
@@ -81,11 +81,10 @@
.aksel-combobox2__field-chevron {
position: absolute;
right: var(--ax-space-8);
top: 0;
margin-top: 0.75rem; /* We don't use dynamic centering because we don't want it to move if height changes */
font-size: 1.5rem;
right: var(--ax-space-8);
pointer-events: none;
margin-top: 12px; /* We don't use dynamic centering because we don't want it to move if height changes */
color: var(--ax-text-neutral);
@media (forced-colors: active) {
@@ -93,7 +92,7 @@
}
.aksel-combobox2[data-size="small"] & {
margin-top: 0.25rem;
margin-top: 4px;
}
}
@@ -237,12 +236,6 @@
.aksel-combobox2__option:is([aria-selected="true"], [aria-selected="true"][data-virtual-focus="true"])
.aksel-listbox__option-inner {
background-color: var(--ax-bg-moderate-pressedA);
@media (forced-colors: active) {
background-color: SelectedItem;
color: SelectedItemText;
forced-color-adjust: none;
}
}
.aksel-combobox2__option-checkmark {
+3 -87
View File
@@ -19,46 +19,6 @@
}
}
/* Checkbox and Radio card */
.aksel-checkbox-card,
.aksel-radio-card {
width: 100%;
align-content: flex-start;
padding: var(--ax-space-16) var(--ax-space-20);
cursor: pointer;
background-color: var(--ax-bg-default);
border: 1px solid var(--ax-border-neutral-subtleA);
border-radius: var(--ax-radius-8);
&:focus-within::after,
&:is(.aksel-checkbox--small, .aksel-radio--small):focus-within::after {
inset: 0;
border-radius: calc(var(--ax-radius-8) - 1px);
outline-offset: 4px;
}
}
/* Checkbox and Radio card group */
.aksel-checkbox-card-group > .aksel-checkboxes,
.aksel-radio-card-group > .aksel-radio-buttons {
display: flex;
flex-direction: column;
gap: var(--ax-space-12);
> .aksel-hgrid:not(.aksel-hgrid-gap) {
gap: var(--ax-space-12);
}
}
.aksel-checkbox-card-group.aksel-checkbox-group--small > .aksel-checkboxes,
.aksel-radio-card-group.aksel-radio-group--small > .aksel-radio-buttons {
gap: var(--ax-space-8);
> .aksel-hgrid:not(.aksel-hgrid-gap) {
gap: var(--ax-space-8);
}
}
/* Small */
.aksel-checkbox--small,
.aksel-radio--small {
@@ -70,11 +30,6 @@
}
}
.aksel-checkbox-card.aksel-checkbox--small,
.aksel-radio-card.aksel-radio--small {
padding: var(--ax-space-12) var(--ax-space-16);
}
/* Label */
.aksel-checkbox__label,
.aksel-radio__label {
@@ -248,38 +203,9 @@
--__axc-radio-checkbox-marker-border: 7px;
}
/* Card border in hover and checked states */
.aksel-checkbox-card:is(
.aksel-checkbox--error:not(:where(.aksel-checkbox--disabled, .aksel-checkbox--readonly)),
:hover:not(:where(.aksel-checkbox--disabled, .aksel-checkbox--readonly, .aksel-checkbox--error)),
:has(.aksel-checkbox__input:checked):not(:where(.aksel-checkbox--disabled, .aksel-checkbox--readonly))
),
.aksel-radio-card:is(
.aksel-radio--error:not(:where(.aksel-radio--disabled, .aksel-radio--readonly)),
:hover:not(:where(.aksel-radio--disabled, .aksel-radio--readonly, .aksel-radio--error)),
:has(> .aksel-radio__input:checked):not(:where(.aksel-radio--disabled, .aksel-radio--readonly))
) {
border-color: var(--ax-border-default);
box-shadow: 0 0 0 1px var(--ax-border-default);
}
/* Card background in checked state */
.aksel-checkbox-card:has(.aksel-checkbox__input:checked):not(
:where(.aksel-checkbox--disabled, .aksel-checkbox--readonly, .aksel-checkbox--error)
),
.aksel-radio-card:has(> .aksel-radio__input:checked):not(
:where(.aksel-radio--disabled, .aksel-radio--readonly, .aksel-radio--error)
) {
background-color: var(--ax-bg-accent-moderateA);
}
/* Marker colors in unchecked hover state (unless disabled) */
.aksel-checkbox__input:hover:not(:disabled, :checked, :indeterminate),
.aksel-radio__input:hover:not(:disabled, :checked),
.aksel-checkbox-card:hover:not(:where(.aksel-checkbox--disabled, .aksel-checkbox--readonly))
.aksel-checkbox__input:not(:disabled, :checked, :indeterminate),
.aksel-radio-card:hover:not(:where(.aksel-radio--disabled, .aksel-radio--readonly))
> .aksel-radio__input:not(:disabled, :checked) {
.aksel-radio__input:hover:not(:disabled, :checked) {
border-color: var(--ax-border-strong);
background-color: var(--ax-bg-moderate-hoverA);
}
@@ -296,25 +222,15 @@
opacity: var(--ax-opacity-disabled);
}
/* Card background in readonly state */
.aksel-checkbox-card:where(.aksel-checkbox--readonly),
.aksel-radio-card:where(.aksel-radio--readonly) {
background-color: var(--ax-bg-neutral-moderate);
}
/* Cursor in disabled state on otherwise clickable elements */
.aksel-checkbox--disabled :where(.aksel-checkbox__input, .aksel-checkbox__label),
.aksel-radio--disabled > :where(.aksel-radio__input, .aksel-radio__label),
.aksel-checkbox-card.aksel-checkbox--disabled,
.aksel-radio-card.aksel-radio--disabled {
.aksel-radio--disabled > :where(.aksel-radio__input, .aksel-radio__label) {
cursor: not-allowed;
}
/* Cursor in readonly state on otherwise clickable elements */
.aksel-checkbox--readonly :where(.aksel-checkbox__input, .aksel-checkbox__label),
.aksel-radio--readonly > :where(.aksel-radio__input, .aksel-radio__label),
.aksel-checkbox-card.aksel-checkbox--readonly,
.aksel-radio-card.aksel-radio--readonly {
.aksel-radio--readonly > :where(.aksel-radio__input, .aksel-radio__label) {
cursor: default;
}
-4
View File
@@ -81,10 +81,6 @@
pointer-events: none;
font-size: 1.5rem;
@media (forced-colors: active) {
color: CanvasText;
}
.aksel-search--disabled & {
opacity: var(--ax-opacity-disabled);
}
-4
View File
@@ -49,9 +49,5 @@
outline: 2px solid var(--ax-border-focus);
outline-offset: -2px;
background-color: var(--ax-bg-neutral-moderate-hoverA);
@media (forced-colors: active) {
outline-color: Highlight;
}
}
}
-4
View File
@@ -86,10 +86,6 @@
.aksel-read-more[data-state="closed"] & {
display: none;
@media print {
display: block;
}
}
}
-12
View File
@@ -87,18 +87,6 @@
}
}
/* Modifier xsmall size */
.aksel-toggle-group--xsmall {
& .aksel-toggle-group__button {
padding: var(--ax-space-1) var(--ax-space-8);
min-height: 1.5rem;
}
& .aksel-toggle-group__button-inner > svg {
font-size: var(--ax-font-size-large);
}
}
/* High contrast */
@media (forced-colors: active) {
.aksel-toggle-group {
-82
View File
@@ -1,87 +1,5 @@
# @navikt/ds-react
## 8.20.0
### Minor Changes
- Checkbox: Add `CheckboxCard` and `CheckboxCardGroup` components ([#5364](https://github.com/navikt/aksel/pull/5364))
### Patch Changes
- Lookup, Badge: Mark as deprecated when imported from @navikt/ds-react/PREVIEW ([#5401](https://github.com/navikt/aksel/pull/5401))
- ToggleGroup: Add xsmall as size ([#5411](https://github.com/navikt/aksel/pull/5411))
- Build: `next build --webpack` now works when package is part of `optimizePackageImports` ([#5422](https://github.com/navikt/aksel/pull/5422))
- Updated dependencies []:
- @navikt/ds-tokens@8.20.0
- @navikt/aksel-icons@8.20.0
## 8.19.2
### Patch Changes
- TextField: Now inherits `readOnly` from Fieldset. No longer gets read-only styling when `disabled` and `readOnly`. ([#5328](https://github.com/navikt/aksel/pull/5328))
- Textarea: Now inherits `readOnly` from Fieldset. No longer gets read-only styling when `disabled` and `readOnly`. ([#5328](https://github.com/navikt/aksel/pull/5328))
- Fieldset: Nested Fieldsets now inherits the parent's `disabled` state. ([#5328](https://github.com/navikt/aksel/pull/5328))
- Button: `loading={true}` and `disabled={false}` now disable the element. ([#5328](https://github.com/navikt/aksel/pull/5328))
- Switch: `loading={true}` and `disabled={false}` now disable the element. ([#5328](https://github.com/navikt/aksel/pull/5328))
- Search.Button: `loading={true}` and `disabled={false}` now disable SearchButton. ([#5328](https://github.com/navikt/aksel/pull/5328))
- Badge: No longer sets aria-hidden internally automatically. ([#5397](https://github.com/navikt/aksel/pull/5397))
- Updated dependencies []:
- @navikt/ds-tokens@8.19.2
- @navikt/aksel-icons@8.19.2
## 8.19.1
### Patch Changes
- Updated dependencies []:
- @navikt/ds-tokens@8.19.1
- @navikt/aksel-icons@8.19.1
## 8.19.0
### Patch Changes
- Updated dependencies [[`c5dd309`](https://github.com/navikt/aksel/commit/c5dd3097e51ef2a9f79964248398bd158660bd3b)]:
- @navikt/aksel-icons@8.19.0
- @navikt/ds-tokens@8.19.0
## 8.18.0
### Minor Changes
- Radio: Add RadioCard and RadioCardGroup components ([#5276](https://github.com/navikt/aksel/pull/5276))
### Patch Changes
- Badge, Lookup: now available outside of Preview scope ([#5344](https://github.com/navikt/aksel/pull/5344))
- DatePicker: Use without hook now correctly closes picker on date-selection ([#5327](https://github.com/navikt/aksel/pull/5327))
- ActionMenu: Menu is no longer modal, Page scroll and interaction outside the menu are no longer blocked while open. ([#5296](https://github.com/navikt/aksel/pull/5296))
- Updated dependencies []:
- @navikt/ds-tokens@8.18.0
- @navikt/aksel-icons@8.18.0
## 8.17.2
### Patch Changes
- Updated dependencies [[`9673de4`](https://github.com/navikt/aksel/commit/9673de4c264479dd8ca04897003532595acad255)]:
- @navikt/aksel-icons@8.17.2
- @navikt/ds-tokens@8.17.2
## 8.17.1
### Patch Changes
+18 -57
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-react",
"version": "8.20.0",
"version": "8.17.1",
"description": "React components from the Norwegian Labour and Welfare Administration.",
"author": "Aksel, a team part of the Norwegian Labour and Welfare Administration.",
"license": "MIT",
@@ -68,42 +68,22 @@
},
"./PREVIEW/Lookup": {
"import": {
"types": "./esm/lookup/index-deprecated.d.ts",
"default": "./esm/lookup/index-deprecated.js"
"types": "./esm/lookup/index.d.ts",
"default": "./esm/lookup/index.js"
},
"require": {
"types": "./cjs/lookup/index-deprecated.d.ts",
"default": "./cjs/lookup/index-deprecated.js"
"types": "./cjs/lookup/index.d.ts",
"default": "./cjs/lookup/index.js"
}
},
"./PREVIEW/Badge": {
"import": {
"types": "./esm/badge/index-deprecated.d.ts",
"default": "./esm/badge/index-deprecated.js"
"types": "./esm/badge/index.d.ts",
"default": "./esm/badge/index.js"
},
"require": {
"types": "./cjs/badge/index-deprecated.d.ts",
"default": "./cjs/badge/index-deprecated.js"
}
},
"./PREVIEW/RadioCard": {
"import": {
"types": "./esm/form/radio-card/index.d.ts",
"default": "./esm/form/radio-card/index.js"
},
"require": {
"types": "./cjs/form/radio-card/index.d.ts",
"default": "./cjs/form/radio-card/index.js"
}
},
"./PREVIEW/CheckboxCard": {
"import": {
"types": "./esm/form/checkbox-card/index.d.ts",
"default": "./esm/form/checkbox-card/index.js"
},
"require": {
"types": "./cjs/form/checkbox-card/index.d.ts",
"default": "./cjs/form/checkbox-card/index.js"
"types": "./cjs/badge/index.d.ts",
"default": "./cjs/badge/index.js"
}
},
"./types/theme": {
@@ -739,26 +719,6 @@
"default": "./cjs/dialog/index.js"
}
},
"./Lookup": {
"import": {
"types": "./esm/lookup/index.d.ts",
"default": "./esm/lookup/index.js"
},
"require": {
"types": "./cjs/lookup/index.d.ts",
"default": "./cjs/lookup/index.js"
}
},
"./Badge": {
"import": {
"types": "./esm/badge/index.d.ts",
"default": "./esm/badge/index.js"
},
"require": {
"types": "./cjs/badge/index.d.ts",
"default": "./cjs/badge/index.js"
}
},
"./package.json": "./package.json",
"./Theme": {
"import": {
@@ -788,8 +748,8 @@
"dependencies": {
"@floating-ui/react": "0.27.20",
"@floating-ui/react-dom": "^2.1.9",
"@navikt/aksel-icons": "^8.20.0",
"@navikt/ds-tokens": "^8.20.0",
"@navikt/aksel-icons": "^8.17.1",
"@navikt/ds-tokens": "^8.17.1",
"date-fns": "^4.0.0",
"react-day-picker": "10.0.1"
},
@@ -797,22 +757,23 @@
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@testing-library/user-event": "^14.5.2",
"@types/jscodeshift": "17.3.0",
"@types/node": "^24.10.9",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"concurrently": "10.0.6",
"aksel": "workspace:^",
"concurrently": "10.0.4",
"copyfiles": "^2.4.1",
"cross-env": "^10.1.0",
"jscodeshift": "17.4.0",
"jsdom": "29.1.1",
"react": "19.3.0",
"react-dom": "19.3.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"rimraf": "6.1.3",
"swr": "^2.4.2",
"tsc-alias": "1.9.7",
"tsx": "^4.23.15",
"tsc-alias": "1.9.1",
"tsx": "^4.23.1",
"typescript": "6.0.3",
"vitest": "4.1.11"
},
@@ -1,10 +1,10 @@
import { describe, expect, test } from "vitest";
import { type ParsedMeta, readMetaFiles } from "./parse-meta";
import { type ParsedMeta, parseMetaFiles } from "./parse-meta";
import { validateMetas } from "./validate-meta";
describe("component metadata files", () => {
test("all meta files satisfy the metadata spec", () => {
const metas = readMetaFiles();
const metas = parseMetaFiles();
expect(validateMetas(metas)).toEqual([]);
});
});
+87 -150
View File
@@ -1,5 +1,4 @@
import fg from "fast-glob";
import { readFileSync } from "node:fs";
import path from "node:path";
import ts from "typescript";
import { META_GLOB, packageRoot, tsconfigPath } from "./paths";
@@ -35,21 +34,6 @@ interface ParsedMeta {
utils: ResolvedEntry[];
}
/**
* Syntax-only contents of one `*.meta.ts` file. `components`/`utils` hold the
* map labels, e.g. `"Accordion.Item"`, without resolving their declarations.
*/
interface MetaSummary extends Omit<ParsedMeta, "components" | "utils"> {
components: string[];
utils: string[];
}
/** Syntactic parts of a meta file, shared by the summary and full parsers. */
interface MetaSource extends Omit<ParsedMeta, "components" | "utils"> {
componentsMap: ts.ObjectLiteralExpression;
utilsMap: ts.ObjectLiteralExpression | undefined;
}
/**
* Extracts exported "metadata" object through AST
*/
@@ -162,122 +146,14 @@ function createTsProgram(metaFiles: string[]): ts.Program {
});
}
/**
* Returns the display label of a `components`/`utils` map entry, or
* `undefined` for entries that are neither `Name` nor `"Label": Name`.
*/
function getEntryLabel(entry: ts.ObjectLiteralElementLike) {
if (ts.isShorthandPropertyAssignment(entry)) {
return entry.name.text;
}
if (ts.isPropertyAssignment(entry) && ts.isIdentifier(entry.initializer)) {
return ts.isStringLiteral(entry.name)
? entry.name.text
: entry.name.getText();
}
return undefined;
}
/**
* Reads a `components`/`utils` map from the metadata object. Throws when the
* property exists but is not an object literal.
*/
function readEntryMap(
metadataNode: ts.ObjectLiteralExpression,
key: "components" | "utils",
relMeta: string,
) {
const prop = getObjectProperty(metadataNode, key);
if (!prop) {
return undefined;
}
if (
!ts.isPropertyAssignment(prop) ||
!ts.isObjectLiteralExpression(prop.initializer)
) {
throw new Error(`[${relMeta}] metadata.${key} must be an object literal.`);
}
return prop.initializer;
}
/**
* Reads the syntactic parts of a meta file. Needs no type information, so it
* works on a standalone `ts.SourceFile`.
*/
function readMetaSource(
sourceFile: ts.SourceFile,
metaFile: string,
): MetaSource {
const relMeta = path.relative(packageRoot, metaFile);
const metadataNode = findMetadataObject(sourceFile);
if (!metadataNode) {
throw new Error(`[${relMeta}] Could not find a "metadata" object.`);
}
const name = readStringLiteralProperty(metadataNode, "name");
if (!name) {
throw new Error(`[${relMeta}] metadata.name must be a string literal.`);
}
const componentsMap = readEntryMap(metadataNode, "components", relMeta);
if (!componentsMap) {
throw new Error(
`[${relMeta}] metadata.components must be an object literal.`,
);
}
return {
name,
dir: path.relative(packageRoot, path.dirname(metaFile)),
metaFile: relMeta,
keywords: readStringArrayProperty(metadataNode, "keywords"),
related: readStringArrayProperty(metadataNode, "related"),
componentsMap,
utilsMap: readEntryMap(metadataNode, "utils", relMeta),
};
}
function findMetaFiles() {
return fg.sync(META_GLOB, { cwd: packageRoot, absolute: true }).sort();
}
/**
* Reads every `*.meta.ts` file under the package without building a
* TypeScript program. Much faster than {@link parseMetaFiles}, but entries are
* not resolved to declarations. Use for spec validation only.
*/
function readMetaFiles(): MetaSummary[] {
const getLabels = (map: ts.ObjectLiteralExpression) =>
map.properties
.map(getEntryLabel)
.filter((label): label is string => Boolean(label));
return findMetaFiles().map((metaFile) => {
const sourceFile = ts.createSourceFile(
metaFile,
readFileSync(metaFile, "utf8"),
ts.ScriptTarget.Latest,
true,
);
const { componentsMap, utilsMap, ...meta } = readMetaSource(
sourceFile,
metaFile,
);
return {
...meta,
components: getLabels(componentsMap),
utils: utilsMap ? getLabels(utilsMap) : [],
};
});
}
/**
* Reads every `*.meta.ts` file under the package and resolves its
* `components`/`utils` identifiers to their declarations.
*/
function parseMetaFiles(): ParsedMeta[] {
const metaFiles = findMetaFiles();
const metaFiles = fg
.sync(META_GLOB, { cwd: packageRoot, absolute: true })
.sort();
if (metaFiles.length === 0) {
return [];
@@ -301,19 +177,30 @@ function parseMetaFiles(): ParsedMeta[] {
const entries: ResolvedEntry[] = [];
for (const entry of mapExpression.properties) {
const label = getEntryLabel(entry);
let label: string | undefined;
let symbol: ts.Symbol | undefined;
if (ts.isShorthandPropertyAssignment(entry)) {
label = entry.name.text;
symbol = resolveAliasedSymbol(
typeChecker.getShorthandAssignmentValueSymbol(entry),
);
} else if (
ts.isPropertyAssignment(entry) &&
ts.isIdentifier(entry.initializer)
) {
label = ts.isStringLiteral(entry.name)
? entry.name.text
: entry.name.getText();
symbol = resolveAliasedSymbol(
typeChecker.getSymbolAtLocation(entry.initializer),
);
}
if (!label) {
continue;
}
let symbol: ts.Symbol | undefined;
if (ts.isShorthandPropertyAssignment(entry)) {
symbol = typeChecker.getShorthandAssignmentValueSymbol(entry);
} else if (ts.isPropertyAssignment(entry)) {
symbol = typeChecker.getSymbolAtLocation(entry.initializer);
}
symbol = resolveAliasedSymbol(symbol);
const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0];
if (!symbol || !declaration) {
throw new Error(
@@ -335,24 +222,74 @@ function parseMetaFiles(): ParsedMeta[] {
return entries;
}
return metaFiles.map((metaFile) => {
const sourceFile = tsProgram.getSourceFile(metaFile);
if (!sourceFile) {
/**
* Reads a `components`/`utils` map from the metadata object and resolves its
* entries. Throws when a required map is missing or either map is not an
* object literal.
*/
function resolveEntryMap(
metadataNode: ts.ObjectLiteralExpression,
key: "components" | "utils",
metaFile: string,
relMeta: string,
required: boolean,
): ResolvedEntry[] {
const prop = getObjectProperty(metadataNode, key);
if (!prop) {
if (required) {
throw new Error(
`[${relMeta}] metadata.${key} must be an object literal.`,
);
}
return [];
}
if (
!ts.isPropertyAssignment(prop) ||
!ts.isObjectLiteralExpression(prop.initializer)
) {
throw new Error(
`Could not load meta file: ${path.relative(packageRoot, metaFile)}`,
`[${relMeta}] metadata.${key} must be an object literal.`,
);
}
const { componentsMap, utilsMap, ...meta } = readMetaSource(
sourceFile,
metaFile,
);
return resolveEntries(prop.initializer, metaFile);
}
function parseMetaFile(metaFile: string): ParsedMeta {
const relMeta = path.relative(packageRoot, metaFile);
const sourceFile = tsProgram.getSourceFile(metaFile);
if (!sourceFile) {
throw new Error(`Could not load meta file: ${relMeta}`);
}
const metadataNode = findMetadataObject(sourceFile);
if (!metadataNode) {
throw new Error(`[${relMeta}] Could not find a "metadata" object.`);
}
const name = readStringLiteralProperty(metadataNode, "name");
if (!name) {
throw new Error(`[${relMeta}] metadata.name must be a string literal.`);
}
return {
...meta,
components: resolveEntries(componentsMap, metaFile),
utils: utilsMap ? resolveEntries(utilsMap, metaFile) : [],
name,
dir: path.relative(packageRoot, path.dirname(metaFile)),
metaFile: relMeta,
keywords: readStringArrayProperty(metadataNode, "keywords"),
related: readStringArrayProperty(metadataNode, "related"),
components: resolveEntryMap(
metadataNode,
"components",
metaFile,
relMeta,
true,
),
utils: resolveEntryMap(metadataNode, "utils", metaFile, relMeta, false),
};
});
}
return metaFiles.map(parseMetaFile);
}
export { parseMetaFiles, readMetaFiles };
export type { ResolvedEntry, ParsedMeta, MetaSummary };
export { parseMetaFiles };
export type { ResolvedEntry, ParsedMeta };
@@ -1,4 +1,4 @@
import type { MetaSummary, ParsedMeta } from "./parse-meta";
import type { ParsedMeta } from "./parse-meta";
/** Characters allowed in a Sanity document `_id`. */
const SANITY_ID_PATTERN = /^[A-Za-z0-9_.-]+$/;
@@ -9,7 +9,7 @@ const SANITY_ID_PATTERN = /^[A-Za-z0-9_.-]+$/;
* valid). Kept pure so it can be exercised both by the extractor (which throws
* on any error) and by the test-suite.
*/
function validateMetas(metas: (ParsedMeta | MetaSummary)[]): string[] {
function validateMetas(metas: ParsedMeta[]): string[] {
const errors: string[] = [];
const nameToMeta = new Map<string, string>();
@@ -102,7 +102,7 @@ const ActionMenuRoot = ({
rootElement={rootElement}
size={size}
>
<Menu open={open} onOpenChange={setOpen} modal={false}>
<Menu open={open} onOpenChange={setOpen} modal>
{children}
</Menu>
</ActionMenuProvider>
+10 -10
View File
@@ -19,12 +19,12 @@ export default meta;
type Story = StoryObj<typeof Badge>;
const statuses: AkselColor[] = [
"danger",
"success",
"info",
"warning",
"neutral",
const statuses: { color: AkselColor; label: string }[] = [
{ color: "danger", label: "Avslått" },
{ color: "success", label: "Innvilget" },
{ color: "info", label: "Under behandling" },
{ color: "warning", label: "Mangler dokumentasjon" },
{ color: "neutral", label: "Ikke startet" },
];
export const Default: Story = {
@@ -37,8 +37,8 @@ export const Default: Story = {
export const Dot: Story = {
render: () => (
<HStack gap="space-16" align="center">
{statuses.map((color) => (
<Badge key={color} data-color={color} />
{statuses.map(({ color, label }) => (
<Badge key={color} data-color={color} aria-label={label} />
))}
</HStack>
),
@@ -58,13 +58,13 @@ export const Count: Story = {
export const Anchored: Story = {
render: () => (
<HStack gap="space-32" align="center">
<Badge data-color="danger" count={42} aria-hidden>
<Badge data-color="danger" count={42}>
<Button
icon={<InboxIcon aria-hidden />}
aria-label="Innboks, 42 nye meldinger"
/>
</Badge>
<Badge data-color="danger" aria-hidden>
<Badge data-color="danger" aria-label="Nytt varsel">
<Button icon={<InboxIcon aria-hidden />} aria-label="Innboks" />
</Badge>
</HStack>
+20 -13
View File
@@ -35,8 +35,9 @@ interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
* Use standalone for inline indicators/counts, or pass the element you want to
* anchor it to as `children` to pin the badge to one of its corners.
*
* When anchoring to an interactive element,
* set `aria-hidden` on the badge and fold the count into that element's accessible name
* Accessibility: A badge with no label is treated as decorative when it is a
* dot or anchored to another element. When anchoring to an interactive element,
* fold the state into that element's accessible name
* (e.g. `aria-label="Inbox, 42 new messages"`).
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/badge)
@@ -45,11 +46,11 @@ interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
* @example
* ```jsx
* // Standalone
* <Badge data-color="success" />
* <Badge data-color="success" aria-label="Active" />
* <Badge data-color="danger" count={42} />
*
* // Anchored to an element
* <Badge data-color="danger" count={42} aria-hidden>
* <Badge data-color="danger" count={42}>
* <Button icon={<InboxIcon />} aria-label="Inbox, 42 new messages" />
* </Badge>
* ```
@@ -60,6 +61,10 @@ const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
children,
className,
"data-color": color = "danger",
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledby,
role,
"aria-hidden": ariaHidden,
count,
maxCount = 99,
...rest
@@ -70,6 +75,10 @@ const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
count == null ? undefined : normalizeCount(count, 0);
const normalizedMaxCount = normalizeCount(maxCount, 99);
const isDot = normalizedCount == null;
const hasLabel =
Boolean(ariaLabel?.trim()) || Boolean(ariaLabelledby?.trim());
const isDecorative =
role == null && !hasLabel && (isDot || children != null);
const badge = (
<BodyShort
@@ -80,6 +89,10 @@ const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
weight="semibold"
data-color={color}
data-dot={isDot || undefined}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
role={role}
aria-hidden={ariaHidden ?? (isDecorative || undefined)}
className={cl("aksel-badge", className)}
>
{isDot
@@ -107,16 +120,10 @@ function normalizeCount(value: number, fallback: number) {
return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : fallback;
}
// eslint-disable-next-line @typescript-eslint/no-namespace
// eslint-disable-next-line @typescript-eslint/no-namespace, import/export
export namespace Badge {
export type Props = BadgeProps;
}
/** @deprecated Badge is no longer in preview. Import it from `@navikt/ds-react` instead. */
const BadgeDeprecated = Badge;
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace BadgeDeprecated {
export type Props = BadgeProps;
}
export { Badge, BadgeDeprecated };
// eslint-disable-next-line import/export
export { Badge };
@@ -1,2 +0,0 @@
"use client";
export { BadgeDeprecated as Badge } from "./Badge";
+2 -2
View File
@@ -105,9 +105,9 @@ export const Button: OverridableComponent<ButtonProps, HTMLButtonElement> =
className={cl(className, "aksel-button", `aksel-button--${size}`, {
"aksel-button--loading": loading,
"aksel-button--icon-only": !!icon && !children,
"aksel-button--disabled": disabled || loading,
"aksel-button--disabled": disabled ?? loading,
})}
disabled={disabled || loading ? true : undefined}
disabled={(disabled ?? loading) ? true : undefined}
>
{icon && iconPosition === "left" && (
<span className="aksel-button__icon">{icon}</span>
@@ -221,11 +221,12 @@ function isFieldVisible(
return fields?.[key] !== false;
}
// eslint-disable-next-line @typescript-eslint/no-namespace
// eslint-disable-next-line @typescript-eslint/no-namespace, import/export
export namespace DataGridPreferencesRoot {
export type Props = DataGridPreferencesProps;
}
export type { DataGridPreferencesProps };
// eslint-disable-next-line import/export
export { DataGridPreferencesRoot };
export default DataGridPreferencesRoot;
@@ -140,7 +140,7 @@ const DataGrid = DataGridInternal as unknown as DataGridComponent;
DataGrid.Table = DataGridTable;
DataGrid.Preferences = DataGridPreferences;
// eslint-disable-next-line @typescript-eslint/no-namespace
// eslint-disable-next-line @typescript-eslint/no-namespace, import/export
export namespace DataGrid {
export type Props<T = unknown> = DataGridProps<T>;
export type Columns<T = unknown> = ColumnDefinitions<T>;
@@ -166,5 +166,6 @@ export namespace DataGrid {
}
}
// eslint-disable-next-line import/export
export { DataGrid, DataGridInternal }; // DataGridRoot needs to be exported b.c. of docgen
export default DataGrid;
@@ -553,7 +553,7 @@ const DataGridTable = DataGridTableInternal as <RowT>(
props: DataGridTableProps<RowT> & React.RefAttributes<HTMLTableElement>,
) => React.ReactElement | null;
// eslint-disable-next-line @typescript-eslint/no-namespace
// eslint-disable-next-line @typescript-eslint/no-namespace, import/export
export namespace DataGridTable {
export type Props<T = unknown> = DataGridTableProps<T>;
export type Sorting = TableSortOptions;
@@ -566,6 +566,7 @@ export namespace DataGridTable {
}
// docgen doesn't work well with type params, so we let it use DataGridTableInternal instead
// eslint-disable-next-line import/export
export { DataGridTable, DataGridTableInternal };
export type { DataGridTableProps };
export default DataGridTable;
@@ -86,9 +86,7 @@ const AutoSuggest = forwardRef<HTMLInputElement, AutoSuggestProps>(
<Floating>
<Listbox setVirtuallyFocusedOptionId={setVirtuallyFocusedOptionId}>
<Floating.Anchor>
<Listbox.InputSlot
virtuallyFocusedOptionId={virtuallyFocusedOptionId}
>
<Listbox.InputSlot>
<Search
label="Tabellsøk"
variant="simple"
@@ -64,7 +64,7 @@ const DatePickerRoot = forwardRef<HTMLDivElement, DatePickerProps>(
onClose,
onOpenToggle,
strategy,
mode: _mode,
mode,
...rest
},
ref,
@@ -83,8 +83,6 @@ const DatePickerRoot = forwardRef<HTMLDivElement, DatePickerProps>(
value: _open,
});
const mode = _mode ?? "single";
const datePickerOpener = useRef<"from" | "to" | null>(null);
/* We use state here to insure that anchor is defined if open is true on initial render */
@@ -144,7 +142,7 @@ const DatePickerRoot = forwardRef<HTMLDivElement, DatePickerProps>(
}}
locale={locale}
translate={translate}
variant={mode}
variant={mode ?? "single"}
popoverProps={{
id: ariaId,
strategy,
@@ -107,18 +107,6 @@ function DisabledFormElements() {
<TextField label="Second field" />
</Fieldset>
<Fieldset legend="Nested Fieldset (outer disabled)" disabled>
<Fieldset legend="Inner fieldset">
<TextField label="Nested field" />
<Switch>Nested switch</Switch>
</Fieldset>
</Fieldset>
<Fieldset legend="Fieldset (readOnly)" readOnly>
<TextField label="TextField" defaultValue="Read-only value" />
<Textarea label="Textarea" defaultValue="Read-only value" />
</Fieldset>
<FileUpload.Dropzone
label="FileUpload Dropzone"
onSelect={console.log}
@@ -1,14 +0,0 @@
import type { ComponentMetadata } from "../../utils/types/metadata";
import { CheckboxCard, CheckboxCardGroup } from "./index";
const metadata: ComponentMetadata = {
name: "CheckboxCard",
components: {
CheckboxCard,
CheckboxCardGroup,
},
keywords: ["checkbox", "avkrysningsboks", "form", "input", "choice", "card"],
related: ["Checkbox"],
};
export { metadata };
@@ -1,129 +0,0 @@
import type { Meta } from "@storybook/react-vite";
import React from "react";
import { HGrid } from "../../primitives/grid";
import { renderStoriesForChromatic } from "../../utils/renderStoriesForChromatic";
import { CheckboxCard } from "./CheckboxCard";
import { CheckboxCardGroup } from "./CheckboxCardGroup";
const meta: Meta<typeof CheckboxCardGroup> = {
title: "ds-react/CheckboxCard",
component: CheckboxCardGroup,
subcomponents: { CheckboxCard },
parameters: {
chromatic: { disable: true },
},
};
export default meta;
export const Default = () => (
<CheckboxCardGroup
legend="Velg varslingskanaler"
description="Du kan velge flere alternativer."
>
<CheckboxCard value="sms" description="Sendes til mobilnummeret ditt.">
SMS
</CheckboxCard>
<CheckboxCard value="epost" description="Sendes til e-postadressen din.">
E-post
</CheckboxCard>
</CheckboxCardGroup>
);
export const HorizontalLayout = () => (
<CheckboxCardGroup
legend="Velg varslingskanaler"
description="Du kan velge flere alternativer."
defaultValue={["sms"]}
size="small"
>
<HGrid columns={{ xs: 1, md: 3 }}>
<CheckboxCard value="sms" description="Sendes til mobilnummeret ditt.">
SMS
</CheckboxCard>
<CheckboxCard value="epost" description="Sendes til e-postadressen din.">
E-post
</CheckboxCard>
<CheckboxCard
value="brev"
description="Sendes til folkeregistrert adresse."
>
Brev
</CheckboxCard>
</HGrid>
</CheckboxCardGroup>
);
export const Small = () => (
<CheckboxCardGroup legend="Velg varslingskanaler" size="small">
<CheckboxCard value="sms" description="Sendes til mobilnummeret ditt.">
SMS
</CheckboxCard>
<CheckboxCard value="epost" description="Sendes til e-postadressen din.">
E-post
</CheckboxCard>
</CheckboxCardGroup>
);
export const ErrorProp = () => (
<CheckboxCardGroup
legend="Velg varslingskanaler"
error="Du må velge minst én varslingskanal."
defaultValue={["sms"]}
>
<CheckboxCard value="sms" description="Sendes til mobilnummeret ditt.">
SMS
</CheckboxCard>
<CheckboxCard value="epost" description="Sendes til e-postadressen din.">
E-post
</CheckboxCard>
</CheckboxCardGroup>
);
export const Readonly = () => (
<CheckboxCardGroup
legend="Valgte varslingskanaler"
defaultValue={["sms"]}
readOnly
>
<CheckboxCard value="sms" description="Sendes til mobilnummeret ditt.">
SMS
</CheckboxCard>
<CheckboxCard value="epost" description="Sendes til e-postadressen din.">
E-post
</CheckboxCard>
</CheckboxCardGroup>
);
export const Disabled = () => (
<CheckboxCardGroup
legend="Velg varslingskanaler"
defaultValue={["sms"]}
disabled
>
<CheckboxCard value="sms" description="Sendes til mobilnummeret ditt.">
SMS
</CheckboxCard>
<CheckboxCard value="epost" description="Sendes til e-postadressen din.">
E-post
</CheckboxCard>
</CheckboxCardGroup>
);
export const Chromatic = renderStoriesForChromatic({
Default,
HorizontalLayout,
Small,
ErrorProp,
Readonly,
Disabled,
});
export const ChromaticDark = renderStoriesForChromatic({
Default,
HorizontalLayout,
Small,
ErrorProp,
Readonly,
Disabled,
});
ChromaticDark.globals = { theme: "dark" };
@@ -1,41 +0,0 @@
import React, { forwardRef } from "react";
import { cl } from "../../utils/helpers";
import { Checkbox, CheckboxCardContextProvider } from "../checkbox/Checkbox";
import type { CheckboxProps } from "../checkbox/types";
type CheckboxCardProps = CheckboxProps;
/**
* Checkbox option styled as a card.
* @see 🏷️ {@link CheckboxCardProps}
* @example
* ```tsx
* <CheckboxCardGroup legend="Velg varslingskanaler">
* <CheckboxCard value="sms" description="Sendes til mobilnummeret ditt.">
* SMS
* </CheckboxCard>
* <CheckboxCard value="epost">E-post</CheckboxCard>
* </CheckboxCardGroup>
* ```
*/
const CheckboxCard = forwardRef<HTMLInputElement, CheckboxCardProps>(
({ className, ...rest }, ref) => {
return (
<CheckboxCardContextProvider>
<Checkbox
{...rest}
ref={ref}
className={cl("aksel-checkbox-card", className)}
/>
</CheckboxCardContextProvider>
);
},
);
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace CheckboxCard {
export type Props = CheckboxCardProps;
}
export { CheckboxCard };
export type { CheckboxCardProps };
@@ -1,37 +0,0 @@
import React, { forwardRef } from "react";
import { cl } from "../../utils/helpers";
import CheckboxGroup, {
type CheckboxGroupProps,
} from "../checkbox/CheckboxGroup";
type CheckboxCardGroupProps = CheckboxGroupProps;
/**
* CheckboxGroup for `<CheckboxCard />` elements. Horizontal layout is best suited for a small number of checkbox cards.
* @see 🏷️ {@link CheckboxCardGroupProps}
* @example
* ```tsx
* <CheckboxCardGroup legend="Velg varslingskanaler">
* <CheckboxCard value="sms">SMS</CheckboxCard>
* <CheckboxCard value="epost">E-post</CheckboxCard>
* </CheckboxCardGroup>
* ```
*/
const CheckboxCardGroup = forwardRef<
HTMLFieldSetElement,
CheckboxCardGroupProps
>(({ className, ...rest }, ref) => (
<CheckboxGroup
{...rest}
ref={ref}
className={cl("aksel-checkbox-card-group", className)}
/>
));
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace CheckboxCardGroup {
export type Props = CheckboxCardGroupProps;
}
export { CheckboxCardGroup };
export type { CheckboxCardGroupProps };
@@ -1,3 +0,0 @@
"use client";
export { CheckboxCard } from "./CheckboxCard";
export { CheckboxCardGroup } from "./CheckboxCardGroup";
@@ -88,22 +88,3 @@ describe("Checkbox handles controlled-state correctly", () => {
);
});
});
describe("Checkbox outside CheckboxCard", () => {
test("does not toggle when clicking description", () => {
render(
<CheckboxGroup legend="legend">
<Checkbox value="value1" description="Checkbox description">
label1
</Checkbox>
</CheckboxGroup>,
);
fireEvent.click(screen.getByText("Checkbox description"));
expect(
screen.getByRole<HTMLInputElement>("checkbox", { name: "label1" })
.checked,
).toBe(false);
});
});
@@ -1,33 +1,21 @@
import React, { forwardRef, useRef } from "react";
import React, { forwardRef } from "react";
import { BodyShort } from "../../typography";
import { omit, useId } from "../../utils-external";
import { cl, createStrictContext } from "../../utils/helpers";
import { useMergeRefs } from "../../utils/hooks";
import { cl } from "../../utils/helpers";
import { ReadOnlyIconWithTitle } from "../ReadOnlyIcon";
import { CheckboxInput } from "./checkbox-input/CheckboxInput";
import type { CheckboxProps } from "./types";
import useCheckbox from "./useCheckbox";
const {
Provider: CheckboxCardContextProvider,
useContext: useCheckboxCardContext,
} = createStrictContext({ name: "checkbox-card" });
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
(props: CheckboxProps, forwardedRef) => {
const { inputProps, hasError, size, readOnly, nested } = useCheckbox(props);
const descriptionId = useId();
const cardContext = useCheckboxCardContext(false);
const checkboxRef = useRef<HTMLInputElement>(null);
const mergedRefs = useMergeRefs(forwardedRef, checkboxRef);
const { className, description, children, indeterminate, hideLabel } =
props;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: clickable div for checkbox card
// biome-ignore lint/a11y/useKeyWithClickEvents: clickable div for checkbox card
<div
className={cl(className, "aksel-checkbox", `aksel-checkbox--${size}`, {
"aksel-checkbox--error": hasError,
@@ -35,26 +23,9 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
"aksel-checkbox--readonly": readOnly,
})}
data-color={hasError ? "danger" : props["data-color"]}
onClick={(event) => {
if (inputProps.disabled || readOnly || !cardContext) {
return;
}
/* Let input and label handle their own click events */
const target = event.target;
const nativeHandler =
target instanceof Element ? target.closest("label, input") : null;
if (nativeHandler && event.currentTarget.contains(nativeHandler)) {
return;
}
checkboxRef.current?.click();
checkboxRef.current?.focus({ preventScroll: true });
}}
>
<CheckboxInput
ref={mergedRefs}
ref={forwardedRef}
{...omit(props, [
"children",
"size",
@@ -101,4 +72,3 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
);
export default Checkbox;
export { CheckboxCardContextProvider };
@@ -21,19 +21,20 @@ import { ComboboxTrigger } from "./trigger/ComboboxTrigger";
interface ComboboxProps<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>
extends Omit<ComboboxRootProps<T>, "children">, Omit<FormFieldProps, "size"> {
/**
* Label
*/
extends
Omit<
ComboboxRootProps<T>,
"children" | "selectedOptions" | "onToggleOption"
>,
Partial<Pick<ComboboxRootProps<T>, "selectedOptions" | "onToggleOption">>,
Omit<FormFieldProps, "size"> {
label: string;
/**
* Hides the label and description visually while keeping them available to screen readers.
*/
hideLabel?: boolean;
/**
* Field name. When provided, the field value will be included in form submissions.
*/
name?: string;
defaultSelectedOptions?: ComboboxRootProps<T>["selectedOptions"];
onSelectedOptionsChange?: (
newSelectedOptions: ComboboxRootProps<T>["selectedOptions"],
) => void;
}
function Combobox<
@@ -42,6 +43,8 @@ function Combobox<
label,
hideLabel,
name,
defaultSelectedOptions,
onSelectedOptionsChange,
size: sizeProp,
error,
errorId: errorIdProp,
@@ -49,7 +52,9 @@ function Combobox<
description,
id,
readOnly: readOnlyProp,
multiple,
multiselect = false, // Default value should be synced with ComboboxRoot
selectedOptions: selectedOptionsProp,
onToggleOption: onToggleOptionProp,
...rest
}: ComboboxProps<T>) {
const {
@@ -73,11 +78,35 @@ function Combobox<
"combobox",
);
const [selectedOptions, setSelectedOptions] = useControllableState({
defaultValue: defaultSelectedOptions || [],
value: selectedOptionsProp,
onChange: onSelectedOptionsChange,
});
const onToggleOption: ComboboxRootProps<T>["onToggleOption"] = (
option,
isSelected,
) => {
onToggleOptionProp?.(option, isSelected);
setSelectedOptions((prevSelectedOptions) => {
if (!multiselect) {
return [option.value];
}
if (isSelected) {
return [...prevSelectedOptions, option.value];
}
return prevSelectedOptions.filter((value) => value !== option.value);
});
};
return (
<ComboboxRoot
size={size}
disabled={inputProps.disabled}
multiple={multiple}
multiselect={multiselect}
selectedOptions={selectedOptions}
onToggleOption={onToggleOption}
{...rest}
>
<ComboboxLabel
@@ -117,8 +146,8 @@ function Combobox<
</div>
{name &&
rest.value.map((val) => (
<input key={val} type="hidden" name={name} value={val} />
selectedOptions.map((value) => (
<input key={value} type="hidden" name={name} value={value} />
))}
<ComboboxOverlay
@@ -140,126 +169,5 @@ function Combobox<
);
}
// ----------- ComboboxSingle ----------- //
interface ComboboxSingleProps<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
> extends Omit<ComboboxProps<T>, "multiple" | "value" | "onToggleOption"> {
/**
* Called when selected option changes.
*/
onChange?: (
newValue: NonNullable<ComboboxProps<T>["value"]>[number] | undefined,
) => void;
/**
* Value of the selected option.
*/
value?: NonNullable<ComboboxProps<T>["value"]>[number];
/**
* Initial value when uncontrolled.
*/
defaultValue?: NonNullable<ComboboxProps<T>["value"]>[number];
}
function ComboboxSingle<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>({ onChange, value, defaultValue, ...rest }: ComboboxSingleProps<T>) {
const [localValue, setLocalValue] = useControllableState({
defaultValue,
value,
onChange,
});
const onToggleOption: ComboboxProps<T>["onToggleOption"] = (
option,
isSelected,
) => {
setLocalValue(isSelected ? option.value : undefined);
}; // TODO: Dumt at ikke får hele option-objektet... Legge til i onChange? Hvis vi tilbyr onToggle så blir det så likt onChange...
return (
<Combobox
{...rest}
value={localValue ? [localValue] : []}
defaultValue={defaultValue ? [defaultValue] : []}
multiple={false}
onToggleOption={onToggleOption}
/>
);
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace ComboboxSingle {
export type Props<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
> = ComboboxSingleProps<T>;
}
// ----------- ComboboxMulti ----------- //
interface ComboboxMultiProps<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>
extends
Omit<ComboboxProps<T>, "multiple" | "value" | "onToggleOption">,
Partial<Pick<ComboboxProps<T>, "onToggleOption">> {
/**
* Called when selected options change.
*/
onChange?: (newValue: ComboboxProps<T>["value"]) => void;
/**
* Value of the selected options.
*/
value?: ComboboxProps<T>["value"];
/**
* Initial value when uncontrolled.
*/
defaultValue?: ComboboxProps<T>["value"];
}
function ComboboxMulti<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>({
onChange,
value,
defaultValue,
onToggleOption,
...rest
}: ComboboxMultiProps<T>) {
const [localValue, setLocalValue] = useControllableState({
defaultValue: defaultValue || [],
value,
onChange,
});
const localOnToggleOption: ComboboxProps<T>["onToggleOption"] = (
option,
isSelected,
) => {
onToggleOption?.(option, isSelected);
setLocalValue((prevSelectedOptions) =>
isSelected
? [...prevSelectedOptions, option.value]
: prevSelectedOptions.filter((optVal) => optVal !== option.value),
);
};
return (
<Combobox
{...rest}
value={localValue}
defaultValue={defaultValue}
multiple
onToggleOption={localOnToggleOption}
/>
);
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace ComboboxMulti {
export type Props<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
> = ComboboxMultiProps<T>;
}
export { ComboboxSingle, ComboboxMulti };
export { Combobox };
export type { ComboboxProps };
@@ -3,7 +3,8 @@ import React, { useMemo, useState } from "react";
import { Button } from "../../button";
import { Dialog } from "../../dialog";
import { HStack, VStack } from "../../primitives/stack";
import { ComboboxMulti, ComboboxSingle } from "./Combobox";
import { Select } from "../select";
import { Combobox, type ComboboxProps } from "./Combobox";
import { ComboboxField } from "./field/ComboboxField";
import { ComboboxFilter } from "./filter/ComboboxFilter";
import { ComboboxLabel } from "./label/ComboboxLabel";
@@ -17,9 +18,9 @@ import {
} from "./root/ComboboxRoot";
import { ComboboxTrigger } from "./trigger/ComboboxTrigger";
const meta: Meta<typeof ComboboxSingle> = {
const meta: Meta<typeof Combobox> = {
title: "ds-react/Combobox2",
component: ComboboxSingle,
component: Combobox,
parameters: {
chromatic: { disable: true },
},
@@ -76,12 +77,13 @@ const groupedCountries: (MyGroup | MyOption)[] = [
];
type DefaultProps = Pick<
ComboboxSingle.Props<MyOption>,
ComboboxProps<MyOption>,
| "label"
| "description"
| "hideLabel"
| "size"
| "defaultOpen"
| "multiselect"
| "id"
| "readOnly"
| "disabled"
@@ -89,13 +91,14 @@ type DefaultProps = Pick<
>;
export const Default: StoryFn<DefaultProps> = (props) => {
return <ComboboxSingle options={countries} {...props} />;
return <Combobox defaultOpen options={countries} {...props} />;
};
Default.args = {
label: "Velg land",
description: "Landet hvor du er født.",
hideLabel: false,
defaultOpen: false,
multiselect: false,
readOnly: false,
disabled: false,
error: "",
@@ -110,21 +113,17 @@ Default.argTypes = {
},
};
export const Multiselect = () => {
return <ComboboxMulti label="Velg land" options={countries} />;
};
Multiselect.storyName = "Multiselect (WIP)";
function BasicCombobox<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData> =
MyOption,
>(props: Partial<ComboboxSingle.Props<T>>) {
>(props: Partial<ComboboxProps<T>>) {
return (
<ComboboxSingle
<Combobox
options={countries as T[]}
defaultValue="opt-1"
defaultSelectedOptions={["opt-1"]}
label="Velg land"
description="Landet hvor du er født."
multiselect={false}
{...props}
/>
);
@@ -134,23 +133,20 @@ export const Small = () => <BasicCombobox size="small" error="Error" />;
export const Hidelabel = () => <BasicCombobox hideLabel />;
export const Readonly = () => <BasicCombobox readOnly error="Test" />;
export const Readonly = () => <BasicCombobox readOnly />;
export const Disabled = () => <BasicCombobox disabled error="Test" />;
export const Disabled = () => <BasicCombobox disabled />;
export const ErrorStory = () => (
<VStack gap="space-16">
<BasicCombobox error="Du må velge et land." defaultValue={undefined} />
<BasicCombobox error="Ugyldig land." />
</VStack>
);
export const ErrorStory = () => <BasicCombobox error="Du må velge et land." />;
ErrorStory.storyName = "Error";
export const DefaultOpen = () => <BasicCombobox defaultOpen />;
DefaultOpen.storyName = "DefaultOpen";
export const DefaultValue = () => <BasicCombobox defaultValue="opt-1" />;
DefaultValue.storyName = "DefaultValue";
export const DefaultSelectedOptions = () => (
<BasicCombobox defaultSelectedOptions={["opt-1"]} />
);
DefaultSelectedOptions.storyName = "DefaultSelectedOptions";
export const Name = () => (
<form
@@ -168,19 +164,50 @@ export const Name = () => (
);
export const Controlled = () => {
const [value, setValue] = useState<MyOption["value"] | undefined>("opt-1");
const [selectedOptions, setSelectedOptions] = useState<MyOption["value"][]>([
"opt-1",
]);
return (
<VStack gap="space-8" minWidth="250px">
<ComboboxSingle
<Combobox
options={countries}
value={value}
onChange={setValue}
selectedOptions={selectedOptions}
onToggleOption={(option, newSelected) => {
setSelectedOptions((prev) =>
newSelected
? [...prev, option.value]
: prev.filter((v) => v !== option.value),
);
}}
label="Velg land"
/>
Selected: {value}
Selected: {selectedOptions.join(", ")}
<div>
<button type="button" onClick={() => setValue(undefined)}>
<button type="button" onClick={() => setSelectedOptions([])}>
Clear
</button>
</div>
</VStack>
);
};
export const Controlled2 = () => {
const [selectedOptions, setSelectedOptions] = useState<MyOption["value"][]>([
"opt-1",
]);
return (
<VStack gap="space-8" minWidth="250px">
<Combobox
options={countries}
selectedOptions={selectedOptions}
onSelectedOptionsChange={setSelectedOptions}
label="Velg land"
/>
Selected: {selectedOptions.join(", ")}
<div>
<button type="button" onClick={() => setSelectedOptions([])}>
Clear
</button>
</div>
@@ -211,7 +238,7 @@ export const ManyOptions: StoryFn<ManyOptionsProps> = ({ count }) => {
return (
<div style={{ minHeight: "400px", width: "250px" }}>
<ComboboxSingle options={manyOptions} label="Test" />
<Combobox options={manyOptions} label="Test" />
</div>
);
};
@@ -246,7 +273,7 @@ export const LongLabels = () => (
],
},
]}
value={["opt-1"]}
selectedOptions={["opt-1"]}
onToggleOption={() => {}}
>
<ComboboxField />
@@ -258,13 +285,15 @@ export const LongLabels = () => (
);
export const Composition = () => {
const [value, setSelectedOptions] = useState<MyOption["value"][]>(["opt-1"]);
const [selectedOptions, setSelectedOptions] = useState<MyOption["value"][]>([
"opt-1",
]);
const [selectedOption, setSelectedOption] = useState<MyOption>(countries[0]);
const rootProps = {
options: countries,
value,
selectedOptions,
onToggleOption: (option) => {
setSelectedOptions((prev) =>
prev.includes(option.value)
@@ -289,7 +318,7 @@ export const Composition = () => {
</ComboboxRoot>
<ComboboxRoot {...rootProps}>
<ComboboxTrigger openWhenStartsTyping={false}>
<ComboboxTrigger>
<Button>Uten input</Button>
</ComboboxTrigger>
<ComboboxOverlay>
@@ -301,11 +330,11 @@ export const Composition = () => {
<ComboboxRoot
options={countries}
value={[selectedOption.value]}
selectedOptions={[selectedOption.value]}
onToggleOption={setSelectedOption}
multiple={false}
multiselect={false}
>
<ComboboxTrigger openWhenStartsTyping={false}>
<ComboboxTrigger>
<Button>Single select</Button>
</ComboboxTrigger>
<ComboboxOverlay>
@@ -332,9 +361,9 @@ export const Composition = () => {
<ComboboxRoot
options={countries}
value={[selectedOption.value]}
selectedOptions={[selectedOption.value]}
onToggleOption={setSelectedOption}
multiple={false}
multiselect={false}
>
<ComboboxLabel htmlFor="demo2">Velg land (single select)</ComboboxLabel>
<ComboboxTrigger id="demo2">
@@ -349,7 +378,7 @@ export const Composition = () => {
</ComboboxRoot>
<ComboboxRoot {...rootProps}>
<ComboboxLabel htmlFor="demo3">Velg land (multiple)</ComboboxLabel>
<ComboboxLabel htmlFor="demo3">Velg land (multiselect)</ComboboxLabel>
<ComboboxTrigger id="demo3">
<ComboboxField />
</ComboboxTrigger>
@@ -389,45 +418,77 @@ export const InDialog = () => {
);
};
function WithValidation() {
const [error, setError] = useState("Du må velge et land");
export const Testing = () => {
return (
<BasicCombobox
options={nordicCountries}
defaultValue={undefined}
error={error}
onChange={(newVal) => {
if (!newVal) {
setError("Du må velge et land");
return;
}
setError(newVal === "opt-5" ? "" : "Du må velge Island");
}}
/>
<VStack gap="space-32" width="300px">
<button type="button" onClick={() => console.log("Knapp før")}>
Knapp før
</button>
<Combobox
options={countries}
multiselect={false}
label="Velg land (Combobox)"
description="Beskrivelse"
/>
<Select
label="Velg land (vanlig select)"
description="Dette er en vanlig select."
//error="Test"
>
<option value=""></option>
<option value="norway">Norway</option>
<option value="finland">Finland</option>
<option value="sweden">Sweden</option>
<option value="denmark">Denmark</option>
<option value="iceland">Iceland</option>
<option value="faroe-islands">Faroe Islands</option>
<option value="aland-islands">Åland Islands</option>
<option value="estonia">Estonia</option>
<option value="latvia">Latvia</option>
<option value="lithuania">Lithuania</option>
</Select>
<Select label="Mange alternativer (vanlig select)">
{Array.from({ length: 100 }, (_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: Don't care
<option key={i} value={`opt-${i + 1}`}>
{`Option ${String(i + 1).padStart(4, "0")}`}
</option>
))}
</Select>
</VStack>
);
}
};
export const ScreenreaderTesting = () => (
<div>
<h2>Med grupper</h2>
<BasicCombobox options={groupedCountries} defaultValue={undefined} />
/* TODO:
- Mobil: Vurder å ikke fokusere søkefelt automatisk.
- Mobil: Vurder å ikke ha virtuelt fokus i det hele tatt hvis man ikke har tastatur
Kan kanskje bruke media query, ev. ikke gi noe fokus før man begynner å trykke (som dagens CB. Sjekk hvordan SR takler at ingenting har virtuelt fokus.)
Sjekk om det er mulig å flytte virtuelt fokus uten tastatur (typ med skjermleser).
- Vurder om fokus skal låses til søkefelt (mest aktuelt ved multiselect).
- Åpne på pil ned (og ev. opp)?
- Vurder funksjoner fra gamle CB (ikke brukt: dropp, brukt lite: muliggjør med komposisjon, brukt mye: bygg inn støtte)
- allowNewValues (er dette ofte egentlig Autocomplete?)
- isLoading
- maxSelected
- Skal den hete noe annet enn Combobox?
- Vurder om Label og Description (og error?) skal være sub-komponenter eller ikke.
- Følge Combobox-pattern (mer)? Kan ikke følge det slavisk uansett.
Pil opp og ned velger
Ikke loop
- A11y-sjekk (skjermleser, zoom, høykontrast...)
- Beslutningsloggen?
<h2>Uten grupper</h2>
<BasicCombobox options={nordicCountries} defaultValue={undefined} />
<h2>Label skjult visuelt</h2>
<BasicCombobox options={nordicCountries} hideLabel />
Utfordringer med komposisjon:
- Vanskelig å bruke
- Utfordrende for oss å endre (ref FormSummary)
- Context er litt magisk/uoversiktlig
- Description: Hvordan skal root/trigger vite at aria-describedby skal settes?
Forslag: Tilby enkeltkomponent for de vanligste tilfellene, men også subkomponentene for fleksibilitet.
Kan ev. ha slot/render-props for enkelte ting.
Kan ev. bruke children for å kunne bytte ut/skreddersy innholdet i popup.
<h2>Med feilmelding</h2>
<WithValidation />
<h2>Readonly med verdi</h2>
<BasicCombobox readOnly />
<h2>Readonly uten verdi</h2>
<BasicCombobox readOnly defaultValue={undefined} />
<h2>Disabled</h2>
<BasicCombobox disabled />
</div>
);
*/
@@ -1,7 +1,7 @@
import type { Meta } from "@storybook/react-vite";
import React, { useState } from "react";
import { expect, userEvent, within } from "storybook/test";
import { ComboboxSingle } from "./Combobox";
import { Combobox, type ComboboxProps } from "./Combobox";
import { ComboboxFilter } from "./filter/ComboboxFilter";
import { ComboboxList } from "./list/ComboboxList";
import { ComboboxPopup } from "./popup/ComboboxPopup";
@@ -59,14 +59,20 @@ const groupedOptions = [
function BaseCombobox<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>(props: Partial<ComboboxSingle.Props<T>>) {
const [value, setValue] = useState<string | undefined>("option-1");
>(props: Partial<ComboboxProps<T>>) {
const [selectedOptions, setSelectedOptions] = useState(["option-1"]);
return (
<ComboboxSingle
<Combobox
options={countries as T[]}
value={value}
onChange={setValue}
selectedOptions={selectedOptions}
onToggleOption={(option, newSelected) => {
setSelectedOptions((prev) =>
newSelected
? [...prev, option.value]
: prev.filter((v) => v !== option.value),
);
}}
label="Velg land"
description="Landet hvor du er født."
{...props}
@@ -93,7 +99,7 @@ const optionMemoTestRenderFn = (
return optOrGroup.label;
};
export const OptionMemoization = () => {
const [value, setValue] = useState(["option-1"]);
const [selectedOptions, setSelectedOptions] = useState(["option-1"]);
return (
<div style={{ minHeight: "450px" }}>
@@ -106,9 +112,9 @@ export const OptionMemoization = () => {
<ComboboxRoot
defaultOpen
options={groupedOptions}
value={value}
selectedOptions={selectedOptions}
onToggleOption={(option) => {
setValue((prev) =>
setSelectedOptions((prev) =>
prev.includes(option.value)
? prev.filter((v) => v !== option.value)
: [...prev, option.value],
@@ -7,17 +7,15 @@ import {
} from "../root/ComboboxRoot";
interface ComboboxFieldProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children?: string;
children?: React.ReactNode;
hasError?: boolean;
}
const ComboboxField = forwardRef<HTMLButtonElement, ComboboxFieldProps>(
({ children, hasError, ...rest }, ref) => {
const { options, value, size } = useComboboxRootContext();
const { options, selectedOptions, size } = useComboboxRootContext();
const labels = getLabels(value, options);
const label =
children ?? (labels.length > 1 ? `${labels.length} valgt` : labels[0]);
const labels = getLabels(selectedOptions, options);
return (
<div className="aksel-combobox2__field-container">
@@ -30,11 +28,10 @@ const ComboboxField = forwardRef<HTMLButtonElement, ComboboxFieldProps>(
"aksel-body-short",
`aksel-body-short--${size ?? "medium"}`,
)}
// TODO: This results in other problems. Find another fix!
//aria-label={label} // Without this, VO on Safari will announce the old label when focusing this button right after selecting a new option
{...rest}
>
{label}
{children ||
(labels.length > 1 ? `${labels.length} valgt` : labels[0])}
</button>
<ChevronDownIcon
className="aksel-combobox2__field-chevron"
@@ -46,7 +43,7 @@ const ComboboxField = forwardRef<HTMLButtonElement, ComboboxFieldProps>(
);
const getLabels = (
value: ComboboxRootProps["value"],
selectedOptions: ComboboxRootProps["selectedOptions"],
options: ComboboxRootProps["options"],
) => {
const labels: string[] = [];
@@ -55,13 +52,13 @@ const getLabels = (
if ("options" in optOrGroup) {
// It's a group
for (const option of optOrGroup.options) {
if (value.includes(option.value)) {
if (selectedOptions.includes(option.value)) {
labels.push(option.label);
}
}
} else {
// It's a single option
if (value.includes(optOrGroup.value)) {
if (selectedOptions.includes(optOrGroup.value)) {
labels.push(optOrGroup.label);
}
}
@@ -11,16 +11,11 @@ type ComboboxFilterProps = Pick<
export const ComboboxFilter = (props: ComboboxFilterProps) => {
const { size } = useComboboxRootContext();
const {
filterString,
setFilterString,
virtuallyFocusedOptionValue,
setVirtuallyFocusedOptionValue,
} = useComboboxPopupContext();
const { filterString, setFilterString } = useComboboxPopupContext();
return (
<div className="aksel-combobox2__filter">
<Listbox.InputSlot virtuallyFocusedOptionId={virtuallyFocusedOptionValue}>
<Listbox.InputSlot>
<Search
value={filterString}
onChange={setFilterString}
@@ -34,11 +29,6 @@ export const ComboboxFilter = (props: ComboboxFilterProps) => {
event.preventDefault(); // Prevents navigation with screen reader
}
}}
onKeyDown={(event) => {
if (["ArrowLeft", "ArrowRight"].includes(event.key)) {
setVirtuallyFocusedOptionValue("");
}
}}
aria-autocomplete="list"
inputMode="text"
/>
@@ -47,22 +47,28 @@ function ComboboxList<
function ComboboxListContent<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>({ children }: ComboboxListProps<T>) {
const { virtuallyFocusedOptionValue, filterString, setAnnounceText } =
const { virtuallyFocusedOptionValue, filterString } =
useComboboxPopupContext();
const { setOpen, options, value, onToggleOption, multiple, triggerRef } =
useComboboxRootContext();
const {
setOpen,
options,
selectedOptions,
onToggleOption,
multiselect,
triggerRef,
} = useComboboxRootContext();
const memoizedOnToggleOption = useEventCallback(onToggleOption);
const deferredFilterString = useDeferredValue(filterString);
const localOnToggleOption = useCallback(
(option: ComboboxOptionData, isSelected: boolean) => {
memoizedOnToggleOption(option, isSelected);
if (!multiple) {
if (!multiselect) {
setOpen(false);
triggerRef.current?.focus();
}
},
[memoizedOnToggleOption, multiple, triggerRef, setOpen],
[memoizedOnToggleOption, multiselect, triggerRef, setOpen],
);
if (children && typeof children !== "function") {
@@ -74,7 +80,6 @@ function ComboboxListContent<
: options;
if (filteredOptions.length === 0) {
setAnnounceText("Ingen treff");
return (
<div className="aksel-combobox2__no-results" role="status">
Ingen treff
@@ -82,8 +87,6 @@ function ComboboxListContent<
);
}
setAnnounceText("");
return filteredOptions.map((optOrGroup) =>
"options" in optOrGroup ? (
<ComboboxGroup
@@ -96,7 +99,7 @@ function ComboboxListContent<
key={option.value}
option={option}
onToggleOption={localOnToggleOption}
isSelected={value.includes(option.value)}
isSelected={selectedOptions.includes(option.value)}
hasVirtualFocus={virtuallyFocusedOptionValue === option.value}
highlightText={deferredFilterString}
>
@@ -109,7 +112,7 @@ function ComboboxListContent<
key={optOrGroup.value}
option={optOrGroup}
onToggleOption={localOnToggleOption}
isSelected={value.includes(optOrGroup.value)}
isSelected={selectedOptions.includes(optOrGroup.value)}
hasVirtualFocus={virtuallyFocusedOptionValue === optOrGroup.value}
highlightText={deferredFilterString} // TODO: Vurder å kunne slå av dette for bedre ytelse
>
@@ -103,13 +103,10 @@ function ComboboxModal({
useEffect(() => {
// See "Overlay" section in CSS file for context. This handles the case when
// the list doesn't overflow and overscroll-behavior doesn't work on iOS.
// Works best on iOS 27. On iOS 26 you can scroll a little before it triggers.
if (open) {
const scrollToTop = () => (document.documentElement.scrollTop = 0);
visualViewport?.addEventListener("scroll", scrollToTop);
return () => visualViewport?.removeEventListener("scroll", scrollToTop);
}
}, [open]);
const scrollToTop = () => (document.documentElement.scrollTop = 0);
visualViewport?.addEventListener("scroll", scrollToTop);
return () => visualViewport?.removeEventListener("scroll", scrollToTop);
}, []);
return (
<Dialog
@@ -57,15 +57,17 @@ const groupedOptions: (MyGroup | MyOption)[] = [
];
export const Default = () => {
const [value, setValue] = useState<MyOption["value"][]>(["opt-3"]);
const [selectedOptions, setSelectedOptions] = useState<MyOption["value"][]>([
"opt-3",
]);
return (
<ComboboxRoot
defaultOpen
options={options}
value={value}
selectedOptions={selectedOptions}
onToggleOption={(option, newSelected) => {
setValue((prev) =>
setSelectedOptions((prev) =>
newSelected
? [...prev, option.value]
: prev.filter((v) => v !== option.value),
@@ -87,7 +89,7 @@ export const SingleSelect = () => {
<ComboboxRoot
defaultOpen
options={options}
value={[selectedOption.value]}
selectedOptions={[selectedOption.value]}
onToggleOption={setSelectedOption}
>
<ComboboxPopup>
@@ -106,7 +108,7 @@ export const Groups = () => {
<ComboboxRoot
defaultOpen
options={groupedOptions}
value={[selectedOption.value]}
selectedOptions={[selectedOption.value]}
onToggleOption={setSelectedOption}
>
<ComboboxPopup>
@@ -119,7 +121,7 @@ export const Groups = () => {
size="small"
defaultOpen
options={groupedOptions}
value={[selectedOption.value]}
selectedOptions={[selectedOption.value]}
onToggleOption={setSelectedOption}
>
<ComboboxPopup>
@@ -145,7 +147,7 @@ export const WithoutFilter = () => {
options: options.slice(0, 3),
},
]}
value={[selectedOption.value]}
selectedOptions={[selectedOption.value]}
onToggleOption={setSelectedOption}
>
<ComboboxPopup>
@@ -156,7 +158,7 @@ export const WithoutFilter = () => {
<ComboboxRoot
defaultOpen
options={options.slice(0, 3)}
value={[selectedOption.value]}
selectedOptions={[selectedOption.value]}
onToggleOption={setSelectedOption}
>
<ComboboxPopup>
@@ -168,18 +170,20 @@ export const WithoutFilter = () => {
};
export const CustomOptionRendering = () => {
const [value, setValue] = useState<MyOption["value"][]>(["opt-1"]);
const [selectedOptions, setSelectedOptions] = useState<MyOption["value"][]>([
"opt-1",
]);
return (
<ComboboxRoot
defaultOpen
options={options}
value={value}
selectedOptions={selectedOptions}
onToggleOption={(option, isSelected) => {
setValue(
setSelectedOptions(
isSelected
? [...value, option.value]
: value.filter((v) => v !== option.value),
? [...selectedOptions, option.value]
: selectedOptions.filter((v) => v !== option.value),
);
}}
>
@@ -208,7 +212,7 @@ export const CustomOptionRenderingGroups = () => {
<ComboboxRoot
defaultOpen
options={groupedOptions}
value={[selectedOption.value]}
selectedOptions={[selectedOption.value]}
onToggleOption={setSelectedOption}
>
<ComboboxPopup>
@@ -235,7 +239,9 @@ export const CustomOptionRenderingGroups = () => {
// Alternativer: Kun støtte option, måtte iterere selv, renderOption/group-props på root, flytte props ned (hvordan få tak i valgte i Input?) 🤔
export const ControlledInput = () => {
const [value, setValue] = useState<MyOption["value"][]>(["opt-1"]);
const [selectedOptions, setSelectedOptions] = useState<MyOption["value"][]>([
"opt-1",
]);
const [filterString, setFilterString] = useState("");
const filterStringLowerCase = filterString.toLocaleLowerCase();
@@ -260,9 +266,9 @@ export const ControlledInput = () => {
<ComboboxRoot
defaultOpen
options={filteredOptions}
value={value}
selectedOptions={selectedOptions}
onToggleOption={(option) => {
setValue((prev) =>
setSelectedOptions((prev) =>
prev.includes(option.value)
? prev.filter((v) => v !== option.value)
: [...prev, option.value],
@@ -284,7 +290,7 @@ export const States: StoryFn = () => {
<ComboboxRoot
defaultOpen
options={options.slice(0, 3)}
value={[selectedOption.value]}
selectedOptions={[selectedOption.value]}
onToggleOption={setSelectedOption}
>
<ComboboxPopup>
@@ -5,10 +5,8 @@ import { useComboboxRootContext } from "../root/ComboboxRoot";
interface ComboboxPopupContextProps {
virtuallyFocusedOptionValue: string;
setVirtuallyFocusedOptionValue: (value: string) => void;
filterString: string;
setFilterString: (value: string) => void;
setAnnounceText: (text: string) => void;
}
export const {
@@ -23,34 +21,32 @@ export interface ComboboxPopupProps {
children: React.ReactNode;
}
// TODO: Bør den hete Dropdown eller Menu? (Og Overlay -> Popup?)
export const ComboboxPopup = ({ children }: ComboboxPopupProps) => {
const [virtuallyFocusedOptionValue, setVirtuallyFocusedOptionValue] =
useState("");
const [filterString, setFilterString] = useState("");
const rootContext = useComboboxRootContext();
const ref = useRef<HTMLDivElement | null>(null);
const [announceText, setAnnounceText] = useState("");
useEffect(
function onOpen() {
const canHover = window.matchMedia("(hover: hover)").matches;
// Virtually focus and scroll to first (selected) option
const selectedOptionElm = ref.current?.querySelector<HTMLElement>(
'[aria-selected="true"]',
);
if (selectedOptionElm) {
if (canHover) {
setVirtuallyFocusedOptionValue(selectedOptionElm.dataset.id || "");
}
const scrollToSelectedOption = () =>
selectedOptionElm.scrollIntoView({ block: "nearest" });
setTimeout(scrollToSelectedOption, 0); // For when opened in Floating
queueMicrotask(scrollToSelectedOption); // For when opened in Dialog
setVirtuallyFocusedOptionValue(selectedOptionElm.dataset.id || "");
setTimeout(
() => selectedOptionElm.scrollIntoView({ block: "nearest" }),
0,
);
} else {
const firstOptionElm =
ref.current?.querySelector<HTMLElement>('[role="option"]');
if (firstOptionElm && canHover) {
if (firstOptionElm) {
setVirtuallyFocusedOptionValue(firstOptionElm.dataset.id || "");
}
}
@@ -83,18 +79,10 @@ export const ComboboxPopup = ({ children }: ComboboxPopupProps) => {
>
<ComboboxPopupContextProvider
virtuallyFocusedOptionValue={virtuallyFocusedOptionValue}
setVirtuallyFocusedOptionValue={setVirtuallyFocusedOptionValue}
filterString={filterString}
setFilterString={(newString) => {
setFilterString(newString);
setVirtuallyFocusedOptionValue("");
}}
setAnnounceText={setAnnounceText}
setFilterString={setFilterString}
>
{children}
<div className="aksel-sr-only" role="status">
{announceText}
</div>
</ComboboxPopupContextProvider>
</Listbox>
);
@@ -4,26 +4,6 @@ import { cl, createStrictContext } from "../../../utils/helpers";
import type { FormFieldProps } from "../../useFormField";
import type { ComboboxOptionProps } from "../option/ComboboxOption";
type HtmlAttributes = Omit<
React.HTMLAttributes<HTMLDivElement>,
| "onChange"
| "defaultChecked"
| "autoCapitalize"
| "contentEditable"
| "enterKeyHint"
| "spellCheck"
| "inputMode"
| "autoCorrect"
| "autoSave"
| "color"
| "results"
| "security"
| "unselectable"
| "popover"
| "popoverTargetAction"
| "popoverTarget"
>;
/** Resolves the option type based on whether the parameter is a group or a single option */
export type ResolveOption<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
@@ -48,7 +28,9 @@ export interface ComboboxRootProps<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData> =
ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>
extends Pick<FormFieldProps, "disabled" | "size">, HtmlAttributes {
extends
Pick<FormFieldProps, "disabled">,
React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
/**
* Can be either an array of options ({@link ComboboxOptionData})
@@ -58,25 +40,14 @@ export interface ComboboxRootProps<
* put the single options first.
*/
options: T[];
/**
* Value of the selected options.
*/
value: ResolveOption<T>["value"][];
/**
* Called when an option is toggled.
* @param option The option that was toggled.
* @param isSelected The new selected state of the option.
*/
selectedOptions: ResolveOption<T>["value"][]; // TODO: Vurder navn: value(s)
onToggleOption: ComboboxOptionProps<ResolveOption<T>>["onToggleOption"];
/**
* Whether the combobox is open by default.
*/
defaultOpen?: boolean;
multiselect?: boolean; // TODO: Hva skal være default? Vurder navn
/**
* Whether multiple options can be selected.
* @default true
* @default "medium"
*/
multiple?: boolean; // TODO: Hva skal være default?
size?: "small" | "medium";
}
export interface ComboboxRootContextProps {
@@ -84,9 +55,9 @@ export interface ComboboxRootContextProps {
setOpen: (newOpen: boolean) => void;
triggerRef: React.RefObject<HTMLDivElement | null>;
options: ComboboxRootProps["options"];
value: ComboboxRootProps["value"];
selectedOptions: ComboboxRootProps["selectedOptions"];
onToggleOption: ComboboxRootProps["onToggleOption"];
multiple: boolean;
multiselect: boolean;
size: Exclude<ComboboxRootProps["size"], undefined>;
disabled: ComboboxRootProps["disabled"];
}
@@ -104,10 +75,10 @@ export function ComboboxRoot<
>({
children,
options,
value,
selectedOptions,
onToggleOption,
defaultOpen = false,
multiple = true,
multiselect = true,
size = "medium",
disabled,
className,
@@ -128,11 +99,11 @@ export function ComboboxRoot<
setOpen={setOpen}
triggerRef={triggerRef}
options={options}
value={value}
selectedOptions={selectedOptions}
onToggleOption={
onToggleOption as ComboboxRootContextProps["onToggleOption"]
}
multiple={multiple}
multiselect={multiselect}
size={size}
disabled={disabled}
>
@@ -9,18 +9,11 @@ interface ComboboxTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElem
*/
children: React.ReactNode;
readOnly?: boolean;
/**
* Whether to open when the user starts typing.
* Set to `false` if the filter input is omitted.
* @default true
*/
openWhenStartsTyping?: boolean; // TODO: Check ComboboxFilter presence automatically? Or check in dev and warn if prop wrong.
}
const ComboboxTrigger = ({
children,
readOnly,
openWhenStartsTyping = true,
...rest
}: ComboboxTriggerProps) => {
const rootContext = useComboboxRootContext();
@@ -35,26 +28,15 @@ const ComboboxTrigger = ({
// Prevents "flash of focus" on trigger before focus is moved to input
if (!readOnly && !rootContext.open) event.preventDefault();
}}
onKeyDown={(event) => {
if (readOnly) {
return;
/*onKeyDown={composeEventHandlers(onKeyDown, (event) => {
if (event.key === "ArrowDown") {
context.onOpenChange(true);
// Stop keydown from scrolling window
event.preventDefault();
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
rootContext.setOpen(true);
event.preventDefault(); // Stop keydown from scrolling window
} else if (
openWhenStartsTyping &&
!event.nativeEvent.isComposing &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
event.key.length === 1 &&
event.key !== " "
) {
rootContext.setOpen(true); // Open when you start writing
}
}}
})}*/
data-readonly={readOnly}
aria-readonly={readOnly} // TODO: Vurder
disabled={rootContext.disabled}
{...rest}
>
@@ -61,7 +61,6 @@ export const Fieldset = forwardRef<HTMLFieldSetElement, FieldsetProps>(
readOnly,
inputDescriptionId,
readOnlyIconNeedsTitle,
disabled,
} = useFieldset(props, legendId);
const fieldset = useContext(FieldsetContext);
@@ -87,7 +86,7 @@ export const Fieldset = forwardRef<HTMLFieldSetElement, FieldsetProps>(
[fieldset?.errorId ?? ""]: !!fieldset?.error,
}),
size,
disabled,
disabled: props.disabled ?? false,
readOnly,
}}
>
@@ -15,7 +15,6 @@ export const useFieldset = (props: FieldsetProps, legendId: string) => {
return {
...formField,
disabled: formField.inputProps.disabled ?? false,
readOnlyIconNeedsTitle: checkboxGroupContext || radioGroupContext,
inputProps: {
...(checkboxGroupContext || radioGroupContext
@@ -1,14 +0,0 @@
import type { ComponentMetadata } from "../../utils/types/metadata";
import { RadioCard, RadioCardGroup } from "./index";
const metadata: ComponentMetadata = {
name: "RadioCard",
components: {
RadioCard,
RadioCardGroup,
},
keywords: ["radio", "radioknapp", "form", "input", "choice", "card"],
related: ["Radio"],
};
export { metadata };
@@ -1,118 +0,0 @@
import type { Meta } from "@storybook/react-vite";
import React from "react";
import { HGrid } from "../../primitives/grid";
import { renderStoriesForChromatic } from "../../utils/renderStoriesForChromatic";
import { RadioCard } from "./RadioCard";
import { RadioCardGroup } from "./RadioCardGroup";
const meta: Meta<typeof RadioCardGroup> = {
title: "ds-react/RadioCard",
component: RadioCardGroup,
subcomponents: { RadioCard },
parameters: {
chromatic: { disable: true },
},
};
export default meta;
export const Default = () => (
<RadioCardGroup
legend="Velg leveringsmåte"
description="Du kan velge ett alternativ."
>
<RadioCard value="digital" description="Sendes til innboksen din.">
Digital levering
</RadioCard>
<RadioCard value="post" description="Sendes til folkeregistrert adresse.">
Post
</RadioCard>
</RadioCardGroup>
);
export const HorizontalLayout = () => (
<RadioCardGroup
legend="Velg leveringsmåte"
description="Du kan velge ett alternativ."
defaultValue="digital"
size="small"
>
<HGrid columns={{ xs: 1, md: 3 }}>
<RadioCard value="digital" description="Sendes til innboksen din.">
Digital levering
</RadioCard>
<RadioCard value="post" description="Sendes til folkeregistrert adresse.">
Post
</RadioCard>
<RadioCard value="seng" description="Blir servert på sengen.">
Servert på sengen
</RadioCard>
</HGrid>
</RadioCardGroup>
);
export const Small = () => (
<RadioCardGroup legend="Velg leveringsmåte" size="small">
<RadioCard value="digital" description="Sendes til innboksen din.">
Digital levering
</RadioCard>
<RadioCard value="post" description="Sendes til folkeregistrert adresse.">
Post
</RadioCard>
</RadioCardGroup>
);
export const ErrorProp = () => (
<RadioCardGroup
legend="Velg leveringsmåte"
error="Du må velge en leveringsmåte."
defaultValue="digital"
>
<RadioCard value="digital" description="Sendes til innboksen din.">
Digital levering
</RadioCard>
<RadioCard value="post" description="Sendes til folkeregistrert adresse.">
Post
</RadioCard>
</RadioCardGroup>
);
export const Readonly = () => (
<RadioCardGroup legend="Valgt leveringsmåte" defaultValue="digital" readOnly>
<RadioCard value="digital" description="Sendes til innboksen din.">
Digital levering
</RadioCard>
<RadioCard value="post" description="Sendes til folkeregistrert adresse.">
Post
</RadioCard>
</RadioCardGroup>
);
export const Disabled = () => (
<RadioCardGroup legend="Velg leveringsmåte" defaultValue="digital" disabled>
<RadioCard value="digital" description="Sendes til innboksen din.">
Digital levering
</RadioCard>
<RadioCard value="post" description="Sendes til folkeregistrert adresse.">
Post
</RadioCard>
</RadioCardGroup>
);
export const Chromatic = renderStoriesForChromatic({
Default,
HorizontalLayout,
Small,
ErrorProp,
Readonly,
Disabled,
});
export const ChromaticDark = renderStoriesForChromatic({
Default,
HorizontalLayout,
Small,
ErrorProp,
Readonly,
Disabled,
});
ChromaticDark.globals = { theme: "dark" };
@@ -1,41 +0,0 @@
import React, { forwardRef } from "react";
import { cl } from "../../utils/helpers";
import { Radio, RadioCardContextProvider } from "../radio/Radio";
import type { RadioProps } from "../radio/types";
type RadioCardProps = RadioProps;
/**
* Radio option styled as a card.
* @see 🏷️ {@link RadioCardProps}
* @example
* ```tsx
* <RadioCardGroup legend="Velg leveringsmåte">
* <RadioCard value="digital" description="Sendes til innboksen din.">
* Digital levering
* </RadioCard>
* <RadioCard value="post">Post</RadioCard>
* </RadioCardGroup>
* ```
*/
const RadioCard = forwardRef<HTMLInputElement, RadioCardProps>(
({ className, ...rest }, ref) => {
return (
<RadioCardContextProvider>
<Radio
{...rest}
ref={ref}
className={cl("aksel-radio-card", className)}
/>
</RadioCardContextProvider>
);
},
);
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace RadioCard {
export type Props = RadioCardProps;
}
export { RadioCard };
export type { RadioCardProps };
@@ -1,34 +0,0 @@
import React, { forwardRef } from "react";
import { cl } from "../../utils/helpers";
import RadioGroup, { type RadioGroupProps } from "../radio/RadioGroup";
type RadioCardGroupProps = RadioGroupProps;
/**
* RadioGroup for `<RadioCard />` elements. Horizontal layout is best suited for a small number of radio cards.
* @see 🏷️ {@link RadioCardGroupProps}
* @example
* ```tsx
* <RadioCardGroup legend="Velg leveringsmåte">
* <RadioCard value="digital">Digital levering</RadioCard>
* <RadioCard value="post">Post</RadioCard>
* </RadioCardGroup>
* ```
*/
const RadioCardGroup = forwardRef<HTMLFieldSetElement, RadioCardGroupProps>(
({ className, ...rest }, ref) => (
<RadioGroup
{...rest}
ref={ref}
className={cl("aksel-radio-card-group", className)}
/>
),
);
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace RadioCardGroup {
export type Props = RadioCardGroupProps;
}
export { RadioCardGroup };
export type { RadioCardGroupProps };
@@ -1,3 +0,0 @@
"use client";
export { RadioCard } from "./RadioCard";
export { RadioCardGroup } from "./RadioCardGroup";
@@ -69,22 +69,3 @@ describe("Uncontrolled RadioGroup", () => {
expect(input2.checked).toBe(true);
});
});
describe("Radio outside RadioCard", () => {
test("does not select when clicking description", async () => {
const user = userEvent.setup();
render(
<RadioGroup legend="legend">
<Radio value={value1} description="Radio description">
{label1}
</Radio>
</RadioGroup>,
);
await user.click(screen.getByText("Radio description"));
expect(
screen.getByRole<HTMLInputElement>("radio", { name: label1 }).checked,
).toBe(false);
});
});
+3 -31
View File
@@ -1,29 +1,19 @@
import React, { forwardRef, useRef } from "react";
import React, { forwardRef } from "react";
import { BodyShort } from "../../typography";
import { omit, useId } from "../../utils-external";
import { cl, createStrictContext } from "../../utils/helpers";
import { useMergeRefs } from "../../utils/hooks";
import { cl } from "../../utils/helpers";
import { RadioInput } from "./radio-input/RadioInput";
import type { RadioProps } from "./types";
import { useRadio } from "./useRadio";
const { Provider: RadioCardContextProvider, useContext: useRadioCardContext } =
createStrictContext({ name: "radio-card" });
export const Radio = forwardRef<HTMLInputElement, RadioProps>(
(props: RadioProps, forwardedRef) => {
const { inputProps, size, hasError, readOnly } = useRadio(props);
const descriptionId = useId();
const cardContext = useRadioCardContext(false);
const radioRef = useRef<HTMLInputElement>(null);
const mergedRefs = useMergeRefs(forwardedRef, radioRef);
const { className, description, children } = props;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: clickable div for radio card
// biome-ignore lint/a11y/useKeyWithClickEvents: clickable div for radio card
<div
className={cl(className, "aksel-radio", `aksel-radio--${size}`, {
"aksel-radio--error": hasError,
@@ -31,26 +21,9 @@ export const Radio = forwardRef<HTMLInputElement, RadioProps>(
"aksel-radio--readonly": readOnly,
})}
data-color={hasError ? "danger" : props["data-color"]}
onClick={(event) => {
if (inputProps.disabled || readOnly || !cardContext) {
return;
}
/* Let input and label handle their own click events */
const target = event.target;
const nativeHandler =
target instanceof Element ? target.closest("label, input") : null;
if (nativeHandler && event.currentTarget.contains(nativeHandler)) {
return;
}
radioRef.current?.click();
radioRef.current?.focus({ preventScroll: true });
}}
>
<RadioInput
ref={mergedRefs}
ref={forwardedRef}
{...omit(props, [
"children",
"size",
@@ -89,4 +62,3 @@ export const Radio = forwardRef<HTMLInputElement, RadioProps>(
);
export default Radio;
export { RadioCardContextProvider };
+1 -2
View File
@@ -1,5 +1,4 @@
"use client";
export { default as Radio } from "./Radio";
export { default as RadioGroup } from "./RadioGroup";
export type { RadioGroupProps } from "./RadioGroup";
export { default as RadioGroup, type RadioGroupProps } from "./RadioGroup";
export type { RadioProps } from "./types";
@@ -40,7 +40,7 @@ const SearchButton: SearchButtonType = forwardRef(
size={size}
variant={variant === "secondary" ? "secondary" : "primary"}
className={cl("aksel-search__button-search", className)}
disabled={context?.disabled || disabled}
disabled={context?.disabled ?? disabled}
onClick={composeEventHandlers(onClick, handleClick)}
icon={
<MagnifyingGlassIcon
@@ -79,7 +79,7 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
`aksel-switch--${position}`,
{
"aksel-switch--loading": loading,
"aksel-switch--disabled": inputProps.disabled || loading,
"aksel-switch--disabled": inputProps.disabled ?? loading,
"aksel-switch--readonly": readOnly,
"aksel-switch--standalone": hideLabel,
},
@@ -88,7 +88,7 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
<input
{...omit(rest, ["size", "readOnly"])}
{...omit(inputProps, ["aria-invalid", "aria-describedby"])}
disabled={inputProps.disabled || loading}
disabled={inputProps.disabled ?? loading}
checked={checkedProp}
defaultChecked={defaultChecked}
ref={ref}
Loaded 100 of 288 files, more files were not shown because too many files have changed in this diff. Show more