Compare commits

..
69 Commits
Author SHA1 Message Date
Ken a7e6f7a014 chore: Migrate from biome to oxlint 2026-10-26 13:24:18 +01:00
github-actions[bot] d140ee84cc Version Packages (#5444)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-26 11:57:15 +01:00
dependabot[bot]andKen dfd3ddbe51 ⬆️ Bump the sanity group across 1 directory with 6 updates (#5452)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ken <ken.aleksander@gmail.com>
2026-10-26 11:56:43 +01:00
Halvor Haugan 68c66f345a chore(deps): sync versions of @next/env to 16.4.2 2026-10-26 11:39:42 +01:00
dependabot[bot] 64726b8874 ⬆️ Bump body-parser from 1.20.6 to 1.20.8 (#5456)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 09:48:36 +01:00
dependabot[bot] 774e25dd27 ⬆️ Bump the react group with 2 updates (#5455)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 09:48:08 +01:00
Halvor Haugan 1b639f4ede feat(website): add example of small Combobox (#5447) 2026-10-26 09:45:56 +01:00
dependabot[bot] c234d98d7c ⬆️ Bump the next group with 3 updates (#5453)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 09:45:12 +01:00
dependabot[bot] a0c7f1fe54 ⬆️ Bump ip-address from 10.7.2 to 10.7.4 (#5449)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-23 21:22:01 +02:00
Halvor Haugan 13bd3cbfd0 chore(website): bump isomorphic-dompurify to 4.6.0 (#5450) 2026-10-23 21:20:44 +02:00
Halvor Haugan d2325fb33b chore(aksel): bump chalk to 6.0.1 2026-10-23 20:19:40 +02:00
Halvor Haugan d8abc9bfeb chore(react): upgrade testing-library packages (#5446) 2026-10-23 20:10:33 +02:00
Halvor Haugan 769c477392 chore(deps): bump markdown-it to 15.0.3 2026-10-23 18:35:45 +02:00
Ken 6fb7eda476 fix(badge): Increase inline padding by 1px for better optical balance (#5443) 2026-10-23 14:34:27 +02:00
Halvor Haugan a013d5312f docs(core): update hideLabel prop description for consistency across components (#5412) 2026-10-23 10:10:46 +02:00
Halvor Haugan dabfb56def feat(Listbox): remove virtual focus on arrow up on first opt. and arrow up on last opt. if input has real focus (#5440) 2026-10-23 09:18:20 +02:00
Halvor Haugan c571a7d4ba revert(Listbox): don't fall back to selected opt. (reverts #5353) (#5439) 2026-10-23 09:18:20 +02:00
dependabot[bot] 848783bb9f ⬆️ Bump launch-editor from 2.14.1 to 2.14.2 (#5438)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-22 14:42:37 +02:00
Halvor Haugan a7876ed078 refactor(useControllableState): avoid type assertion (#5434) 2026-10-22 12:50:03 +02:00
Ken 6713cd6d30 perf: Improved period render performance, hide zoom-elements from timeline in docs (#5436) 2026-10-22 12:48:43 +02:00
KenandCopilot 136676ffa0 refactor: Restructured timeline (#5432)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 12:48:43 +02:00
KenandCopilot 234c19f16d refactor: Restructured table (#5431)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 12:48:42 +02:00
KenandCopilot a2c0f09980 refactor: Restructured pagination (#5430)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 12:48:42 +02:00
Ken c3669539c6 fix: Update chips exports and sync restructure skill (#5429) 2026-10-22 12:48:41 +02:00
Ken 0a0be96cd9 refactor: Restructured modal, popover (#5418) 2026-10-22 12:41:28 +02:00
Ken f2cff91d4f refactor: Restructured expansion-card, link-card, list (#5415) 2026-10-22 12:41:12 +02:00
Ken 2994628c79 Component refactor structure: ToggleGroup, Tabs, Chips and Chat (#5414) 2026-10-22 12:40:47 +02:00
github-actions[bot] 5bdfe05012 Version Packages (#5402)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-22 11:51:50 +02:00
dependabot[bot]andKen cd701af0df ⬆️ Bump storybook from 10.6.1 to 10.6.2 in the storybook-security group across 1 directory (#5437)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ken <ken.aleksander@gmail.com>
2026-10-22 11:33:14 +02:00
Halvor Haugan cd37887e1c chore(website): bump @portabletext/markdown to 2.5.1 (#5435) 2026-10-22 10:35:06 +02:00
Ken 1b5c0b50df chore(dep): Dedupe yarn.lock 2026-10-21 16:06:50 +02:00
Ken 91bd94ec4b chore(dep): Upgrade concurrently to fix shell-quote CVE-2026-9277 2026-10-21 16:02:15 +02:00
Ken 846cb036c4 perf(website): Avoid prefetching when in draft-mode (#5426) 2026-10-21 15:35:20 +02:00
Halvor Haugan b904f4ef04 feat(Combobox2): open when you start writing 2026-10-21 14:51:24 +02:00
Ken 863b6208d9 fix(core): Nextjs-builds with webpack now works with ds-react (#5422) 2026-10-21 13:55:52 +02:00
dependabot[bot] faa8f96507 ⬆️ Bump @modelcontextprotocol/sdk from 1.31.0 to 1.32.0 (#5427)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-21 13:55:32 +02:00
dependabot[bot] dffaace465 ⬆️ Bump vite from 8.2.4 to 8.3.5 (#5428)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-21 13:55:08 +02:00
Ken 23963c93b1 feat: Update lint-staged, post-hooks 2026-10-21 13:53:27 +02:00
dependabot[bot] af7c24b5f0 ⬆️ Bump vite from 8.2.2 to 8.2.4 (#5424)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-21 13:44:07 +02:00
Ken 00deddb6f6 fix(CheckboxCard, RadioCard): Updated border-color in unchecked error-state, input-color in checked disabled state (#5425) 2026-10-21 13:38:58 +02:00
dependabot[bot] 47ef2953fa ⬆️ Bump follow-redirects from 1.16.0 to 1.16.1 (#5423)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-21 12:16:51 +02:00
Halvor Haugan bb5939d21e chore(mcp): bump @modelcontextprotocol/inspector to 2.11.0 2026-10-21 12:07:29 +02:00
Halvor Haugan a71d4b849a fix(Combobox2): remove aria-label from Field, and aria-readonly from Trigger 2026-10-21 10:20:38 +02:00
Halvor Haugan d604deb25c fix(website): fix typo in example desc CheckboxCard 2026-10-21 10:02:03 +02:00
Halvor Haugan 3fe9563078 feat(Combobox2): remove virtual focus when editing filter input 2026-10-20 21:55:45 +02:00
Halvor HauganandCopilot Autofix powered by AI 62d2f5af01 fix(website): avoid sandbox error on DataGrid examples (#5421)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-10-20 17:04:57 +02:00
dependabot[bot] 53780d0c67 ⬆️ Bump js-yaml from 5.4.3 to 5.5.0 (#5419)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 15:38:18 +02:00
dependabot[bot] d476be6604 ⬆️ Bump postcss-selector-parser from 7.1.6 to 7.1.7 (#5420)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 15:38:06 +02:00
KenandHalvor Haugan a0c9494b07 feat: Added support for internal translations (#5413)
Co-authored-by: Halvor Haugan <halvor.haugan@nav.no>
2026-10-20 15:19:15 +02:00
Ken bf857007cd fix(KI): Update vitest config and husky to support proper testing and linting while using cplt 2026-10-20 14:14:49 +02:00
Ken e6db21586e fix(sanity): Code-example block in sanity now defaults compact-view to false 2026-10-20 13:47:43 +02:00
Ken 619ebd5088 feat: Added egnet/uegnet til fields to markdown-version of component docs 2026-10-20 13:46:22 +02:00
Ken 1b539a9b13 chore: Added more website examples for CheckboxCard 2026-10-20 13:35:08 +02:00
Ken 8859bbb7ea feat(core): CheckboxCard component (#5364) 2026-10-20 13:26:36 +02:00
Halvor Haugan dff2c96b4b fix(Combobox2): announce no hits + prevent open with arrow when readOnly 2026-10-20 12:55:39 +02:00
Halvor Haugan 2177f3ab64 refactor(Combobox2): separate components for single- and multiselect 2026-10-20 12:14:16 +02:00
Anniken GranbergandKen 51629f9229 feat(ToggleGroup): Add xsmall size (#5411)
Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>
2026-10-20 12:05:39 +02:00
dependabot[bot] a2d0ffda0a ⬆️ Bump figlet from 1.11.3 to 1.12.0 (#5404)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-19 10:40:46 +02:00
dependabot[bot] 45aa8baf10 ⬆️ Bump tsc-alias from 1.9.1 to 1.9.7 (#5407)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-19 10:38:19 +02:00
dependabot[bot] 3f7b72369e ⬆️ Bump react-hook-form from 7.89.0 to 7.90.0 (#5409)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-19 10:30:25 +02:00
Halvor Haugan 8009289ca4 feat(website): add desc on ToggleGroup icon example 2026-10-16 10:34:43 +02:00
Ken ee50050d27 feat: Added custom link for navno tema header 2026-10-15 15:20:15 +02:00
Halvor Haugan 4e1cfddd84 feat(Combobox2): open on arrow up/down on trigger 2026-10-15 13:17:36 +02:00
Halvor Haugan 643ab79c8d chore(AI): update copilot-instructions.md and code-review skill (regarding tests) 2026-10-15 13:08:15 +02:00
Halvor Haugan d134397fd0 feat(core): mark Badge and Lookup from /PREVIEW as deprecated (#5401) 2026-10-15 11:44:32 +02:00
Halvor Haugan d8e62db7f1 fix(Listbox): fix issue where options were not announced by VO in Safari (#5399) 2026-10-14 15:34:57 +02:00
dependabot[bot] 0cf700322c ⬆️ Bump react-hook-form from 7.86.0 to 7.89.0 (#5395)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-14 13:21:32 +02:00
dependabot[bot] 2c468cc8c7 ⬆️ Bump the storybook group with 8 updates (#5393)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-14 13:21:12 +02:00
Halvor Haugan d722f23a5e fix(Combobox2): fix issue with VO+Safari when selecting option 2026-10-14 13:15:45 +02:00
337 changed files with 5942 additions and 6000 deletions

No files matched your search

+9 -6
View File
@@ -10,8 +10,8 @@
## 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.
@@ -22,7 +22,7 @@
## Repo summary
- Nav's design system. Yarn 4 workspaces monorepo: published packages under `@navikt/` + docs site `aksel.nav.no/` + `apps/` + `tooling/`.
- Stack: TypeScript 6, React 19 (dev), Next.js 16, Storybook 10, Vitest 4, ESLint 9, Stylelint 17, Biome 2 (lint only), Prettier 3 (formatting). Node 22+, Yarn 4.18.0.
- Stack: TypeScript 6, React 19 (dev), Next.js 16, Storybook 10, Vitest 4, ESLint 9, Stylelint 17, Oxlint (JS/TS lint), Prettier 3 (formatting). Node 22+, Yarn 4.18.0.
- Published `@navikt/*` packages must stay React 17 compatible (peer `react ^17 || ^18 || ^19`).
## Start and validate
@@ -45,11 +45,12 @@
## Commands
- `corepack yarn eslint --max-warnings=0 --no-warn-ignored <files...>` / `corepack yarn stylelint <files...>` / `corepack yarn biome lint --no-errors-on-unmatched <files...>`
- `corepack yarn eslint --max-warnings=0 --no-warn-ignored <files...>` / `corepack yarn stylelint <files...>` / `corepack yarn oxlint --deny-warnings <files...>`
- `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) / `corepack yarn dev` (website, :3000, needs Sanity token)
- `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.
## Where to edit
@@ -63,7 +64,7 @@ Path-specific rules load automatically from `.github/instructions/` (see each `a
- `@navikt/aksel/src/` - CLI and codemods
- `aksel.nav.no/website/` - Next.js docs site · `aksel.nav.no/sanity-studio/` - CMS schema
- `apps/playroom/`, `apps/mcp-server/`, `apps/figma-icon-plugin/` - apps · `tooling/` - ESLint plugin, scripts, changelog, analyzer
- `.changeset/` - release notes · Root config: `eslint.config.js`, `stylelint.config.mjs`, `biome.json`, `vitest.config.ts`, `.storybook/`
- `.changeset/` - release notes · Root config: `eslint.config.js`, `stylelint.config.mjs`, `.oxlintrc.json`, `vitest.config.ts`, `.storybook/`
## Skills and agents
@@ -88,3 +89,5 @@ Path-specific rules load automatically from `.github/instructions/` (see each `a
- `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.
- Suppress Oxlint rules with `oxlint-disable-next-line <rule>` / `oxlint-disable <rule>`, never `eslint-disable` (breaks ESLint).
@@ -1,11 +1,13 @@
---
description: "JS/TS conventions not enforced by ESLint/Biome: loops, export style and placement."
description: "JS/TS conventions not enforced by ESLint/Oxlint: loops, export style and placement."
applyTo: "**/*.ts,**/*.tsx,**/*.js,**/*.mts,**/*.mjs"
---
# JS/TS conventions
Formatting, import order and `import type` are handled by Prettier and Biome. Don't hand-tune them.
Formatting and import order are handled by Prettier; linting by ESLint and Oxlint. Don't hand-tune formatting or import order.
Suppress Oxlint rules with `// oxlint-disable-next-line <rule>` (file-level: `/* oxlint-disable <rule> */`). Never use `eslint-disable` for Oxlint rules; ESLint reports unknown rules and unused directives.
- Use `for...of` instead of `Array.forEach`. It supports `break`/`continue`/`await` and reads top-down.
- Use named exports only. Keep type exports separate from value exports (`export { X }` / `export type { XProps }`).
+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. Yarn has `enableScripts: false`, so if the browser is missing, report it instead of installing globally.
- 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.
@@ -59,7 +59,7 @@ Group only matching files:
```sh
corepack yarn eslint --max-warnings=0 --no-warn-ignored <changed .ts/.tsx/.js/.jsx files>
corepack yarn stylelint <changed .css files>
corepack yarn biome lint --no-errors-on-unmatched <changed supported source/config files>
corepack yarn oxlint --deny-warnings <changed .ts/.tsx/.js/.jsx files>
```
Do not pass unsupported file types to make a command appear comprehensive. Documentation-only changes need no code validation unless a documentation check exists.
+2 -2
View File
@@ -26,7 +26,7 @@ argument-hint: "PR number, branch or files to review"
- 🟡 **Suggestion**: should fix. Missing test, docs or changeset, maintainability risk, over-editing.
- 💭 **Nit**: optional. Leave it out when in doubt.
Skip anything ESLint, Stylelint, Biome, Prettier or TypeScript already catches. No praise-only or speculative comments. Every finding says **why** it matters and what to do.
Skip anything ESLint, Stylelint, Oxlint, Prettier or TypeScript already catches. No praise-only or speculative comments. Every finding says **why** it matters and what to do.
## Checklist
@@ -69,7 +69,7 @@ Skip anything ESLint, Stylelint, Biome, Prettier or TypeScript already catches.
### Tests and docs (🟡)
- A behavior change has a story `play` test or Vitest test proving the user-facing contract. Queries use role and accessible name.
- 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.
- 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.
@@ -95,7 +95,7 @@ Move stories to component root dir. If stories import from internal paths, updat
### 5. Update `index.ts`
All exports are **named** — no `default` exports anywhere. The component `index.ts` re-exports the compound component, its sub-components, and their types from the root file:
All exports are **named** — no `default` exports anywhere. For compound components, the root file is the single export source: it assembles the compound component, imports every publicly exported sub-component and its prop types, and exports all public values and types together. The component `index.ts` only re-exports these names from `./root/<Component>Root` — never directly from sub-component files or shared type files.
```ts
"use client";
@@ -111,13 +111,18 @@ export type {
} from "./root/<Component>Root";
```
Group values first, then types. If types live in `<Component>.types.ts`, re-export them from there.
Group values first, then types. If types live in `<Component>.types.ts`, import them into the root file and include them in its type export block. The `index.ts` still re-exports them from the root file.
#### Compound root file export pattern
In `<Component>Root.tsx`, assemble the compound component with `Object.assign` — **do not** use an `as <Component>Component` cast or a dedicated `<Component>Component` interface:
```tsx
import {
<Component>Trigger,
type <Component>TriggerProps,
} from "../trigger/<Component>Trigger";
const <Component>Root = forwardRef<HTMLDivElement, <Component>Props>((props, ref) => {
// ...
});
@@ -138,6 +143,8 @@ export { <Component>, <Component>Trigger };
export type { <Component>Props, <Component>TriggerProps };
```
Follow `accordion/root/AccordionRoot.tsx`: export the imported sub-component values and prop types alongside the compound component at the bottom of the root file. Do not add internal-only sub-components to the public export chain. Sub-components must not import from the root file or component `index.ts`; use shared type/context/helper files to avoid circular dependencies.
Why `Object.assign` instead of `as`: the cast types the const as an interface that has no runtime `valueDeclaration`, so `react-docgen-typescript` (used by `yarn docgen:meta`) fails to extract props and can't document the component when it is exported via a grouped `export { }` statement. `Object.assign` lets TypeScript infer the intersection type from the value, so metadata generation works with named, bottom-of-file exports.
Sub-component files follow the same rule — named value export plus named type export at the bottom, no inline `export interface` and no default:
@@ -173,6 +180,8 @@ Concretely: grep for `export` lines in both versions and diff them. All componen
**Package root.** Verify `@navikt/core/react/src/index.ts` still re-exports the component. No changes needed there unless the component's `index.ts` path changed (it shouldn't).
**Root-only re-exports.** For compound components, verify every value and type export in the component `index.ts` comes from `./root/<Component>Root`, and every forwarded name is exported by that root file.
**No case-duplicate tracked paths.** This is the most common restructure bug. On case-insensitive filesystems (macOS, Windows) git can end up tracking **two** entries that differ only in casing (e.g. `accordion.stories.tsx` **and** `Accordion.stories.tsx`) while the working tree shows only one file. The stale entry then breaks tests and checkout on other machines. `ls` / file search will NOT reveal it — you must ask git. Run:
```sh
@@ -281,6 +290,7 @@ dialog/
- **No breaking changes.** Public export names and prop shapes must be identical after restructuring.
- **Named exports only.** No `default` exports; group named exports at the bottom of each file (values first, then types).
- **Compound exports flow through the root.** Component `index.ts` re-exports public values and types only from `root/<Component>Root.tsx`, not directly from sub-component or shared type files.
- **Compound roots use `Object.assign`.** No `as <Component>Component` cast or `<Component>Component` interface — it breaks `yarn docgen:meta` prop extraction.
- Component rules (React 17 compatibility, `"use client"`, `forwardRef`/`className`/`...rest`/`as`, tokens) live in `.github/instructions/ds-react-component.instructions.md`. They still apply.
- Keep JSDoc on public props and components. The component-level JSDoc block sits directly above the `Object.assign` call; sub-component `@see` tags go on the keys inside the `Object.assign` object literal.
@@ -289,6 +299,6 @@ dialog/
- Canonical example: `@navikt/core/react/src/dialog/`
- Package public API: `@navikt/core/react/src/index.ts`
- Component `index.ts` pattern: `@navikt/core/react/src/dialog/index.ts`
- Component export pattern: `@navikt/core/react/src/accordion/root/AccordionRoot.tsx` and `@navikt/core/react/src/accordion/index.ts`
- Context pattern: `@navikt/core/react/src/dialog/root/DialogRoot.context.ts`
- Meta: `@navikt/core/react/src/accordion/Accordion.meta.ts`
+10 -3
View File
@@ -1,6 +1,13 @@
CHANGED=`git diff $1 $2 --stat -- yarn.lock | wc -l`
if [ $CHANGED -gt 0 ];
then
echo
echo "*** 🟡 yarn.lock has changed 🟡 ***"
fi
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
+10 -3
View File
@@ -1,6 +1,13 @@
CHANGED=`git diff HEAD@{1} --stat -- yarn.lock | wc -l`
if [ $CHANGED -gt 0 ];
then
echo
echo "*** 🟡 yarn.lock has changed 🟡 ***"
fi
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
+2 -1
View File
@@ -1 +1,2 @@
yarn lint-staged
# Runs the local lint-staged checks before committing
./node_modules/.bin/lint-staged
+334
View File
@@ -0,0 +1,334 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [
"eslint",
"typescript",
"unicorn",
"oxc",
"react",
"jsx-a11y",
"import",
"vitest"
],
"categories": {
"correctness": "error"
},
"ignorePatterns": [
"**/lib",
"**/public",
"**/esm",
"**/cjs",
"**/dist",
"**/.next",
"**/.build",
"**/coverage",
"**/storybook-static",
"**/test-results",
"**/playwright-report",
"**/.sanity/runtime",
"**/next-env.d.ts",
"**/codemod/**/*.js",
"**/transforms/**/*.input.*",
"**/transforms/**/*.output.*",
"@navikt/aksel/**/*.input.*",
"@navikt/aksel/**/*.output.*",
"**/modal/helpers/dialog-polyfill.ts",
"**/query-types.ts",
"**/component-names.generated.ts",
"**/tokens/**/plugin.js",
"@navikt/aksel-icons/src",
"@navikt/aksel/src/version.ts",
"@navikt/core/tailwind/src/tokens.ts",
"@navikt/core/tailwind/tailwind.config.js",
"@navikt/core/tailwind/tailwind.darkside.config.js",
"@navikt/core/tokens/token_docs.js",
"apps/playroom/playroom.config.js",
"apps/playroom/src/auto-generated-imports.ts",
"apps/figma-icon-plugin/src/ui/vite-env.d.ts",
"tooling/analyzer/temp",
".yarn"
],
"rules": {
"jsx-a11y/alt-text": "error",
"jsx-a11y/anchor-ambiguous-text": "error",
"jsx-a11y/anchor-has-content": "error",
"jsx-a11y/anchor-is-valid": "error",
"jsx-a11y/aria-activedescendant-has-tabindex": "error",
"jsx-a11y/aria-props": "error",
"jsx-a11y/aria-proptypes": "error",
"jsx-a11y/aria-role": "error",
"jsx-a11y/aria-unsupported-elements": "error",
"jsx-a11y/autocomplete-valid": "error",
"jsx-a11y/click-events-have-key-events": "error",
"jsx-a11y/heading-has-content": "error",
"jsx-a11y/html-has-lang": "error",
"jsx-a11y/iframe-has-title": "error",
"jsx-a11y/img-redundant-alt": "error",
"jsx-a11y/interactive-supports-focus": "error",
"jsx-a11y/label-has-associated-control": "error",
"jsx-a11y/lang": "error",
"jsx-a11y/media-has-caption": "error",
"jsx-a11y/mouse-events-have-key-events": "error",
"jsx-a11y/no-access-key": "error",
"jsx-a11y/no-aria-hidden-on-focusable": "error",
"jsx-a11y/no-autofocus": "error",
"jsx-a11y/no-distracting-elements": "error",
"jsx-a11y/no-interactive-element-to-noninteractive-role": "error",
"jsx-a11y/no-noninteractive-element-to-interactive-role": "error",
"jsx-a11y/no-noninteractive-tabindex": "error",
"jsx-a11y/no-redundant-roles": "error",
"jsx-a11y/no-static-element-interactions": "error",
"jsx-a11y/prefer-tag-over-role": "off",
"jsx-a11y/role-has-required-aria-props": "error",
"jsx-a11y/role-supports-aria-props": "error",
"jsx-a11y/scope": "error",
"jsx-a11y/tabindex-no-positive": "error",
"react/button-has-type": "error",
"eslint/no-extra-label": "warn",
"eslint/no-lone-blocks": "warn",
"eslint/no-sequences": "warn",
"eslint/no-unneeded-ternary": "warn",
"eslint/no-useless-computed-key": "warn",
"eslint/no-useless-constructor": "warn",
"eslint/no-useless-rename": "warn",
"eslint/prefer-numeric-literals": "warn",
"eslint/prefer-regex-literals": "warn",
"eslint/prefer-rest-params": "warn",
"react/jsx-no-useless-fragment": [
"warn",
{
"allowExpressions": true
}
],
"typescript/no-useless-empty-export": "warn",
"unicorn/no-static-only-class": "warn",
"unicorn/no-useless-switch-case": "warn",
"unicorn/prefer-array-flat": "warn",
"unicorn/prefer-array-flat-map": "warn",
"unicorn/prefer-array-index-of": "warn",
"unicorn/prefer-date-now": "warn",
"eslint/no-constructor-return": "error",
"eslint/no-inner-declarations": [
"error",
"both",
{
"blockScopedFunctions": "allow"
}
],
"eslint/no-use-before-define": [
"error",
{
"functions": false,
"classes": false,
"variables": false,
"enums": false,
"typedefs": false,
"ignoreTypeReferences": true,
"allowNamedExports": true
}
],
"eslint/radix": "warn",
"oxc/bad-min-max-func": "error",
"react/void-dom-elements-no-children": "error",
"import/namespace": "warn",
"oxc/no-accumulating-spread": "warn",
"eslint/no-eval": "error",
"eslint/no-script-url": "error",
"react/jsx-no-script-url": "error",
"react/no-danger": "error",
"eslint/prefer-const": "warn",
"eslint/prefer-exponentiation-operator": "warn",
"eslint/prefer-template": "warn",
"typescript/consistent-type-imports": [
"warn",
{
"disallowTypeAnnotations": false
}
],
"typescript/no-non-null-assertion": "warn",
"typescript/prefer-function-type": "warn",
"typescript/prefer-literal-enum-member": "warn",
"unicorn/prefer-node-protocol": "warn",
"eslint/array-callback-return": [
"error",
{
"checkForEach": true,
"allowVoid": true
}
],
"eslint/default-case-last": "warn",
"eslint/eqeqeq": [
"error",
"always",
{
"null": "ignore"
}
],
"eslint/no-label-var": "error",
"eslint/no-labels": [
"warn",
{
"allowLoop": true
}
],
"eslint/no-proto": "warn",
"eslint/no-self-compare": "error",
"eslint/no-template-curly-in-string": "warn",
"oxc/approx-constant": "warn",
"oxc/misrefactored-assign-op": "error",
"oxc/no-const-enum": "warn",
"react/no-array-index-key": "error",
"typescript/adjacent-overload-signatures": "warn",
"typescript/no-invalid-void-type": "warn",
"unicorn/no-document-cookie": "warn",
"unicorn/no-instanceof-array": "warn",
"unicorn/no-thenable": "error",
"unicorn/prefer-number-properties": "off",
"eslint/no-console": [
"warn",
{
"allow": [
"info",
"warn",
"error",
"group",
"groupEnd",
"table",
"assert",
"countReset",
"count",
"dir",
"time",
"timeEnd",
"timeStamp"
]
}
],
"eslint/no-else-return": [
"error",
{
"allowElseIf": false
}
],
"eslint/no-shadow": [
"error",
{
"ignoreTypeValueShadow": true,
"ignoreFunctionTypeParameterNameValueShadow": true
}
],
"eslint/object-shorthand": ["error", "always"],
"typescript/array-type": "off",
"vitest/require-mock-type-parameters": "off",
"vitest/require-to-throw-message": "off",
"typescript/no-explicit-any": "off",
"unicorn/no-array-for-each": "off",
"import/no-cycle": "off",
"react/refs": "off",
"eslint/constructor-super": "off",
"eslint/for-direction": "off",
"eslint/getter-return": "off",
"eslint/no-async-promise-executor": "off",
"eslint/no-class-assign": "off",
"eslint/no-compare-neg-zero": "off",
"eslint/no-cond-assign": "off",
"eslint/no-const-assign": "off",
"eslint/no-constant-binary-expression": "off",
"eslint/no-constant-condition": "off",
"eslint/no-control-regex": "off",
"eslint/no-debugger": "off",
"eslint/no-delete-var": "off",
"eslint/no-dupe-class-members": "off",
"eslint/no-dupe-else-if": "off",
"eslint/no-dupe-keys": "off",
"eslint/no-duplicate-case": "off",
"eslint/no-empty-character-class": "off",
"eslint/no-empty-pattern": "off",
"eslint/no-empty-static-block": "off",
"eslint/no-ex-assign": "off",
"eslint/no-extra-boolean-cast": "off",
"eslint/no-func-assign": "off",
"eslint/no-global-assign": "off",
"eslint/no-import-assign": "off",
"eslint/no-invalid-regexp": "off",
"eslint/no-irregular-whitespace": "off",
"eslint/no-loss-of-precision": "off",
"eslint/no-misleading-character-class": "off",
"eslint/no-new-native-nonconstructor": "off",
"eslint/no-nonoctal-decimal-escape": "off",
"eslint/no-obj-calls": "off",
"eslint/no-self-assign": "off",
"eslint/no-setter-return": "off",
"eslint/no-shadow-restricted-names": "off",
"eslint/no-sparse-arrays": "off",
"eslint/no-this-before-super": "off",
"eslint/no-unassigned-vars": "off",
"eslint/no-unreachable": "off",
"eslint/no-unsafe-finally": "off",
"eslint/no-unsafe-negation": "off",
"eslint/no-unsafe-optional-chaining": "off",
"eslint/no-unused-expressions": "off",
"eslint/no-unused-labels": "off",
"eslint/no-unused-private-class-members": "off",
"eslint/no-unused-vars": "off",
"eslint/no-useless-backreference": "off",
"eslint/no-useless-catch": "off",
"eslint/no-useless-escape": "off",
"eslint/no-with": "off",
"eslint/require-yield": "off",
"eslint/use-isnan": "off",
"eslint/valid-typeof": "off",
"import/default": "off",
"react/error-boundaries": "off",
"react/exhaustive-deps": "off",
"react/globals": "off",
"react/immutability": "off",
"react/incompatible-library": "off",
"react/jsx-key": "off",
"react/jsx-no-duplicate-props": "off",
"react/jsx-no-undef": "off",
"react/no-children-prop": "off",
"react/no-danger-with-children": "off",
"react/no-direct-mutation-state": "off",
"react/no-find-dom-node": "off",
"react/no-is-mounted": "off",
"react/no-render-return-value": "off",
"react/no-string-refs": "off",
"react/preserve-manual-memoization": "off",
"react/purity": "off",
"react/set-state-in-effect": "off",
"react/set-state-in-render": "off",
"react/static-components": "off",
"react/use-memo": "off",
"react/void-use-memo": "off",
"typescript/no-duplicate-enum-values": "off",
"typescript/no-extra-non-null-assertion": "off",
"typescript/no-misused-new": "off",
"typescript/no-non-null-asserted-optional-chain": "off",
"typescript/no-this-alias": "off",
"typescript/no-unsafe-declaration-merging": "off",
"typescript/no-wrapper-object-types": "off",
"typescript/prefer-as-const": "off",
"typescript/prefer-namespace-keyword": "off",
"typescript/triple-slash-reference": "off",
"vitest/no-duplicate-hooks": "error",
"vitest/expect-expect": "off",
"vitest/no-conditional-expect": "off",
"vitest/no-disabled-tests": "off",
"vitest/no-focused-tests": "off",
"vitest/no-standalone-expect": "off",
"vitest/require-local-test-context-for-concurrent-snapshots": "off",
"vitest/valid-describe-callback": "off",
"vitest/valid-expect": "off",
"vitest/valid-expect-in-promise": "off",
"vitest/valid-title": "off"
},
"overrides": [
{
"files": ["**/*.stories.ts", "**/*.stories.tsx", "scripts/*.ts"],
"rules": {
"eslint/no-console": "off"
}
}
]
}
+1 -1
View File
@@ -2,7 +2,7 @@
"recommendations": [
"dbaeumer.vscode-eslint",
"stylelint.vscode-stylelint",
"biomejs.biome",
"oxc.oxc-vscode",
"esbenp.prettier-vscode",
"denoland.vscode-deno",
"github.vscode-github-actions",
+1
View File
@@ -17,6 +17,7 @@ defaultSemverRangePrefix: ""
npmMinimalAgeGate: 7d
npmPreapprovedPackages:
- "@navikt/*"
enableTelemetry: false
yarnPath: .yarn/releases/yarn-4.18.0.cjs
+4
View File
@@ -1,5 +1,9 @@
# @navikt/aksel-icons
## 8.20.1
## 8.20.0
## 8.19.2
## 8.19.1
+1 -2
View File
@@ -13,8 +13,7 @@ files.forEach((file) => {
data = data.map((x) => {
return x.includes("React.ForwardRefExoticComponent")
? x.split(":")[0] +
`: React.ForwardRefExoticComponent<React.SVGProps<SVGSVGElement> & SVGRProps & React.RefAttributes<SVGSVGElement>>;`
? `${x.split(":")[0]}: React.ForwardRefExoticComponent<React.SVGProps<SVGSVGElement> & SVGRProps & React.RefAttributes<SVGSVGElement>>;`
: x;
});
-1
View File
@@ -15,7 +15,6 @@ ymlList.forEach((file) => {
const ymlData = load(fs.readFileSync(`${basePath}/${file}`), {
schema: JSON_SCHEMA,
});
// biome-ignore lint/complexity/useOptionalChain: optional chain not supported in enviroment. Consider revriting to Typescript
if (ymlData.keywords && ymlData.keywords.includes("[ignore-docs]")) {
return;
}
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-icons",
"version": "8.19.2",
"version": "8.20.1",
"author": "Aksel | Nav",
"homepage": "https://aksel.nav.no/ikoner",
"repository": {
@@ -78,16 +78,16 @@
"@types/lodash": "4.17.25",
"@types/react": "19.2.14",
"adm-zip": "^0.6.1",
"concurrently": "10.0.4",
"concurrently": "10.0.6",
"copyfiles": "^2.4.1",
"fast-glob": "3.3.3",
"hast-util-select": "6.0.4",
"js-yaml": "^5.4.3",
"js-yaml": "^5.5.0",
"lodash": "4.18.1",
"prettier": "3.9.6",
"react": "19.3.0",
"react": "19.3.1",
"rehype-parse": "9.0.1",
"tsc-alias": "1.9.1",
"tsc-alias": "1.9.7",
"tsx": "^4.23.15",
"typescript": "6.0.3",
"unified": "11.0.5",
-1
View File
@@ -1,4 +1,3 @@
/* biome-ignore-all lint/correctness/useHookAtTopLevel: `useId` is invariant at runtime. */
import React, { useEffect, useState } from "react";
// https://github.com/mui/material-ui/blob/master/packages/mui-utils/src/useId.ts
+4
View File
@@ -1,5 +1,9 @@
# @navikt/aksel-stylelint
## 8.20.1
## 8.20.0
## 8.19.2
## 8.19.1
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-stylelint",
"version": "8.19.2",
"version": "8.20.1",
"type": "module",
"engines": {
"node": ">=18.12.0"
@@ -43,10 +43,10 @@
"dev": "yarn watch:lint"
},
"devDependencies": {
"@navikt/ds-css": "^8.19.2",
"@navikt/ds-tokens": "^8.19.2",
"concurrently": "10.0.4",
"postcss-selector-parser": "^7.1.6",
"@navikt/ds-css": "^8.20.1",
"@navikt/ds-tokens": "^8.20.1",
"concurrently": "10.0.6",
"postcss-selector-parser": "^7.1.7",
"postcss-value-parser": "^4.2.0",
"stylelint": "^17.15.0",
"stylelint-test-rule-node": "^1.2.0",
+4
View File
@@ -1,5 +1,9 @@
# @navikt/aksel
## 8.20.1
## 8.20.0
## 8.19.2
## 8.19.1
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel",
"version": "8.19.2",
"version": "8.20.1",
"description": "Aksel command line interface. Codemods and other utilities for Aksel users.",
"author": "Aksel | Nav designsystem team",
"license": "MIT",
@@ -40,13 +40,13 @@
"url": "https://github.com/navikt/aksel/issues"
},
"dependencies": {
"chalk": "5.6.2",
"chalk": "6.0.1",
"cli-progress": "3.12.0",
"clipboardy": "5.3.2",
"commander": "15.0.0",
"enquirer": "2.4.1",
"fast-glob": "3.3.3",
"figlet": "1.11.3",
"figlet": "1.12.0",
"is-git-clean": "1.1.0",
"jscodeshift": "17.4.0",
"lodash": "4.18.1"
@@ -37,7 +37,7 @@ describe("getJSXLiteralValue", () => {
});
test("returns null for TemplateLiteral with expressions", () => {
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing only
// oxlint-disable-next-line eslint/no-template-curly-in-string -- Testing only
const node = getAttrValue("<Component prop={`hello ${name}`} />");
expect(getJSXLiteralValue(node)).toBe(null);
});
@@ -209,7 +209,7 @@ describe("getTokenRegex JS", () => {
expect("ATextRed").toMatch(regex);
expect(" ATextRed ").toMatch(regex);
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing token match inside template string
// oxlint-disable-next-line eslint/no-template-curly-in-string -- Testing token match inside template string
expect(" background: `${ATextRed}` ").toMatch(regex);
});
@@ -26,7 +26,7 @@ describe("v8-tokens-js keeps boundaries intact", () => {
["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
// oxlint-disable-next-line eslint/no-template-curly-in-string -- Testing token inside template literal
["const a = `${ASurfaceDefault}`;", "const a = `${BgDefault}`;"],
])("%s", (input, output) => {
expect(runJs(`${importLine}\n${input}`)).toBe(
+18
View File
@@ -1,5 +1,23 @@
# @navikt/ds-css
## 8.20.1
### Patch Changes
- Badge: Increase inline padding by 1px for better optical balance ([#5443](https://github.com/navikt/aksel/pull/5443))
## 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
+4 -4
View File
@@ -139,14 +139,14 @@ async function bundle() {
/* ------------------------------ /global build ----------------------------- */
for (const style of BASELINE_CSS_LIST) {
function parser(input: string) {
const parser = (input: string) => {
const parsed = input
.split("\n")
.filter((line) => line.startsWith("@import"))
.filter((line) => line.includes(style))
.join("\n");
return `${layerDefinition}\n${parsed}`;
}
};
await bundleCSS(parser).then((file) => {
writeFile({
@@ -208,13 +208,13 @@ async function bundle() {
}
for (const componentLine of componentFiles()) {
function parser(input: string) {
const parser = (input: string) => {
const parsed = input
.split("\n")
.filter((line) => line === componentLine)
.join("\n");
return `${layerDefinition}\n${parsed}`;
}
};
await bundleCSS(parser).then((file) => {
const componentName = componentLine
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-css",
"version": "8.19.2",
"version": "8.20.1",
"description": "CSS for Nav Designsystem",
"author": "Aksel | Nav designsystem team",
"keywords": [
@@ -32,7 +32,7 @@
"css:get-version": "node config/get-version.js"
},
"devDependencies": {
"@navikt/ds-tokens": "^8.19.2",
"@navikt/ds-tokens": "^8.20.1",
"browserslist": "^4.28.7",
"esbuild": "^0.28.2",
"fast-glob": "3.3.3",
+3 -1
View File
@@ -16,7 +16,9 @@
justify-content: center;
min-width: var(--__axc-badge-size);
height: var(--__axc-badge-size);
padding: var(--ax-space-4);
/* 1px optical adjustment inline */
padding: var(--ax-space-4) calc(var(--ax-space-4) + var(--ax-space-1));
border-radius: var(--ax-radius-16);
background: var(--ax-bg-strong);
color: var(--ax-text-contrast);
+35 -23
View File
@@ -19,7 +19,8 @@
}
}
/* Radio card */
/* Checkbox and Radio card */
.aksel-checkbox-card,
.aksel-radio-card {
width: 100%;
align-content: flex-start;
@@ -30,26 +31,30 @@
border-radius: var(--ax-radius-8);
&:focus-within::after,
&.aksel-radio--small: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;
}
}
/* Radio card group */
.aksel-radio-card-group {
& > .aksel-radio-buttons {
display: flex;
flex-direction: column;
/* 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-hgrid:not(.aksel-hgrid-gap) {
gap: var(--ax-space-12);
}
}
}
&.aksel-radio-group--small > .aksel-radio-buttons > .aksel-hgrid:not(.aksel-hgrid-gap) {
.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);
}
}
@@ -65,14 +70,11 @@
}
}
.aksel-checkbox-card.aksel-checkbox--small,
.aksel-radio-card.aksel-radio--small {
padding: var(--ax-space-12) var(--ax-space-16);
}
.aksel-radio-card-group.aksel-radio-group--small > .aksel-radio-buttons {
gap: var(--ax-space-8);
}
/* Label */
.aksel-checkbox__label,
.aksel-radio__label {
@@ -246,8 +248,14 @@
--__axc-radio-checkbox-marker-border: 7px;
}
/* Radio card border in hover and checked states */
/* 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))
) {
@@ -255,7 +263,10 @@
box-shadow: 0 0 0 1px var(--ax-border-default);
}
/* Radio card background in checked state */
/* 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)
) {
@@ -265,6 +276,8 @@
/* 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) {
border-color: var(--ax-border-strong);
@@ -283,18 +296,16 @@
opacity: var(--ax-opacity-disabled);
}
/* Radio card background in disabled and readonly states */
/* 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);
}
.aksel-radio-card.aksel-radio--disabled > .aksel-radio__input:checked {
border-color: var(--ax-border-neutral-strong);
}
/* 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 {
cursor: not-allowed;
}
@@ -302,6 +313,7 @@
/* 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 {
cursor: default;
}
-2
View File
@@ -2,7 +2,6 @@
display: flex;
flex-direction: column;
min-height: 100vh;
/* biome-ignore lint/suspicious/noDuplicateProperties: vh is fallback for browsers that do not support lvh */
min-height: 100lvh;
}
@@ -20,7 +19,6 @@
.aksel-page__content--fullheight {
min-height: 100vh;
/* biome-ignore lint/suspicious/noDuplicateProperties: vh is fallback for browsers that do not support lvh */
min-height: 100lvh;
}
+12
View File
@@ -87,6 +87,18 @@
}
}
/* 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 {
+26
View File
@@ -1,5 +1,31 @@
# @navikt/ds-react
## 8.20.1
### Patch Changes
- Updated dependencies []:
- @navikt/ds-tokens@8.20.1
- @navikt/aksel-icons@8.20.1
## 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
+28 -18
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-react",
"version": "8.19.2",
"version": "8.20.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,22 +68,22 @@
},
"./PREVIEW/Lookup": {
"import": {
"types": "./esm/lookup/index.d.ts",
"default": "./esm/lookup/index.js"
"types": "./esm/lookup/index-deprecated.d.ts",
"default": "./esm/lookup/index-deprecated.js"
},
"require": {
"types": "./cjs/lookup/index.d.ts",
"default": "./cjs/lookup/index.js"
"types": "./cjs/lookup/index-deprecated.d.ts",
"default": "./cjs/lookup/index-deprecated.js"
}
},
"./PREVIEW/Badge": {
"import": {
"types": "./esm/badge/index.d.ts",
"default": "./esm/badge/index.js"
"types": "./esm/badge/index-deprecated.d.ts",
"default": "./esm/badge/index-deprecated.js"
},
"require": {
"types": "./cjs/badge/index.d.ts",
"default": "./cjs/badge/index.js"
"types": "./cjs/badge/index-deprecated.d.ts",
"default": "./cjs/badge/index-deprecated.js"
}
},
"./PREVIEW/RadioCard": {
@@ -96,6 +96,16 @@
"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/theme": {
"types": "./esm/types/theme.d.ts"
},
@@ -778,30 +788,30 @@
"dependencies": {
"@floating-ui/react": "0.27.20",
"@floating-ui/react-dom": "^2.1.9",
"@navikt/aksel-icons": "^8.19.2",
"@navikt/ds-tokens": "^8.19.2",
"@navikt/aksel-icons": "^8.20.1",
"@navikt/ds-tokens": "^8.20.1",
"date-fns": "^4.0.0",
"react-day-picker": "10.0.1"
},
"devDependencies": {
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/dom": "10.4.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@testing-library/user-event": "^14.6.8",
"@types/jscodeshift": "17.3.0",
"@types/node": "^24.10.9",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"concurrently": "10.0.4",
"concurrently": "10.0.6",
"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.3.1",
"react-dom": "19.3.1",
"rimraf": "6.1.3",
"swr": "^2.4.2",
"tsc-alias": "1.9.1",
"tsc-alias": "1.9.7",
"tsx": "^4.23.15",
"typescript": "6.0.3",
"vitest": "4.1.11"
@@ -1,5 +1,5 @@
import React, { useRef } from "react";
import { useModalContext } from "../../modal/Modal.context";
import { useModalContext } from "../../modal/root/ModalRoot.context";
import { useId } from "../../utils-external";
import {
Menu,
@@ -16,10 +16,8 @@ const ActionMenuShortcut = ({ children }: ShortcutProps) => {
<ActionMenuMarker placement="right">
{parsed.map((char, index) => (
<span
key={`${children}-${
// biome-ignore lint/suspicious/noArrayIndexKey: This is a static list of characters, so using the index as a key is acceptable in this case
index
}-${char}`}
// oxlint-disable-next-line react/no-array-index-key -- This is a static list of characters, so using the index as a key is acceptable in this case
key={`${children}-${index}-${char}`}
className="aksel-action-menu__shortcut"
>
{char}
+8 -1
View File
@@ -112,4 +112,11 @@ export namespace Badge {
export type Props = BadgeProps;
}
export { Badge };
/** @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 };
@@ -0,0 +1,2 @@
"use client";
export { BadgeDeprecated as Badge } from "./Badge";
+1 -1
View File
@@ -3,7 +3,7 @@ import React from "react";
import { PersonIcon } from "@navikt/aksel-icons";
import { VStack } from "../primitives/stack";
import { renderStoriesForChromatic } from "../utils/renderStoriesForChromatic";
import Chat, { POSITIONS, SIZES, VARIANTS } from "./Chat";
import { Chat, POSITIONS, SIZES, VARIANTS } from "./root/ChatRoot";
export default {
title: "ds-react/Chat",
@@ -1,8 +1,8 @@
import React, { type HTMLAttributes, forwardRef } from "react";
import { Detail, Heading, type HeadingProps } from "../typography";
import { cl } from "../utils/helpers";
import { Detail, Heading, type HeadingProps } from "../../typography";
import { cl } from "../../utils/helpers";
export interface ChatBubbleProps extends HTMLAttributes<HTMLDivElement> {
interface ChatBubbleProps extends HTMLAttributes<HTMLDivElement> {
/**
* Bubble text.
*/
@@ -26,7 +26,7 @@ export interface ChatBubbleProps extends HTMLAttributes<HTMLDivElement> {
toptextHeadingLevel?: Exclude<HeadingProps["level"], "1">;
}
const Bubble = forwardRef<HTMLDivElement, ChatBubbleProps>(
const ChatBubble = forwardRef<HTMLDivElement, ChatBubbleProps>(
(
{
children,
@@ -65,4 +65,5 @@ const Bubble = forwardRef<HTMLDivElement, ChatBubbleProps>(
},
);
export default Bubble;
export { ChatBubble };
export type { ChatBubbleProps };
+2 -2
View File
@@ -1,3 +1,3 @@
"use client";
export { default as Chat, ChatBubble } from "./Chat";
export type { ChatProps, ChatBubbleProps } from "./Chat";
export { Chat, ChatBubble } from "./root/ChatRoot";
export type { ChatProps, ChatBubbleProps } from "./root/ChatRoot";
@@ -1,13 +1,13 @@
import React, { type HTMLAttributes, forwardRef } from "react";
import type { AkselStatusColorRole } from "@navikt/ds-tokens/types";
import type { AkselColor } from "../types";
import { BodyLong, type HeadingProps } from "../typography";
import { cl } from "../utils/helpers";
import Bubble, { type ChatBubbleProps } from "./Bubble";
import type { AkselColor } from "../../types";
import { BodyLong, type HeadingProps } from "../../typography";
import { cl } from "../../utils/helpers";
import { ChatBubble, type ChatBubbleProps } from "../bubble/ChatBubble";
export const VARIANTS = ["subtle", "info", "neutral"] as const;
export const POSITIONS = ["left", "right"] as const;
export const SIZES = ["medium", "small"] as const;
const VARIANTS = ["subtle", "info", "neutral"] as const;
const POSITIONS = ["left", "right"] as const;
const SIZES = ["medium", "small"] as const;
interface ChatProps extends HTMLAttributes<HTMLDivElement> {
/**
@@ -62,33 +62,7 @@ interface ChatProps extends HTMLAttributes<HTMLDivElement> {
"data-color"?: Exclude<AkselColor, AkselStatusColorRole>;
}
interface ChatComponent extends React.ForwardRefExoticComponent<
ChatProps & React.RefAttributes<HTMLDivElement>
> {
/**
* @see 🏷️ {@link ChatBubbleProps}
*/
Bubble: typeof Bubble;
}
/**
* A component for communicating a dialog between two or more parties.
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/chat)
* @see 🏷️ {@link ChatProps}
*
* @example
* ```jsx
* <Chat avatar="A" name="Alice" timestamp="01.01.21 14:00">
* <Chat.Bubble>Hello!</Chat.Bubble>
* <Chat.Bubble>How can I help you?</Chat.Bubble>
* </Chat>
* <Chat avatar="B" name="Bob" timestamp="01.01.21 14:01" position="right">
* <Chat.Bubble>Hi there!</Chat.Bubble>
* </Chat>
* ```
*/
export const Chat = forwardRef<HTMLDivElement, ChatProps>(
const ChatRoot = forwardRef<HTMLDivElement, ChatProps>(
(
{
children,
@@ -143,7 +117,7 @@ export const Chat = forwardRef<HTMLDivElement, ChatProps>(
</div>
);
},
) as ChatComponent;
);
function variantToColor(variant: ChatProps["variant"]): AkselColor {
switch (variant) {
@@ -158,8 +132,29 @@ function variantToColor(variant: ChatProps["variant"]): AkselColor {
}
}
Chat.Bubble = Bubble;
/**
* A component for communicating a dialog between two or more parties.
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/chat)
* @see 🏷️ {@link ChatProps}
*
* @example
* ```jsx
* <Chat avatar="A" name="Alice" timestamp="01.01.21 14:00">
* <Chat.Bubble>Hello!</Chat.Bubble>
* <Chat.Bubble>How can I help you?</Chat.Bubble>
* </Chat>
* <Chat avatar="B" name="Bob" timestamp="01.01.21 14:01" position="right">
* <Chat.Bubble>Hi there!</Chat.Bubble>
* </Chat>
* ```
*/
const Chat = Object.assign(ChatRoot, {
/**
* @see 🏷️ {@link ChatBubbleProps}
*/
Bubble: ChatBubble,
});
export default Chat;
export { Bubble as ChatBubble };
export { Chat, ChatBubble, VARIANTS, POSITIONS, SIZES };
export type { ChatProps, ChatBubbleProps };
+6 -6
View File
@@ -1,7 +1,7 @@
"use client";
export { default as Chips, type ChipsProps } from "./root/ChipsRoot";
export {
ChipsRemovable,
type ChipsRemovableProps,
} from "./removable/ChipsRemovable";
export { ChipsToggle, type ChipsToggleProps } from "./toggle/ChipsToggle";
export { Chips, ChipsRemovable, ChipsToggle } from "./root/ChipsRoot";
export type {
ChipsProps,
ChipsRemovableProps,
ChipsToggleProps,
} from "./root/ChipsRoot";
@@ -4,7 +4,7 @@ import type { AkselColor } from "../../types";
import { cl, composeEventHandlers } from "../../utils/helpers";
import { useI18n } from "../../utils/i18n/i18n.hooks";
export interface ChipsRemovableProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
interface ChipsRemovableProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: string;
/**
* @deprecated Use `data-color` prop instead.
@@ -22,10 +22,7 @@ export interface ChipsRemovableProps extends React.ButtonHTMLAttributes<HTMLButt
"data-color"?: AkselColor;
}
export const ChipsRemovable = forwardRef<
HTMLButtonElement,
ChipsRemovableProps
>(
const ChipsRemovable = forwardRef<HTMLButtonElement, ChipsRemovableProps>(
(
{
children,
@@ -46,6 +43,7 @@ export const ChipsRemovable = forwardRef<
data-color={color ?? variantToColor(variant)}
{...rest}
ref={ref}
// oxlint-disable-next-line react/button-has-type -- React's button type union is validated by TypeScript and defaults to "button".
type={type}
className={cl(
"aksel-chips__chip aksel-chips__removable aksel-chips--icon-right",
@@ -76,4 +74,5 @@ function variantToColor(
}
}
export default ChipsRemovable;
export { ChipsRemovable };
export type { ChipsRemovableProps };
+39 -41
View File
@@ -1,7 +1,10 @@
import React, { type HTMLAttributes, forwardRef } from "react";
import { cl } from "../../utils/helpers";
import { ChipsRemovable } from "../removable/ChipsRemovable";
import { ChipsToggle } from "../toggle/ChipsToggle";
import {
ChipsRemovable,
type ChipsRemovableProps,
} from "../removable/ChipsRemovable";
import { ChipsToggle, type ChipsToggleProps } from "../toggle/ChipsToggle";
interface ChipsProps extends HTMLAttributes<HTMLUListElement> {
children: React.ReactNode;
@@ -12,22 +15,24 @@ interface ChipsProps extends HTMLAttributes<HTMLUListElement> {
size?: "medium" | "small";
}
interface ChipsComponent extends React.ForwardRefExoticComponent<
ChipsProps & React.RefAttributes<HTMLUListElement>
> {
/**
* Toggle between selected-states.
* @see 🏷️ {@link ToggleChipsProps}
* @see [🤖 OverridableComponent](https://aksel.nav.no/grunnleggende/kode/overridablecomponent) support
*/
Toggle: typeof ChipsToggle;
/**
* Remove filter or the likes on click.
* @see 🏷️ {@link RemovableChipsProps}
* @see [🤖 OverridableComponent](https://aksel.nav.no/grunnleggende/kode/overridablecomponent) support
*/
Removable: typeof ChipsRemovable;
}
const ChipsRoot = forwardRef<HTMLUListElement, ChipsProps>(
({ className, size = "medium", children, ...rest }, ref) => {
return (
<ul
{...rest}
ref={ref}
className={cl("aksel-chips", className, `aksel-chips--${size}`, {
"aksel-body-short aksel-body-short--small": size === "medium",
"aksel-detail aksel-detail--small": size === "small",
})}
>
{React.Children.map(children, (chip, index) => {
return <li key={chip?.toString() || index}>{chip}</li>;
})}
</ul>
);
},
);
/**
* A component that displays a list of items as chips.
@@ -56,27 +61,20 @@ interface ChipsComponent extends React.ForwardRefExoticComponent<
* </Chips>
* ```
*/
export const Chips = forwardRef<HTMLUListElement, ChipsProps>(
({ className, size = "medium", children, ...rest }, ref) => {
return (
<ul
{...rest}
ref={ref}
className={cl("aksel-chips", className, `aksel-chips--${size}`, {
"aksel-body-short aksel-body-short--small": size === "medium",
"aksel-detail aksel-detail--small": size === "small",
})}
>
{React.Children.map(children, (chip, index) => {
return <li key={chip?.toString() || index}>{chip}</li>;
})}
</ul>
);
},
) as ChipsComponent;
const Chips = Object.assign(ChipsRoot, {
/**
* Toggle between selected-states.
* @see 🏷️ {@link ChipsToggleProps}
* @see [🤖 OverridableComponent](https://aksel.nav.no/grunnleggende/kode/overridablecomponent) support
*/
Toggle: ChipsToggle,
/**
* Remove filter or the likes on click.
* @see 🏷️ {@link ChipsRemovableProps}
* @see [🤖 OverridableComponent](https://aksel.nav.no/grunnleggende/kode/overridablecomponent) support
*/
Removable: ChipsRemovable,
});
Chips.Toggle = ChipsToggle;
Chips.Removable = ChipsRemovable;
export default Chips;
export type { ChipsProps };
export { Chips, ChipsRemovable, ChipsToggle };
export type { ChipsProps, ChipsRemovableProps, ChipsToggleProps };
@@ -3,7 +3,7 @@ import type { AkselColor } from "../../types";
import type { OverridableComponent } from "../../utils-external";
import { cl } from "../../utils/helpers";
export interface ChipsToggleProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
interface ChipsToggleProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: string;
/**
* Toggles aria-pressed and visual changes
@@ -26,68 +26,66 @@ export interface ChipsToggleProps extends React.ButtonHTMLAttributes<HTMLButtonE
checkmark?: boolean;
}
export const ChipsToggle: OverridableComponent<
ChipsToggleProps,
HTMLButtonElement
> = forwardRef(
(
{
className,
children,
selected,
variant,
checkmark = true,
as: Component = "button",
"data-color": color,
...rest
const ChipsToggle: OverridableComponent<ChipsToggleProps, HTMLButtonElement> =
forwardRef(
(
{
className,
children,
selected,
variant,
checkmark = true,
as: Component = "button",
"data-color": color,
...rest
},
ref,
) => {
return (
<Component
data-color={color ?? variantToColor(variant)}
{...rest}
ref={ref}
className={cl("aksel-chips__chip aksel-chips__toggle", className, {
"aksel-chips__toggle--with-checkmark": checkmark,
})}
aria-pressed={selected}
data-pressed={selected}
>
{checkmark && (
<svg
aria-hidden
className="aksel-chips__toggle-icon"
width="1.25em"
height="1.25em"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
focusable={false}
>
{selected ? (
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10 18.125C14.4873 18.125 18.125 14.4873 18.125 10C18.125 5.51269 14.4873 1.875 10 1.875C5.51269 1.875 1.875 5.51269 1.875 10C1.875 14.4873 5.51269 18.125 10 18.125ZM14.128 7.72904C14.3695 7.44357 14.3339 7.01635 14.0485 6.7748C13.763 6.53326 13.3358 6.56886 13.0942 6.85432L8.60428 12.1606L6.41627 9.97263C6.15185 9.70822 5.72315 9.70822 5.45873 9.97263C5.19431 10.2371 5.19431 10.6658 5.45873 10.9302L8.16706 13.6385C8.30095 13.7724 8.48479 13.8441 8.67397 13.8362C8.86316 13.8284 9.0404 13.7416 9.16271 13.5971L14.128 7.72904Z"
fill="currentColor"
/>
) : (
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10 3.125C6.20304 3.125 3.125 6.20304 3.125 10C3.125 13.797 6.20304 16.875 10 16.875C13.797 16.875 16.875 13.797 16.875 10C16.875 6.20304 13.797 3.125 10 3.125ZM1.875 10C1.875 5.51269 5.51269 1.875 10 1.875C14.4873 1.875 18.125 5.51269 18.125 10C18.125 14.4873 14.4873 18.125 10 18.125C5.51269 18.125 1.875 14.4873 1.875 10Z"
/* After removing old fallbacks, change to currentColor */
fill="var(--ax-text-default)"
/>
)}
</svg>
)}
<span className="aksel-chips__chip-text">{children}</span>
</Component>
);
},
ref,
) => {
return (
<Component
data-color={color ?? variantToColor(variant)}
{...rest}
ref={ref}
className={cl("aksel-chips__chip aksel-chips__toggle", className, {
"aksel-chips__toggle--with-checkmark": checkmark,
})}
aria-pressed={selected}
data-pressed={selected}
>
{checkmark && (
<svg
aria-hidden
className="aksel-chips__toggle-icon"
width="1.25em"
height="1.25em"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
focusable={false}
>
{selected ? (
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10 18.125C14.4873 18.125 18.125 14.4873 18.125 10C18.125 5.51269 14.4873 1.875 10 1.875C5.51269 1.875 1.875 5.51269 1.875 10C1.875 14.4873 5.51269 18.125 10 18.125ZM14.128 7.72904C14.3695 7.44357 14.3339 7.01635 14.0485 6.7748C13.763 6.53326 13.3358 6.56886 13.0942 6.85432L8.60428 12.1606L6.41627 9.97263C6.15185 9.70822 5.72315 9.70822 5.45873 9.97263C5.19431 10.2371 5.19431 10.6658 5.45873 10.9302L8.16706 13.6385C8.30095 13.7724 8.48479 13.8441 8.67397 13.8362C8.86316 13.8284 9.0404 13.7416 9.16271 13.5971L14.128 7.72904Z"
fill="currentColor"
/>
) : (
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10 3.125C6.20304 3.125 3.125 6.20304 3.125 10C3.125 13.797 6.20304 16.875 10 16.875C13.797 16.875 16.875 13.797 16.875 10C16.875 6.20304 13.797 3.125 10 3.125ZM1.875 10C1.875 5.51269 5.51269 1.875 10 1.875C14.4873 1.875 18.125 5.51269 18.125 10C18.125 14.4873 14.4873 18.125 10 18.125C5.51269 18.125 1.875 14.4873 1.875 10Z"
/* After removing old fallbacks, change to currentColor */
fill="var(--ax-text-default)"
/>
)}
</svg>
)}
<span className="aksel-chips__chip-text">{children}</span>
</Component>
);
},
);
);
function variantToColor(
variant?: ChipsToggleProps["variant"],
@@ -102,4 +100,5 @@ function variantToColor(
}
}
export default ChipsToggle;
export { ChipsToggle };
export type { ChipsToggleProps };
@@ -149,7 +149,7 @@ export namespace DataGrid {
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Table {
// biome-ignore lint/suspicious/noShadow: intentional namespace re-export
// oxlint-disable-next-line eslint/no-shadow -- intentional namespace re-export
export type Props<T = unknown> = DataGridTable.Props<T>;
export type Sorting = DataGridTable.Sorting;
export type SortEntry = DataGridTable.SortEntry;
@@ -161,7 +161,7 @@ export namespace DataGrid {
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Preferences {
// biome-ignore lint/suspicious/noShadow: intentional namespace re-export
// oxlint-disable-next-line eslint/no-shadow -- intentional namespace re-export
export type Props = DataGridPreferences.Props;
}
}
@@ -1,4 +1,3 @@
/** biome-ignore-all lint/correctness/useHookAtTopLevel: False positive because of the way forwardRef() is added */
import React, {
forwardRef,
useCallback,
@@ -139,7 +139,6 @@ function useTableColumnResize({
[resizeMin, resizeMax, setWidth],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: We only want to run this on mount and when autoResizeOnce changes
useEffect(
function autoResizeColumn() {
if (!autoResizeOnce) {
@@ -86,7 +86,9 @@ const AutoSuggest = forwardRef<HTMLInputElement, AutoSuggestProps>(
<Floating>
<Listbox setVirtuallyFocusedOptionId={setVirtuallyFocusedOptionId}>
<Floating.Anchor>
<Listbox.InputSlot>
<Listbox.InputSlot
virtuallyFocusedOptionId={virtuallyFocusedOptionId}
>
<Search
label="Tabellsøk"
variant="simple"
@@ -220,7 +220,6 @@ export const TokenFilter = forwardRef<HTMLDivElement, TokenFilterProps>(
);
return (
// biome-ignore lint/a11y/useSemanticElements: search-tag is too new (baseline 2023)
<div
ref={ref}
className={cl("aksel-property-filter", className)}
@@ -61,7 +61,7 @@ function createGroups<T>(
}
/* Custom groups first, then default group if it has items */
const groups: OptionGroup<T>[] = [...Object.values(customGroups)];
const groups: OptionGroup<T>[] = Object.values(customGroups);
if (defaultGroup.options.length > 0) {
groups.push(defaultGroup);
@@ -3,7 +3,7 @@ import { isSameDay } from "date-fns";
import React, { useState } from "react";
import { Button } from "../button";
import { Dialog } from "../dialog";
import Modal from "../modal/Modal";
import { Modal } from "../modal";
import { Box } from "../primitives/box";
import { HGrid } from "../primitives/grid";
import { HStack, Spacer, VStack } from "../primitives/stack";
@@ -49,16 +49,15 @@ export const Default: StoryObj<DefaultStoryProps> = {
toDate: new Date("Aug 23 2029"),
});
const newProps = {
...(!props.inputfield || props.mode === "multiple"
const newProps =
!props.inputfield || props.mode === "multiple"
? {
open,
onClose: () => setOpen(false),
fromDate: new Date("Aug 23 2020"),
toDate: new Date("Aug 23 2029"),
}
: {}),
};
: {};
const Comp = !props.standalone ? DatePicker : DatePicker.Standalone;
@@ -28,6 +28,7 @@ const DatePickerDayButton = ({
return (
<button
type="button"
{...rest}
ref={ref}
aria-hidden={day.outside}
@@ -104,6 +104,7 @@ const ReactDayPicker = ({
selected={selected}
classNames={LegacyClassNames}
components={{
// oxlint-disable-next-line react/jsx-no-useless-fragment -- react-day-picker requires an Element return type, so null is not allowed.
MonthCaption: () => <></>,
DayButton: useCallback(
(props) => <DatePickerDayButton {...props} locale={locale} />,
@@ -23,7 +23,7 @@ const DatePickerWeekRow = ({
return (
<Show below="sm" asChild>
{/** biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: role="grid" improves a11y reading */}
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role -- role="grid" improves a11y reading */}
<table className="rdp-table" role="grid">
<tbody className="rdp-tbody">
<tr className="rdp-row aksel-date__week-row">
@@ -1,5 +1,5 @@
import React, { forwardRef } from "react";
import { useModalContext } from "../../modal/Modal.context";
import { useModalContext } from "../../modal/root/ModalRoot.context";
import { Portal, type PortalProps } from "../../portal";
import { DialogBackdropInternal } from "../backdrop/DialogBackdropInternal";
import { useDialogContext } from "../root/DialogRoot.context";
@@ -1,10 +1,10 @@
import React, { forwardRef, useContext } from "react";
import { useThemeInternal } from "../theme/Theme";
import { BodyLong } from "../typography";
import { cl } from "../utils/helpers";
import { ExpansionCardContext } from "./context";
import { useThemeInternal } from "../../theme/Theme";
import { BodyLong } from "../../typography";
import { cl } from "../../utils/helpers";
import { ExpansionCardContext } from "../root/ExpansionCardRoot.context";
export interface ExpansionCardContentProps extends React.HTMLAttributes<HTMLDivElement> {
interface ExpansionCardContentProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
@@ -44,4 +44,5 @@ const ExpansionCardContent = forwardRef<
);
});
export default ExpansionCardContent;
export { ExpansionCardContent };
export type { ExpansionCardContentProps };
@@ -1,13 +0,0 @@
import { createContext } from "react";
export type ExpansionCardContextProps = {
open: boolean;
toggleOpen: () => void;
size: "medium" | "small";
};
export const ExpansionCardContext = createContext<ExpansionCardContextProps>({
open: false,
toggleOpen: () => {},
size: "medium",
});
@@ -1,13 +1,13 @@
import React, { forwardRef, useContext } from "react";
import { BodyLong } from "../typography";
import { cl } from "../utils/helpers";
import { ExpansionCardContext } from "./context";
import { BodyLong } from "../../typography";
import { cl } from "../../utils/helpers";
import { ExpansionCardContext } from "../root/ExpansionCardRoot.context";
export interface ExpansionCardDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {
interface ExpansionCardDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {
children: React.ReactNode;
}
export const ExpansionCardDescription = forwardRef<
const ExpansionCardDescription = forwardRef<
HTMLParagraphElement,
ExpansionCardDescriptionProps
>(({ className, ...rest }, ref) => {
@@ -31,4 +31,5 @@ export const ExpansionCardDescription = forwardRef<
);
});
export default ExpansionCardDescription;
export { ExpansionCardDescription };
export type { ExpansionCardDescriptionProps };
@@ -1,10 +1,10 @@
import React, { forwardRef, useContext } from "react";
import { ChevronDownIcon } from "@navikt/aksel-icons";
import { cl } from "../utils/helpers";
import { useI18n } from "../utils/i18n/i18n.hooks";
import { ExpansionCardContext } from "./context";
import { cl } from "../../utils/helpers";
import { useI18n } from "../../utils/i18n/i18n.hooks";
import { ExpansionCardContext } from "../root/ExpansionCardRoot.context";
export interface ExpansionCardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
interface ExpansionCardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
@@ -46,4 +46,5 @@ const ExpansionCardHeader = forwardRef<
);
});
export default ExpansionCardHeader;
export { ExpansionCardHeader };
export type { ExpansionCardHeaderProps };
@@ -1,9 +1,9 @@
"use client";
export {
type ExpansionCardProps,
default as ExpansionCard,
} from "./ExpansionCard";
export { default as ExpansionCardContent } from "./ExpansionCardContent";
export { default as ExpansionCardHeader } from "./ExpansionCardHeader";
export { default as ExpansionCardTitle } from "./ExpansionCardTitle";
export { default as ExpansionCardDescription } from "./ExpansionCardDescription";
ExpansionCard,
ExpansionCardContent,
ExpansionCardHeader,
ExpansionCardTitle,
ExpansionCardDescription,
} from "./root/ExpansionCardRoot";
export type { ExpansionCardProps } from "./root/ExpansionCardRoot";
@@ -0,0 +1,16 @@
import { createContext } from "react";
type ExpansionCardContextProps = {
open: boolean;
toggleOpen: () => void;
size: "medium" | "small";
};
const ExpansionCardContext = createContext<ExpansionCardContextProps>({
open: false,
toggleOpen: () => {},
size: "medium",
});
export { ExpansionCardContext };
export type { ExpansionCardContextProps };
@@ -1,51 +1,24 @@
import React, { forwardRef } from "react";
import type { AkselColor } from "../types";
import type { OverridableComponent } from "../utils-external";
import { cl } from "../utils/helpers";
import { useControllableState } from "../utils/hooks";
import ExpansionCardContent, {
import type { AkselColor } from "../../types";
import { cl } from "../../utils/helpers";
import { useControllableState } from "../../utils/hooks";
import {
ExpansionCardContent,
type ExpansionCardContentProps,
} from "./ExpansionCardContent";
} from "../content/ExpansionCardContent";
import {
ExpansionCardDescription,
type ExpansionCardDescriptionProps,
} from "./ExpansionCardDescription";
import ExpansionCardHeader, {
} from "../description/ExpansionCardDescription";
import {
ExpansionCardHeader,
type ExpansionCardHeaderProps,
} from "./ExpansionCardHeader";
} from "../header/ExpansionCardHeader";
import {
ExpansionCardTitle,
type ExpansionCardTitleProps,
} from "./ExpansionCardTitle";
import { ExpansionCardContext } from "./context";
interface ExpansionCardComponent extends React.ForwardRefExoticComponent<
ExpansionCardProps & React.RefAttributes<HTMLDivElement>
> {
/**
* @see 🏷️ {@link ExpansionCardHeaderProps}
*/
Header: React.ForwardRefExoticComponent<
ExpansionCardHeaderProps & React.RefAttributes<HTMLDivElement>
>;
/**
* @see 🏷️ {@link ExpansionCardTitleProps}
* @see [🤖 OverridableComponent](https://aksel.nav.no/grunnleggende/kode/overridablecomponent) support
*/
Title: OverridableComponent<ExpansionCardTitleProps, HTMLHeadingElement>;
/**
* @see 🏷️ {@link ExpansionCardDescriptionProps}
*/
Description: React.ForwardRefExoticComponent<
ExpansionCardDescriptionProps & React.RefAttributes<HTMLParagraphElement>
>;
/**
* @see 🏷️ {@link ExpansionCardContentProps}
*/
Content: React.ForwardRefExoticComponent<
ExpansionCardContentProps & React.RefAttributes<HTMLDivElement>
>;
}
} from "../title/ExpansionCardTitle";
import { ExpansionCardContext } from "./ExpansionCardRoot.context";
interface ExpansionCardCommonProps extends Omit<
React.HTMLAttributes<HTMLDivElement>,
@@ -93,28 +66,10 @@ type ExpansionCardConditionalProps =
"aria-labelledby": string;
};
export type ExpansionCardProps = ExpansionCardCommonProps &
type ExpansionCardProps = ExpansionCardCommonProps &
ExpansionCardConditionalProps;
/**
* A component that displays an expandable card.
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/expansioncard)
* @see 🏷️ {@link ExpansionCardProps}
*
* @example
* ```jsx
* <ExpansionCard aria-label="Utbetaling av sykepenger">
* <ExpansionCard.Header>
* <ExpansionCard.Title>Utbetaling av sykepenger</ExpansionCard.Title>
* </ExpansionCard.Header>
* <ExpansionCard.Content>
* <Innhold />
* </ExpansionCard.Content>
* </ExpansionCard>
* ```
*/
export const ExpansionCard = forwardRef<HTMLDivElement, ExpansionCardProps>(
const ExpansionCardRoot = forwardRef<HTMLDivElement, ExpansionCardProps>(
(
{
className,
@@ -154,11 +109,57 @@ export const ExpansionCard = forwardRef<HTMLDivElement, ExpansionCardProps>(
</ExpansionCardContext.Provider>
);
},
) as ExpansionCardComponent;
);
ExpansionCard.Header = ExpansionCardHeader;
ExpansionCard.Content = ExpansionCardContent;
ExpansionCard.Title = ExpansionCardTitle;
ExpansionCard.Description = ExpansionCardDescription;
/**
* A component that displays an expandable card.
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/expansioncard)
* @see 🏷️ {@link ExpansionCardProps}
*
* @example
* ```jsx
* <ExpansionCard aria-label="Utbetaling av sykepenger">
* <ExpansionCard.Header>
* <ExpansionCard.Title>Utbetaling av sykepenger</ExpansionCard.Title>
* </ExpansionCard.Header>
* <ExpansionCard.Content>
* <Innhold />
* </ExpansionCard.Content>
* </ExpansionCard>
* ```
*/
const ExpansionCard = Object.assign(ExpansionCardRoot, {
/**
* @see 🏷️ {@link ExpansionCardHeaderProps}
*/
Header: ExpansionCardHeader,
/**
* @see 🏷️ {@link ExpansionCardTitleProps}
* @see [🤖 OverridableComponent](https://aksel.nav.no/grunnleggende/kode/overridablecomponent) support
*/
Title: ExpansionCardTitle,
/**
* @see 🏷️ {@link ExpansionCardDescriptionProps}
*/
Description: ExpansionCardDescription,
/**
* @see 🏷️ {@link ExpansionCardContentProps}
*/
Content: ExpansionCardContent,
});
export default ExpansionCard;
export {
ExpansionCard,
ExpansionCardHeader,
ExpansionCardTitle,
ExpansionCardDescription,
ExpansionCardContent,
};
export type {
ExpansionCardProps,
ExpansionCardHeaderProps,
ExpansionCardTitleProps,
ExpansionCardDescriptionProps,
ExpansionCardContentProps,
};
@@ -1,8 +1,8 @@
import React, { forwardRef } from "react";
import type { OverridableComponent } from "../utils-external";
import { cl } from "../utils/helpers";
import type { OverridableComponent } from "../../utils-external";
import { cl } from "../../utils/helpers";
export interface ExpansionCardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
interface ExpansionCardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
children: React.ReactNode;
/**
* Changes text-sizing
@@ -11,7 +11,7 @@ export interface ExpansionCardTitleProps extends React.HTMLAttributes<HTMLHeadin
size?: "large" | "medium" | "small";
}
export const ExpansionCardTitle: OverridableComponent<
const ExpansionCardTitle: OverridableComponent<
ExpansionCardTitleProps,
HTMLHeadingElement
> = forwardRef(
@@ -32,4 +32,5 @@ export const ExpansionCardTitle: OverridableComponent<
},
);
export default ExpansionCardTitle;
export { ExpansionCardTitle };
export type { ExpansionCardTitleProps };
@@ -0,0 +1,14 @@
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 };
@@ -0,0 +1,129 @@
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" };
@@ -0,0 +1,41 @@
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 };
@@ -0,0 +1,37 @@
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 };
@@ -0,0 +1,3 @@
"use client";
export { CheckboxCard } from "./CheckboxCard";
export { CheckboxCardGroup } from "./CheckboxCardGroup";
@@ -88,3 +88,22 @@ 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,21 +1,32 @@
import React, { forwardRef } from "react";
import React, { forwardRef, useRef } from "react";
import { BodyShort } from "../../typography";
import { omit, useId } from "../../utils-external";
import { cl } from "../../utils/helpers";
import { cl, createStrictContext } from "../../utils/helpers";
import { useMergeRefs } from "../../utils/hooks";
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 (
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- clickable div for checkbox card
<div
className={cl(className, "aksel-checkbox", `aksel-checkbox--${size}`, {
"aksel-checkbox--error": hasError,
@@ -23,9 +34,26 @@ 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={forwardedRef}
ref={mergedRefs}
{...omit(props, [
"children",
"size",
@@ -72,3 +100,4 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
);
export default Checkbox;
export { CheckboxCardContextProvider };
@@ -19,7 +19,7 @@ export interface CheckboxProps
*/
children: React.ReactNode;
/**
* Visually hides label and description, and makes it viewable for screen-readers only.
* Hides the label and description visually while keeping them available to screen readers.
*/
hideLabel?: boolean;
/**
@@ -42,7 +42,6 @@ const ComboboxWrapper = ({
}
return (
// biome-ignore lint/a11y/noStaticElementInteractions: Wrapper for combobox input and chips.
<div
ref={wrapperRef}
className={cl(
@@ -52,7 +52,7 @@ const AddNewOption = () => {
activeDescendantId === filteredOptionsUtil.getAddNewOptionId(id),
},
)}
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: List item acts as an option in the combobox.
// oxlint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role -- List item acts as an option in the combobox.
role="option"
aria-selected={false}
>
@@ -113,7 +113,7 @@ const FilteredOptions = () => {
{shouldRenderFilteredOptionsList && (
<ul
ref={setFilteredOptionsRef}
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: The <ul> element acts as a listbox in the combobox.
// oxlint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role -- The <ul> element acts as a listbox in the combobox.
role="listbox"
className="aksel-combobox__list-options"
>
@@ -76,7 +76,7 @@ const FilteredOptionsItem = ({ option }: { option: ComboboxOption }) => {
toggleIsListOpen(false);
}
}}
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: List item acts as an option in the combobox.
// oxlint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role -- List item acts as an option in the combobox.
role="option"
aria-selected={isInList(option.value, selectedOptions)}
aria-disabled={isDisabled(option) || undefined}
@@ -54,8 +54,7 @@ export const InputController = forwardRef<
return (
<Floating.Anchor asChild ref={setAnchorRef}>
{/** biome-ignore lint/a11y/noStaticElementInteractions: Acts as wrapper list-toggle and input */}
{/** biome-ignore lint/a11y/useKeyWithClickEvents: key-events are handled by input itself */}
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- Acts as wrapper list-toggle and input; key-events are handled by input itself */}
<div
className={cl("aksel-combobox__wrapper-inner aksel-text-field__input", {
"aksel-combobox__wrapper-inner--virtually-unfocused":
@@ -112,7 +112,6 @@ const SelectedOptionsProvider = ({
const newHideCaret =
isLimitReached || (!isMultiSelect && selectedOptions.length > 0);
// biome-ignore lint/correctness/useExhaustiveDependencies: We explicitly want to run this effect when selectedOptions changes to match the view with the selected options.
useEffect(() => {
setHideCaret(newHideCaret);
}, [newHideCaret, selectedOptions, setHideCaret]);
@@ -60,8 +60,7 @@ export interface ComboboxProps
*/
filteredOptions?: string[] | ComboboxOption[];
/**
* Optionally hide the label visually.
* Not recommended, but can be considered for e.g. search fields in the top menu.
* Hides the label and description visually while keeping them available to screen readers.
*/
hideLabel?: boolean;
/**
@@ -21,20 +21,19 @@ import { ComboboxTrigger } from "./trigger/ComboboxTrigger";
interface ComboboxProps<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>
extends
Omit<
ComboboxRootProps<T>,
"children" | "selectedOptions" | "onToggleOption"
>,
Partial<Pick<ComboboxRootProps<T>, "selectedOptions" | "onToggleOption">>,
Omit<FormFieldProps, "size"> {
extends Omit<ComboboxRootProps<T>, "children">, Omit<FormFieldProps, "size"> {
/**
* Label
*/
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<
@@ -43,8 +42,6 @@ function Combobox<
label,
hideLabel,
name,
defaultSelectedOptions,
onSelectedOptionsChange,
size: sizeProp,
error,
errorId: errorIdProp,
@@ -52,9 +49,7 @@ function Combobox<
description,
id,
readOnly: readOnlyProp,
multiselect = false, // Default value should be synced with ComboboxRoot
selectedOptions: selectedOptionsProp,
onToggleOption: onToggleOptionProp,
multiple,
...rest
}: ComboboxProps<T>) {
const {
@@ -78,35 +73,11 @@ 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}
multiselect={multiselect}
selectedOptions={selectedOptions}
onToggleOption={onToggleOption}
multiple={multiple}
{...rest}
>
<ComboboxLabel
@@ -146,8 +117,8 @@ function Combobox<
</div>
{name &&
selectedOptions.map((value) => (
<input key={value} type="hidden" name={name} value={value} />
rest.value.map((val) => (
<input key={val} type="hidden" name={name} value={val} />
))}
<ComboboxOverlay
@@ -169,5 +140,126 @@ function Combobox<
);
}
export { Combobox };
export type { ComboboxProps };
// ----------- 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 };
@@ -3,8 +3,7 @@ import React, { useMemo, useState } from "react";
import { Button } from "../../button";
import { Dialog } from "../../dialog";
import { HStack, VStack } from "../../primitives/stack";
import { Select } from "../select";
import { Combobox, type ComboboxProps } from "./Combobox";
import { ComboboxMulti, ComboboxSingle } from "./Combobox";
import { ComboboxField } from "./field/ComboboxField";
import { ComboboxFilter } from "./filter/ComboboxFilter";
import { ComboboxLabel } from "./label/ComboboxLabel";
@@ -18,9 +17,9 @@ import {
} from "./root/ComboboxRoot";
import { ComboboxTrigger } from "./trigger/ComboboxTrigger";
const meta: Meta<typeof Combobox> = {
const meta: Meta<typeof ComboboxSingle> = {
title: "ds-react/Combobox2",
component: Combobox,
component: ComboboxSingle,
parameters: {
chromatic: { disable: true },
},
@@ -77,13 +76,12 @@ const groupedCountries: (MyGroup | MyOption)[] = [
];
type DefaultProps = Pick<
ComboboxProps<MyOption>,
ComboboxSingle.Props<MyOption>,
| "label"
| "description"
| "hideLabel"
| "size"
| "defaultOpen"
| "multiselect"
| "id"
| "readOnly"
| "disabled"
@@ -91,14 +89,13 @@ type DefaultProps = Pick<
>;
export const Default: StoryFn<DefaultProps> = (props) => {
return <Combobox defaultOpen options={countries} {...props} />;
return <ComboboxSingle 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: "",
@@ -113,17 +110,21 @@ 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<ComboboxProps<T>>) {
>(props: Partial<ComboboxSingle.Props<T>>) {
return (
<Combobox
<ComboboxSingle
options={countries as T[]}
defaultSelectedOptions={["opt-1"]}
defaultValue="opt-1"
label="Velg land"
description="Landet hvor du er født."
multiselect={false}
{...props}
/>
);
@@ -133,13 +134,13 @@ export const Small = () => <BasicCombobox size="small" error="Error" />;
export const Hidelabel = () => <BasicCombobox hideLabel />;
export const Readonly = () => <BasicCombobox readOnly />;
export const Readonly = () => <BasicCombobox readOnly error="Test" />;
export const Disabled = () => <BasicCombobox disabled />;
export const Disabled = () => <BasicCombobox disabled error="Test" />;
export const ErrorStory = () => (
<VStack gap="space-16">
<BasicCombobox error="Du må velge et land." defaultSelectedOptions={[]} />
<BasicCombobox error="Du må velge et land." defaultValue={undefined} />
<BasicCombobox error="Ugyldig land." />
</VStack>
);
@@ -148,10 +149,8 @@ ErrorStory.storyName = "Error";
export const DefaultOpen = () => <BasicCombobox defaultOpen />;
DefaultOpen.storyName = "DefaultOpen";
export const DefaultSelectedOptions = () => (
<BasicCombobox defaultSelectedOptions={["opt-1"]} />
);
DefaultSelectedOptions.storyName = "DefaultSelectedOptions";
export const DefaultValue = () => <BasicCombobox defaultValue="opt-1" />;
DefaultValue.storyName = "DefaultValue";
export const Name = () => (
<form
@@ -169,50 +168,19 @@ export const Name = () => (
);
export const Controlled = () => {
const [selectedOptions, setSelectedOptions] = useState<MyOption["value"][]>([
"opt-1",
]);
const [value, setValue] = useState<MyOption["value"] | undefined>("opt-1");
return (
<VStack gap="space-8" minWidth="250px">
<Combobox
<ComboboxSingle
options={countries}
selectedOptions={selectedOptions}
onToggleOption={(option, newSelected) => {
setSelectedOptions((prev) =>
newSelected
? [...prev, option.value]
: prev.filter((v) => v !== option.value),
);
}}
value={value}
onChange={setValue}
label="Velg land"
/>
Selected: {selectedOptions.join(", ")}
Selected: {value}
<div>
<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([])}>
<button type="button" onClick={() => setValue(undefined)}>
Clear
</button>
</div>
@@ -243,7 +211,7 @@ export const ManyOptions: StoryFn<ManyOptionsProps> = ({ count }) => {
return (
<div style={{ minHeight: "400px", width: "250px" }}>
<Combobox options={manyOptions} label="Test" />
<ComboboxSingle options={manyOptions} label="Test" />
</div>
);
};
@@ -278,7 +246,7 @@ export const LongLabels = () => (
],
},
]}
selectedOptions={["opt-1"]}
value={["opt-1"]}
onToggleOption={() => {}}
>
<ComboboxField />
@@ -290,15 +258,13 @@ export const LongLabels = () => (
);
export const Composition = () => {
const [selectedOptions, setSelectedOptions] = useState<MyOption["value"][]>([
"opt-1",
]);
const [value, setSelectedOptions] = useState<MyOption["value"][]>(["opt-1"]);
const [selectedOption, setSelectedOption] = useState<MyOption>(countries[0]);
const rootProps = {
options: countries,
selectedOptions,
value,
onToggleOption: (option) => {
setSelectedOptions((prev) =>
prev.includes(option.value)
@@ -323,7 +289,7 @@ export const Composition = () => {
</ComboboxRoot>
<ComboboxRoot {...rootProps}>
<ComboboxTrigger>
<ComboboxTrigger openWhenStartsTyping={false}>
<Button>Uten input</Button>
</ComboboxTrigger>
<ComboboxOverlay>
@@ -335,11 +301,11 @@ export const Composition = () => {
<ComboboxRoot
options={countries}
selectedOptions={[selectedOption.value]}
value={[selectedOption.value]}
onToggleOption={setSelectedOption}
multiselect={false}
multiple={false}
>
<ComboboxTrigger>
<ComboboxTrigger openWhenStartsTyping={false}>
<Button>Single select</Button>
</ComboboxTrigger>
<ComboboxOverlay>
@@ -366,9 +332,9 @@ export const Composition = () => {
<ComboboxRoot
options={countries}
selectedOptions={[selectedOption.value]}
value={[selectedOption.value]}
onToggleOption={setSelectedOption}
multiselect={false}
multiple={false}
>
<ComboboxLabel htmlFor="demo2">Velg land (single select)</ComboboxLabel>
<ComboboxTrigger id="demo2">
@@ -383,7 +349,7 @@ export const Composition = () => {
</ComboboxRoot>
<ComboboxRoot {...rootProps}>
<ComboboxLabel htmlFor="demo3">Velg land (multiselect)</ComboboxLabel>
<ComboboxLabel htmlFor="demo3">Velg land (multiple)</ComboboxLabel>
<ComboboxTrigger id="demo3">
<ComboboxField />
</ComboboxTrigger>
@@ -423,60 +389,20 @@ export const InDialog = () => {
);
};
export const Testing = () => {
return (
<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>
);
};
function WithValidation() {
const [error, setError] = useState("Du må velge et land");
return (
<BasicCombobox
options={nordicCountries}
defaultSelectedOptions={[]}
defaultValue={undefined}
error={error}
onToggleOption={(option) =>
setError(option.label === "Island" ? "" : "Du må velge Island")
}
onChange={(newVal) => {
if (!newVal) {
setError("Du må velge et land");
return;
}
setError(newVal === "opt-5" ? "" : "Du må velge Island");
}}
/>
);
}
@@ -484,10 +410,10 @@ function WithValidation() {
export const ScreenreaderTesting = () => (
<div>
<h2>Med grupper</h2>
<BasicCombobox options={groupedCountries} defaultSelectedOptions={[]} />
<BasicCombobox options={groupedCountries} defaultValue={undefined} />
<h2>Uten grupper</h2>
<BasicCombobox options={nordicCountries} defaultSelectedOptions={[]} />
<BasicCombobox options={nordicCountries} defaultValue={undefined} />
<h2>Label skjult visuelt</h2>
<BasicCombobox options={nordicCountries} hideLabel />
@@ -499,18 +425,9 @@ export const ScreenreaderTesting = () => (
<BasicCombobox readOnly />
<h2>Readonly uten verdi</h2>
<BasicCombobox readOnly defaultSelectedOptions={[]} />
<BasicCombobox readOnly defaultValue={undefined} />
<h2>Disabled</h2>
<BasicCombobox disabled />
</div>
);
/* TODO:
- Navngivning: Vurder å dele opp i separate komponenter for single- og multiselect.
- Hvordan unngå CSS-konflikt med dagens CB?
- Vurder om Label og Description (og error?) skal være sub-komponenter eller ikke.
- Test med skjermleser
*/
@@ -1,7 +1,7 @@
import type { Meta } from "@storybook/react-vite";
import React, { useState } from "react";
import { expect, userEvent, within } from "storybook/test";
import { Combobox, type ComboboxProps } from "./Combobox";
import { ComboboxSingle } from "./Combobox";
import { ComboboxFilter } from "./filter/ComboboxFilter";
import { ComboboxList } from "./list/ComboboxList";
import { ComboboxPopup } from "./popup/ComboboxPopup";
@@ -59,20 +59,14 @@ const groupedOptions = [
function BaseCombobox<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>(props: Partial<ComboboxProps<T>>) {
const [selectedOptions, setSelectedOptions] = useState(["option-1"]);
>(props: Partial<ComboboxSingle.Props<T>>) {
const [value, setValue] = useState<string | undefined>("option-1");
return (
<Combobox
<ComboboxSingle
options={countries as T[]}
selectedOptions={selectedOptions}
onToggleOption={(option, newSelected) => {
setSelectedOptions((prev) =>
newSelected
? [...prev, option.value]
: prev.filter((v) => v !== option.value),
);
}}
value={value}
onChange={setValue}
label="Velg land"
description="Landet hvor du er født."
{...props}
@@ -99,7 +93,7 @@ const optionMemoTestRenderFn = (
return optOrGroup.label;
};
export const OptionMemoization = () => {
const [selectedOptions, setSelectedOptions] = useState(["option-1"]);
const [value, setValue] = useState(["option-1"]);
return (
<div style={{ minHeight: "450px" }}>
@@ -112,9 +106,9 @@ export const OptionMemoization = () => {
<ComboboxRoot
defaultOpen
options={groupedOptions}
selectedOptions={selectedOptions}
value={value}
onToggleOption={(option) => {
setSelectedOptions((prev) =>
setValue((prev) =>
prev.includes(option.value)
? prev.filter((v) => v !== option.value)
: [...prev, option.value],
@@ -7,15 +7,17 @@ import {
} from "../root/ComboboxRoot";
interface ComboboxFieldProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children?: React.ReactNode;
children?: string;
hasError?: boolean;
}
const ComboboxField = forwardRef<HTMLButtonElement, ComboboxFieldProps>(
({ children, hasError, ...rest }, ref) => {
const { options, selectedOptions, size } = useComboboxRootContext();
const { options, value, size } = useComboboxRootContext();
const labels = getLabels(selectedOptions, options);
const labels = getLabels(value, options);
const label =
children ?? (labels.length > 1 ? `${labels.length} valgt` : labels[0]);
return (
<div className="aksel-combobox2__field-container">
@@ -28,10 +30,11 @@ 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}
>
{children ||
(labels.length > 1 ? `${labels.length} valgt` : labels[0])}
{label}
</button>
<ChevronDownIcon
className="aksel-combobox2__field-chevron"
@@ -43,7 +46,7 @@ const ComboboxField = forwardRef<HTMLButtonElement, ComboboxFieldProps>(
);
const getLabels = (
selectedOptions: ComboboxRootProps["selectedOptions"],
value: ComboboxRootProps["value"],
options: ComboboxRootProps["options"],
) => {
const labels: string[] = [];
@@ -52,13 +55,13 @@ const getLabels = (
if ("options" in optOrGroup) {
// It's a group
for (const option of optOrGroup.options) {
if (selectedOptions.includes(option.value)) {
if (value.includes(option.value)) {
labels.push(option.label);
}
}
} else {
// It's a single option
if (selectedOptions.includes(optOrGroup.value)) {
if (value.includes(optOrGroup.value)) {
labels.push(optOrGroup.label);
}
}
@@ -11,11 +11,16 @@ type ComboboxFilterProps = Pick<
export const ComboboxFilter = (props: ComboboxFilterProps) => {
const { size } = useComboboxRootContext();
const { filterString, setFilterString } = useComboboxPopupContext();
const {
filterString,
setFilterString,
virtuallyFocusedOptionValue,
setVirtuallyFocusedOptionValue,
} = useComboboxPopupContext();
return (
<div className="aksel-combobox2__filter">
<Listbox.InputSlot>
<Listbox.InputSlot virtuallyFocusedOptionId={virtuallyFocusedOptionValue}>
<Search
value={filterString}
onChange={setFilterString}
@@ -29,6 +34,11 @@ 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,28 +47,22 @@ function ComboboxList<
function ComboboxListContent<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>({ children }: ComboboxListProps<T>) {
const { virtuallyFocusedOptionValue, filterString } =
const { virtuallyFocusedOptionValue, filterString, setAnnounceText } =
useComboboxPopupContext();
const {
setOpen,
options,
selectedOptions,
onToggleOption,
multiselect,
triggerRef,
} = useComboboxRootContext();
const { setOpen, options, value, onToggleOption, multiple, triggerRef } =
useComboboxRootContext();
const memoizedOnToggleOption = useEventCallback(onToggleOption);
const deferredFilterString = useDeferredValue(filterString);
const localOnToggleOption = useCallback(
(option: ComboboxOptionData, isSelected: boolean) => {
memoizedOnToggleOption(option, isSelected);
if (!multiselect) {
if (!multiple) {
setOpen(false);
triggerRef.current?.focus();
}
},
[memoizedOnToggleOption, multiselect, triggerRef, setOpen],
[memoizedOnToggleOption, multiple, triggerRef, setOpen],
);
if (children && typeof children !== "function") {
@@ -80,6 +74,7 @@ function ComboboxListContent<
: options;
if (filteredOptions.length === 0) {
setAnnounceText("Ingen treff");
return (
<div className="aksel-combobox2__no-results" role="status">
Ingen treff
@@ -87,6 +82,8 @@ function ComboboxListContent<
);
}
setAnnounceText("");
return filteredOptions.map((optOrGroup) =>
"options" in optOrGroup ? (
<ComboboxGroup
@@ -99,7 +96,7 @@ function ComboboxListContent<
key={option.value}
option={option}
onToggleOption={localOnToggleOption}
isSelected={selectedOptions.includes(option.value)}
isSelected={value.includes(option.value)}
hasVirtualFocus={virtuallyFocusedOptionValue === option.value}
highlightText={deferredFilterString}
>
@@ -112,7 +109,7 @@ function ComboboxListContent<
key={optOrGroup.value}
option={optOrGroup}
onToggleOption={localOnToggleOption}
isSelected={selectedOptions.includes(optOrGroup.value)}
isSelected={value.includes(optOrGroup.value)}
hasVirtualFocus={virtuallyFocusedOptionValue === optOrGroup.value}
highlightText={deferredFilterString} // TODO: Vurder å kunne slå av dette for bedre ytelse
>
@@ -65,7 +65,6 @@ const highlightSubstring = (text: string, substring: string) => {
const end = text.substring(indexOfHighlightedText + substring.length);
return (
// Aria-label is used to fix testing-library wrongly evaluating the accessible name of the option when highlighting text
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: Doesn't matter if aria-label doesn't work
<span aria-label={text}>
{start}
{highlight && <mark>{highlight}</mark>}
@@ -103,6 +103,7 @@ 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);
@@ -57,17 +57,15 @@ const groupedOptions: (MyGroup | MyOption)[] = [
];
export const Default = () => {
const [selectedOptions, setSelectedOptions] = useState<MyOption["value"][]>([
"opt-3",
]);
const [value, setValue] = useState<MyOption["value"][]>(["opt-3"]);
return (
<ComboboxRoot
defaultOpen
options={options}
selectedOptions={selectedOptions}
value={value}
onToggleOption={(option, newSelected) => {
setSelectedOptions((prev) =>
setValue((prev) =>
newSelected
? [...prev, option.value]
: prev.filter((v) => v !== option.value),
@@ -89,7 +87,7 @@ export const SingleSelect = () => {
<ComboboxRoot
defaultOpen
options={options}
selectedOptions={[selectedOption.value]}
value={[selectedOption.value]}
onToggleOption={setSelectedOption}
>
<ComboboxPopup>
@@ -108,7 +106,7 @@ export const Groups = () => {
<ComboboxRoot
defaultOpen
options={groupedOptions}
selectedOptions={[selectedOption.value]}
value={[selectedOption.value]}
onToggleOption={setSelectedOption}
>
<ComboboxPopup>
@@ -121,7 +119,7 @@ export const Groups = () => {
size="small"
defaultOpen
options={groupedOptions}
selectedOptions={[selectedOption.value]}
value={[selectedOption.value]}
onToggleOption={setSelectedOption}
>
<ComboboxPopup>
@@ -147,7 +145,7 @@ export const WithoutFilter = () => {
options: options.slice(0, 3),
},
]}
selectedOptions={[selectedOption.value]}
value={[selectedOption.value]}
onToggleOption={setSelectedOption}
>
<ComboboxPopup>
@@ -158,7 +156,7 @@ export const WithoutFilter = () => {
<ComboboxRoot
defaultOpen
options={options.slice(0, 3)}
selectedOptions={[selectedOption.value]}
value={[selectedOption.value]}
onToggleOption={setSelectedOption}
>
<ComboboxPopup>
@@ -170,20 +168,18 @@ export const WithoutFilter = () => {
};
export const CustomOptionRendering = () => {
const [selectedOptions, setSelectedOptions] = useState<MyOption["value"][]>([
"opt-1",
]);
const [value, setValue] = useState<MyOption["value"][]>(["opt-1"]);
return (
<ComboboxRoot
defaultOpen
options={options}
selectedOptions={selectedOptions}
value={value}
onToggleOption={(option, isSelected) => {
setSelectedOptions(
setValue(
isSelected
? [...selectedOptions, option.value]
: selectedOptions.filter((v) => v !== option.value),
? [...value, option.value]
: value.filter((v) => v !== option.value),
);
}}
>
@@ -212,7 +208,7 @@ export const CustomOptionRenderingGroups = () => {
<ComboboxRoot
defaultOpen
options={groupedOptions}
selectedOptions={[selectedOption.value]}
value={[selectedOption.value]}
onToggleOption={setSelectedOption}
>
<ComboboxPopup>
@@ -239,9 +235,7 @@ 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 [selectedOptions, setSelectedOptions] = useState<MyOption["value"][]>([
"opt-1",
]);
const [value, setValue] = useState<MyOption["value"][]>(["opt-1"]);
const [filterString, setFilterString] = useState("");
const filterStringLowerCase = filterString.toLocaleLowerCase();
@@ -266,9 +260,9 @@ export const ControlledInput = () => {
<ComboboxRoot
defaultOpen
options={filteredOptions}
selectedOptions={selectedOptions}
value={value}
onToggleOption={(option) => {
setSelectedOptions((prev) =>
setValue((prev) =>
prev.includes(option.value)
? prev.filter((v) => v !== option.value)
: [...prev, option.value],
@@ -290,7 +284,7 @@ export const States: StoryFn = () => {
<ComboboxRoot
defaultOpen
options={options.slice(0, 3)}
selectedOptions={[selectedOption.value]}
value={[selectedOption.value]}
onToggleOption={setSelectedOption}
>
<ComboboxPopup>
@@ -5,8 +5,10 @@ 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 {
@@ -27,6 +29,7 @@ export const ComboboxPopup = ({ children }: ComboboxPopupProps) => {
const [filterString, setFilterString] = useState("");
const rootContext = useComboboxRootContext();
const ref = useRef<HTMLDivElement | null>(null);
const [announceText, setAnnounceText] = useState("");
useEffect(
function onOpen() {
@@ -80,10 +83,18 @@ export const ComboboxPopup = ({ children }: ComboboxPopupProps) => {
>
<ComboboxPopupContextProvider
virtuallyFocusedOptionValue={virtuallyFocusedOptionValue}
setVirtuallyFocusedOptionValue={setVirtuallyFocusedOptionValue}
filterString={filterString}
setFilterString={setFilterString}
setFilterString={(newString) => {
setFilterString(newString);
setVirtuallyFocusedOptionValue("");
}}
setAnnounceText={setAnnounceText}
>
{children}
<div className="aksel-sr-only" role="status">
{announceText}
</div>
</ComboboxPopupContextProvider>
</Listbox>
);
@@ -4,6 +4,26 @@ 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>,
@@ -28,9 +48,7 @@ export interface ComboboxRootProps<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData> =
ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>
extends
Pick<FormFieldProps, "disabled">,
React.HTMLAttributes<HTMLDivElement> {
extends Pick<FormFieldProps, "disabled" | "size">, HtmlAttributes {
children: React.ReactNode;
/**
* Can be either an array of options ({@link ComboboxOptionData})
@@ -40,14 +58,25 @@ export interface ComboboxRootProps<
* put the single options first.
*/
options: T[];
selectedOptions: ResolveOption<T>["value"][]; // TODO: Vurder navn: value(s)
onToggleOption: ComboboxOptionProps<ResolveOption<T>>["onToggleOption"];
defaultOpen?: boolean;
multiselect?: boolean; // TODO: Hva skal være default? Vurder navn
/**
* @default "medium"
* Value of the selected options.
*/
size?: "small" | "medium";
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.
*/
onToggleOption: ComboboxOptionProps<ResolveOption<T>>["onToggleOption"];
/**
* Whether the combobox is open by default.
*/
defaultOpen?: boolean;
/**
* Whether multiple options can be selected.
* @default true
*/
multiple?: boolean; // TODO: Hva skal være default?
}
export interface ComboboxRootContextProps {
@@ -55,9 +84,9 @@ export interface ComboboxRootContextProps {
setOpen: (newOpen: boolean) => void;
triggerRef: React.RefObject<HTMLDivElement | null>;
options: ComboboxRootProps["options"];
selectedOptions: ComboboxRootProps["selectedOptions"];
value: ComboboxRootProps["value"];
onToggleOption: ComboboxRootProps["onToggleOption"];
multiselect: boolean;
multiple: boolean;
size: Exclude<ComboboxRootProps["size"], undefined>;
disabled: ComboboxRootProps["disabled"];
}
@@ -75,10 +104,10 @@ export function ComboboxRoot<
>({
children,
options,
selectedOptions,
value,
onToggleOption,
defaultOpen = false,
multiselect = true,
multiple = true,
size = "medium",
disabled,
className,
@@ -99,11 +128,11 @@ export function ComboboxRoot<
setOpen={setOpen}
triggerRef={triggerRef}
options={options}
selectedOptions={selectedOptions}
value={value}
onToggleOption={
onToggleOption as ComboboxRootContextProps["onToggleOption"]
}
multiselect={multiselect}
multiple={multiple}
size={size}
disabled={disabled}
>
@@ -9,11 +9,18 @@ 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();
@@ -21,6 +28,7 @@ const ComboboxTrigger = ({
return (
<Floating.Anchor asChild ref={rootContext.triggerRef}>
<Slot
// oxlint-disable-next-line jsx-a11y/role-has-required-aria-props -- aria-controls can be supplied through rest or the slotted trigger.
role="combobox"
aria-expanded={readOnly ? undefined : rootContext.open}
onClick={() => !readOnly && rootContext.setOpen(!rootContext.open)}
@@ -28,15 +36,26 @@ const ComboboxTrigger = ({
// Prevents "flash of focus" on trigger before focus is moved to input
if (!readOnly && !rootContext.open) event.preventDefault();
}}
/*onKeyDown={composeEventHandlers(onKeyDown, (event) => {
if (event.key === "ArrowDown") {
context.onOpenChange(true);
// Stop keydown from scrolling window
event.preventDefault();
onKeyDown={(event) => {
if (readOnly) {
return;
}
})}*/
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}
>
@@ -85,7 +85,6 @@ export const ErrorSummary = forwardRef<HTMLDivElement, ErrorSummaryProps>(
const mergedRef = useMergeRefs(ref, wrapperRef);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: Allows focus-calls on div to move focus to heading.
<div
ref={mergedRef}
{...rest}
@@ -90,8 +90,7 @@ const FileUploadDropzone = forwardRef<
{description}
</BodyShort>
)}
{/** biome-ignore lint/a11y/noStaticElementInteractions: Assistive technologies will use the input within. */}
{/** biome-ignore lint/a11y/useKeyWithClickEvents: Keyboard will access input/button within */}
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- Assistive technologies and keyboard users access the input/button within. */}
<div
className="aksel-dropzone__area"
onDragEnter={dropzoneCtx.onDragEnter}
@@ -2,7 +2,7 @@ import { isAcceptedSize } from "./is-accepted-size";
describe("isAcceptedSize", () => {
it("should return true if maxSize is less than or equal to 0", () => {
const file = new File([new Array(5000).join("a")], "filename.txt", {
const file = new File(["a".repeat(4999)], "filename.txt", {
type: "text/plain",
});
expect(isAcceptedSize(file, -1)).toBe(true);
@@ -10,7 +10,7 @@ describe("isAcceptedSize", () => {
});
it("should return true if file size is less than or equal to maxSize", () => {
const file = new File([new Array(5000).join("a")], "filename.txt", {
const file = new File(["a".repeat(4999)], "filename.txt", {
type: "text/plain",
});
expect(isAcceptedSize(file, 5000)).toBe(true);
@@ -18,7 +18,7 @@ describe("isAcceptedSize", () => {
});
it("should return false if file size is greater than maxSize", () => {
const file = new File([new Array(5000).join("a")], "filename.txt", {
const file = new File(["a".repeat(4999)], "filename.txt", {
type: "text/plain",
});
expect(isAcceptedSize(file, 4000)).toBe(false);
+1 -2
View File
@@ -22,8 +22,7 @@ export const Radio = forwardRef<HTMLInputElement, RadioProps>(
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
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- clickable div for radio card
<div
className={cl(className, "aksel-radio", `aksel-radio--${size}`, {
"aksel-radio--error": hasError,
@@ -39,7 +39,7 @@ export interface SearchProps
*/
label: React.ReactNode;
/**
* Shows label and description for screenreaders only.
* Hides the label and description visually while keeping them available to screen readers.
* @default true
*/
hideLabel?: boolean;
@@ -158,7 +158,7 @@ export const Search = forwardRef<HTMLInputElement, SearchProps>(
clearButton && !inputProps.disabled && (value ?? internalValue);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: Escape key handler for clearing input
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- Escape key handler for clearing input
<div
onKeyDown={(event) => {
if (event.key !== "Escape") {
@@ -23,7 +23,7 @@ export interface SelectProps
*/
label: React.ReactNode;
/**
* Shows label and description for screenreaders only.
* Hides the label and description visually while keeping them available to screen readers.
*/
hideLabel?: boolean;
}
@@ -15,7 +15,7 @@ export interface SwitchProps
*/
children: React.ReactNode;
/**
* If enabled shows the label and description for screenreaders only.
* Hides the label and description visually while keeping them available to screen readers.
*/
hideLabel?: boolean;
/**
@@ -36,7 +36,7 @@ export interface TextareaProps
*/
label: React.ReactNode;
/**
* If enabled shows the label and description for screenreaders only.
* Hides the label and description visually while keeping them available to screen readers.
*/
hideLabel?: boolean;
/**
@@ -20,7 +20,7 @@ export interface TextFieldProps
*/
htmlSize?: number;
/**
* If enabled shows the label and description for screenreaders only
* Hides the label and description visually while keeping them available to screen readers
*/
hideLabel?: boolean;
/**
Loaded 100 of 337 files, more files were not shown because too many files have changed in this diff. Show more