Compare commits

..
Author SHA1 Message Date
Halvor Haugan e30a1edd98 fix(Combobox2): fix issue with VO on Safari 2026-11-02 11:48:47 +01:00
Halvor Haugan 32c578184e fix(Combobox2): fix test 2026-10-30 17:54:38 +01:00
Halvor Haugan cd8fadf444 fix(Combobox2): add forwardRef and squash a bunch of bugs 2026-10-30 17:36:05 +01:00
Ken b69c5fca30 perf: Mark esm builds as side-effect free (#5472) 2026-10-30 13:56:02 +01:00
Ken 7a9530a252 perf(website): Optimize fuse indexing (#5468) 2026-10-30 13:03:08 +01:00
Halvor Haugan 3a858027b8 chore(deps): bump body-parser to 2.4.0 2026-10-30 12:42:31 +01:00
dependabot[bot] ab4ab6f488 ⬆️ Bump body-parser from 1.20.8 to 1.20.9 (#5473)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-30 11:46:46 +01:00
github-actions[bot] 22a00913d8 Version Packages (#5462)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-30 10:25:50 +01:00
Ken 66955f5018 feat(AI): Added skill for verifying component contracts 2026-10-29 13:09:45 +01:00
Ken f351018be0 refactor: Improved debugging/review skills, removed caveman skill 2026-10-29 12:14:18 +01:00
Ken f7c83f4643 feat(AI): Updated copilot instructions 2026-10-29 12:01:11 +01:00
75 changed files with 647 additions and 2652 deletions

No files matched your search

-5
View File
@@ -1,5 +0,0 @@
---
"@navikt/aksel-icons": minor
---
Icons: Add 46 icons for text editing, tables, keyboard input, and filtering.
-6
View File
@@ -1,6 +0,0 @@
---
"@navikt/ds-react": minor
"@navikt/ds-css": patch
---
DataGrid: Adds client- and server-side pagination through the `pagination` prop and `DataGrid.Pagination` controls.
-6
View File
@@ -1,6 +0,0 @@
---
"@navikt/ds-react": minor
"@navikt/ds-css": minor
---
DataGrid: Add top and bottom content areas outside the table's scrollable viewport.
-5
View File
@@ -1,5 +0,0 @@
---
"@navikt/ds-react": patch
---
DataGridTable: Restore keyboard navigation position after loading.
+6
View File
@@ -0,0 +1,6 @@
---
"@navikt/ds-react": patch
"@navikt/aksel-icons": patch
---
ESM: Mark ESM output as side-effect free, so bundlers like webpack, Turbopack and esbuild can tree-shake unused components and icons.
-5
View File
@@ -1,5 +0,0 @@
---
"@navikt/ds-react": patch
---
DatePicker: Preserve keyboard focus when controlled month changes replace the focused day.
-5
View File
@@ -1,5 +0,0 @@
---
"@navikt/ds-react": patch
---
Dialog, Lookup: Avoid setting aria-hidden on elements with role=alert/status when open
-5
View File
@@ -1,5 +0,0 @@
---
"@navikt/ds-react": patch
---
DataGrid: Fix focus getting trapped when pressing Shift+Tab from the first cell.
-5
View File
@@ -1,5 +0,0 @@
---
"@navikt/ds-css": patch
---
DataTable: Constrain details panel width to the visible table area.
+6 -6
View File
@@ -10,11 +10,10 @@
## Behavior
- 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.
- Respond in the language the user prompted in, unless they request another language.
- Use concise, plain language without sacrificing technical precision or completeness.
- Lead with the outcome. Avoid filler and unnecessary apologies; summarize meaningful changes and validation when useful.
- Do not narrate routine tool use. Explain decisions, blockers, uncertainty, and tradeoffs when relevant.
- Show only what changes. Minimal diffs; don't paste whole files.
- Edit only the files the change needs (see Definition of done); no unrelated files. Ask only when truly blocked.
- Favor perf/security; avoid re-renders, top-level side effects, unsafe HTML, env var leaks.
@@ -39,7 +38,7 @@
## Definition of done
- Behavior/visual change: update impl + closest story + test.
- Behavior/visual change: update the implementation and closest existing story. Prefer Storybook play tests for UI behavior and unit tests for small utilities; combine overlapping coverage into existing stories instead of adding redundant stories.
- New public export: sync component `index.ts`, `src/index.ts` (or `src/preview.ts`) and `package.json` exports, then `corepack yarn workspace aksel-playroom sync-imports` (root `lint` fails on stale playroom imports).
- User-facing change to a published package: add a changeset (`add-changeset` skill) unless told otherwise.
@@ -70,6 +69,7 @@ Path-specific rules load automatically from `.github/instructions/` (see each `a
- `aksel-local-validation` - pick and run the right lint/type/test commands
- `code-review` - review a branch, diff or PR for consumer-facing risk
- `component-contract` - define and verify component behavior, state ownership and accessibility contracts
- `add-changeset` - write `.changeset/*.md` (never run interactive `yarn changeset`)
- `ds-component-restructure` - move/rename component files to canonical layout
- `website-example` - add aksel.nav.no examples/templates
@@ -1,78 +0,0 @@
---
description: >
Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra.
Always use caveman for code-related questions unless spesificed otherwise. For non-code topics, use caveman if user seems to want quick, concise answers.
applyTo: "**"
---
# caveman
ACTIVE EVERY RESPONSE.
default: full
switch mode:
- "/caveman lite" → lite
- "/caveman full" → full
- "/caveman ultra" → ultra
- "stop caveman" | "normal mode" → off
persist: keep last state
## intensity
| Level | What change |
| --------- | ------------------------------------------------------------------------------------------------------------- |
| **lite** | short sentences, no filler, keep grammar |
| **full** | fragments ok, drop articles, short words. Classic caveman |
| **ultra** | Abbreviate (DB/auth/config/req/res/fn/impl), fragments, abbrev (db/api/req/res/fn), arrows (→), minimal words |
Example - "Why React component re-render?"
- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
- ultra: "Inline obj prop → new ref → re-render. `useMemo`."
## global rules
- no filler (just/really/basically/etc)
- no pleasantries
- no hedging
- keep tech exact
- no long sentences
- prefer symbols (→, =)
## patterns
Patterns: `[thing] [action] [reason]. [next step].`
## examples
lite:
"Component re-renders because you create a new object each render. Use useMemo."
full:
"New object each render → new ref → re-render. useMemo."
ultra:
"inline obj → new ref → re-render. fix: useMemo."
## auto-Clarity
Drop caveman for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user asks to clarify or repeats question. Resume caveman after clear part done.
Example - destructive op:
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
>
> ```sql
> DROP TABLE users;
> ```
>
> Caveman resume. Verify backup exist first.
## boundaries
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end
@@ -7,6 +7,8 @@ applyTo: "@navikt/core/react/src/**"
Read the whole component folder (`index.ts`, source, `.meta.ts`, stories, tests) before editing. Moving or renaming files: use the `ds-component-restructure` skill.
For changes to component state, callbacks, async behavior, composition, semantics, keyboard or focus behavior, use the [component-contract skill](../skills/component-contract/SKILL.md).
## Compatibility (why: peer range is `react ^17 || ^18 || ^19`)
- No React 18/19-only APIs. ESLint `no-restricted-imports` blocks e.g. `React.useId`. Use the internal `useId`, `useClientLayoutEffect`, etc. from `utils-external/`.
+6 -1
View File
@@ -10,6 +10,11 @@ applyTo: "**/*.test.ts,**/*.test.tsx,**/*.stories.tsx"
- Play-test files set `parameters: { chromatic: { disable: true } }`. Visual stories feed Chromatic; use `renderStoriesForChromatic` from `utils/` when adding visual variants.
- Import from `@storybook/react-vite` and `storybook/test`, not `@storybook/react` or `@storybook/test`.
- 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.
- Cover the main path and meaningful edge cases in the nearest existing story/play-test where they belong. Prefer combining overlapping cases over adding redundant stories; add a separate story for a distinct user-visible scenario.
- A behavior change updates the nearest story and test in the same change. Use unit tests for small utilities and pure logic; use Storybook play tests for UI behavior.
- Describe behavioral coverage as starting state → action → observable result. Assert relevant intermediate states so a no-op cannot pass as success; pair a negative/blocked case with a successful control when it helps distinguish the behavior.
- Match assertions to timing: assert synchronous outcomes immediately and wait only for behavior that is contractually eventual. When changing ARIA relationships, verify the referenced element and resulting relationship, not just that an attribute exists.
- Before removing or consolidating tests, map each meaningful old scenario/assertion to its replacement story or test. Keep coverage that still represents a supported behavior; explain when a scenario is intentionally obsolete.
- For state, accessibility, keyboard or focus behavior, use the [component-contract skill](../skills/component-contract/SKILL.md) to identify the contract and evidence to preserve.
- ds-react unit tests run with `TZ=UTC`. Date logic must not depend on the local timezone.
- Browser tests need Playwright Firefox (Chromium under Copilot CLI, see `vitest.config.ts`). Yarn has `enableScripts: false`, so if the browser is missing, report it instead of installing globally.
@@ -7,6 +7,7 @@ applyTo: "aksel.nav.no/website/**"
- Both routers are in use: `app/` (App Router, `cacheComponents: true` in `next.config.ts`) and `pages/` (examples, templates, legacy). Stay in the router already used nearby. Don't move routes between them.
- In `app/`, dynamic data needs Suspense or `"use cache"`, following nearby code. Cache Components errors at build time are real failures.
- For website build, runtime, or performance issues, use the `website-debugging` skill to establish the cause before changing code.
- `pages/eksempler/**` and `pages/templates/**` are public code snippets. Use the `website-example` skill. ESLint rules `aksel-local/{import-check,comment-check,args-check}` enforce their shape.
- Reuse `@/` utils and components nearby before adding abstractions.
- Examples/templates: `corepack yarn storybook:aksel` (no secrets). `corepack yarn dev` only for app behavior; it needs `SANITY_READ_NO_DRAFTS` and `NPM_AUTH_TOKEN`.
+31 -1
View File
@@ -14,12 +14,27 @@ argument-hint: "PR number, branch or files to review"
## Process
1. **Scope**: get the diff (`gh pr diff <n>` or `git diff origin/main...HEAD`) and the PR description. Note the stated goal.
1. **Scope**: identify what the user wants reviewed.
- PR: get `gh pr diff <n>` and the PR description. Note the stated goal.
- Branch: compare against its stated base (for example, `git diff origin/main...HEAD`).
- Current worktree or "my changes": inspect staged, unstaged, and relevant untracked files. Do not substitute the committed branch diff for worktree changes.
```sh
git status --short
git diff
git diff --cached
git ls-files --others --exclude-standard
```
Read in-scope untracked files too. If the requested review target is unclear, ask before choosing a scope.
2. **Read**: for every changed file, read the surrounding code, component `index.ts`, stories, tests and the matching `.github/instructions/*.instructions.md`. Never review from the diff alone.
3. **Check**: run the smallest validation that covers the change (`aksel-local-validation` skill). Tool output replaces style comments.
4. **Analyze** against the checklist below.
5. **Report** in the output format.
For changes to component state, callbacks, async behavior, composition, semantics, keyboard or focus behavior, use the [component-contract skill](../component-contract/SKILL.md) to trace the affected contract and identify observable evidence.
## Priorities
- 🔴 **Blocker**: must fix before merge. Breaks consumers, regresses accessibility, security issue or wrong behavior.
@@ -48,6 +63,7 @@ Skip anything ESLint, Stylelint, Biome, Prettier or TypeScript already catches.
- Full keyboard support, logical focus order, focus returned after close, visible `:focus-visible`.
- Works in `forced-colors`, dark theme and at 200% zoom. Motion relies on the global `prefers-reduced-motion` handling.
- User-facing strings use `useI18n`, with keys in `nb`, `nn` and `en` under `@navikt/core/react/src/utils/i18n/locales/`.
- Review only the interaction pattern in scope; do not apply modal focus or dismissal rules to unrelated popup patterns. Check changed accessible names and relationships against their rendered targets.
### Wiring (🔴 for new public components)
@@ -72,8 +88,15 @@ Skip anything ESLint, Stylelint, Biome, Prettier or TypeScript already catches.
- 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.
- For behavior changes, check that tests distinguish the intended outcome from a no-op and cover relevant intermediate states. When tests are consolidated or removed, verify meaningful old scenarios have replacement coverage.
- Website examples (`aksel.nav.no/website/pages/eksempler/`) are updated when usage changes.
### Component contracts and public consumer impact
- `COMP-01` **Contract evidence (🟡; 🔴 if behavior is broken):** For changed state, callbacks, async behavior, composition, keyboard or focus behavior, use the [component-contract skill](../component-contract/SKILL.md). Check that a nearby story or test demonstrates the changed user-visible outcome; if coverage is missing, request it.
- `COMP-02` **Public impact (🟡; 🔴 if a consumer contract regresses):** For changed hooks, contexts, CSS or tokens, trace affected exported components and inspect a representative consumer-visible contract. Do not stop at the implementation diff; request a public-boundary example or test when impact is not demonstrated.
- `COMP-03` **Generated API metadata (🟡; 🔴 if published API is wrong):** For public prop or JSDoc changes, check the relevant generated props metadata as well as exports and tests; request the documented metadata update or correction when stale.
### Release (🟡, 🔴 if breaking)
- User-facing change to a publishable package has a `.changeset/*.md` with the right bump and a `<Component>: <consumer-facing text>` summary. Website, playroom and tooling changes get none.
@@ -92,6 +115,13 @@ Skip anything ESLint, Stylelint, Biome, Prettier or TypeScript already catches.
What the change does, overall verdict and key risks (1–3 sentences).
### Evidence and replay
- **Inspected:** relevant files, stories and behavior states reviewed.
- **Checks run:** exact command and result, or `Not run` with reason.
- **Not inspected:** relevant surfaces or behavior not verified.
- **Reviewer replay:** starting story/URL, action and expected result, or `Not needed`. Label unexecuted steps as unverified.
### Findings
| File | Line | Priority | Issue |
@@ -0,0 +1,36 @@
---
name: component-contract
description: "Define and verify component behavior, state ownership, accessibility and compatibility before changing a public component contract. Use when adding or changing controlled state, callbacks, async transitions, composition, keyboard or focus behavior, semantics, or consumer-facing props."
---
# Component contract
Use this workflow for the behavior affected by a component change. Do not turn every dimension below into a new requirement when the change does not touch it.
## Establish the existing contract
Read the component folder, including its exports, metadata, stories, tests and relevant CSS. Trace related hooks, contexts and callers when the behavior crosses component boundaries. Preserve established behavior unless the requested change intentionally changes it.
For the affected behavior, record a compact contract:
| Area | Define when relevant |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| State ownership | Controlled and uncontrolled inputs, defaults, source of truth, and what callbacks signal. A controlled component must reflect the owner's prop; do not assume a requested update was accepted. |
| Async transitions | Relevant pending, success, failure, cancellation or stale-result behavior, and what the user sees. |
| Composition | Which component or context owns registration, event handling and cleanup; how nesting or portals affect the behavior. |
| Accessibility | Native semantics, accessible name/content responsibility, roles, states and relationships. Identify what the library guarantees and what the consumer must provide. |
| Keyboard and focus | Supported keys, focus entry/movement/return, and whether each outcome is synchronous or eventual. Apply pattern-specific behavior only where that pattern is used. |
| Compatibility | Public API and React 17–19 / SSR constraints affected by the change. |
Keep the contract proportional to the change. Use existing documentation, JSDoc, stories and tests to record decisions; do not create a new metadata format or a separate document for every component.
## Verify the contract
- Identify the closest existing story or test that demonstrates each changed behavior. Prefer observable public-component outcomes over assertions on implementation details.
- Check that the starting state and intermediate outcome distinguish the intended behavior from a no-op.
- Cover meaningful accepted and rejected controlled updates, async outcomes, or nested interactions when they are part of the change.
- Verify accessible names and relationships against the actual rendered elements when those semantics change. Do not infer accessibility from attribute presence alone.
- Separate automated interaction evidence from visual inspection or manual assistive-technology checks; report what was not verified.
- Use `aksel-local-validation` to select commands. This skill defines behavior, not a separate test or validation process.
Do not copy another library's state semantics, API, React hooks or architectural layers as Aksel's contract. Follow Aksel's existing component and compatibility instructions.
+6
View File
@@ -1,5 +1,11 @@
# @navikt/aksel-icons
## 8.21.0
### Minor Changes
- Icons: Add 46 icons for text editing, tables, keyboard input, and filtering. ([#5458](https://github.com/navikt/aksel/pull/5458))
## 8.20.1
## 8.20.0
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-icons",
"version": "8.20.1",
"version": "8.21.0",
"author": "Aksel | Nav",
"homepage": "https://aksel.nav.no/ikoner",
"repository": {
@@ -64,7 +64,7 @@
"parse-svg": "node -e 'require(\"./config/parse-svg.js\").main()'",
"copy": "copyfiles util/* src",
"update-metadata": "node config/metadata.js",
"write-packagejson": "echo '{\"type\": \"module\"}' > dist/react/esm/package.json",
"write-packagejson": "echo '{\"type\": \"module\", \"sideEffects\": false}' > dist/react/esm/package.json",
"build": "yarn copy && yarn create-icons && concurrently \"tsc\" \"tsc -p tsconfig.esm.json && tsc-alias -p tsconfig.esm.json && yarn write-packagejson\" && node config/cleanTypes.js && yarn update-metadata && yarn parse-svg",
"fetch-new:icons": "tsx --env-file=.env config/figma/index.ts",
"zip:icons": "node config/zip.js",
+2
View File
@@ -1,5 +1,7 @@
# @navikt/aksel-stylelint
## 8.21.0
## 8.20.1
## 8.20.0
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-stylelint",
"version": "8.20.1",
"version": "8.21.0",
"type": "module",
"engines": {
"node": ">=18.12.0"
@@ -43,8 +43,8 @@
"dev": "yarn watch:lint"
},
"devDependencies": {
"@navikt/ds-css": "^8.20.1",
"@navikt/ds-tokens": "^8.20.1",
"@navikt/ds-css": "^8.21.0",
"@navikt/ds-tokens": "^8.21.0",
"concurrently": "10.0.6",
"postcss-selector-parser": "^7.1.7",
"postcss-value-parser": "^4.2.0",
+2
View File
@@ -1,5 +1,7 @@
# @navikt/aksel
## 8.21.0
## 8.20.1
## 8.20.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel",
"version": "8.20.1",
"version": "8.21.0",
"description": "Aksel command line interface. Codemods and other utilities for Aksel users.",
"author": "Aksel | Nav designsystem team",
"license": "MIT",
+10
View File
@@ -1,5 +1,15 @@
# @navikt/ds-css
## 8.21.0
### Minor Changes
- DataGrid: Add top and bottom content areas outside the table's scrollable viewport. ([#5442](https://github.com/navikt/aksel/pull/5442))
### Patch Changes
- DataTable: Constrain details panel width to the visible table area. ([#5433](https://github.com/navikt/aksel/pull/5433))
## 8.20.1
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-css",
"version": "8.20.1",
"version": "8.21.0",
"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.20.1",
"@navikt/ds-tokens": "^8.21.0",
"browserslist": "^4.28.7",
"esbuild": "^0.28.2",
"fast-glob": "3.3.3",
+1 -91
View File
@@ -6,8 +6,7 @@
display: flex;
flex-direction: column;
width: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.aksel-data-table__frame {
@@ -755,95 +754,6 @@ html[data-dragging-cursor="true"] *::after {
/* ------------------------ DataGrid.Preferences End ------------------------ */
/* ------------------------ DataGrid.Pagination Start ----------------------- */
.aksel-data-grid__pagination {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: var(--ax-space-8);
/* Equal side columns keep page size centered when summary width changes. */
&[data-layout="preset"] {
@media (min-width: 768px) {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
& > :last-child {
justify-self: end;
}
}
}
}
.aksel-data-grid__pagination-page-size {
display: flex;
align-items: center;
gap: var(--ax-space-8);
.aksel-data-grid__pagination[data-layout="preset"] & {
display: none;
@media (min-width: 768px) {
display: flex;
}
}
}
.aksel-data-grid__pagination-navigation {
display: flex;
align-items: center;
gap: var(--ax-space-4);
}
.aksel-data-grid__pagnation-navigation-button {
/* TODO: Avoids focus loss now, but is unconventional so consider revisiting this approach */
&[aria-disabled="true"] {
cursor: default;
opacity: var(--ax-opacity-disabled);
}
@media (forced-colors: active) {
/* TODO: Avoids focus loss now, but is unconventional so consider revisiting this approach */
&[aria-disabled="true"] {
color: GrayText;
opacity: 1;
}
}
}
.aksel-data-grid__pagination-summary {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.aksel-data-grid__pagination-navigation-page {
display: inline-grid;
font-variant-numeric: tabular-nums;
white-space: nowrap;
&::before {
content: attr(data-max-label);
visibility: hidden;
}
&::before,
& > span {
grid-area: 1 / 1;
}
}
.aksel-data-grid__pagination-navigation-page {
text-align: center;
display: none;
@media (min-width: 480px) {
display: inline-grid;
}
}
/* ------------------------- DataGrid.Pagination End ------------------------ */
.aksel-data-table__attachment {
padding: var(--ax-space-12) var(--ax-space-16);
border: 1px solid var(--ax-border-neutral-subtle);
+16 -30
View File
@@ -142,7 +142,7 @@
flex-direction: column;
overflow: hidden;
.aksel-combobox2:has(:not(.aksel-combobox2__modal)) & {
.aksel-combobox2:not(:has(.aksel-combobox2__modal)) & {
outline: 1px solid var(--ax-border-neutral-subtleA);
outline-offset: -1px;
border-radius: var(--ax-radius-8);
@@ -158,26 +158,21 @@
margin: -1px;
}
.aksel-combobox2__filter .aksel-search__wrapper {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
.aksel-combobox2__filter .aksel-search__wrapper:has(.aksel-search__input:focus-visible) {
outline: none;
position: relative;
&:has(.aksel-search__input:focus-visible) {
outline: none;
position: relative;
&::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
border: 2px solid var(--ax-border-focus);
}
&::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
border: 2px solid var(--ax-border-focus);
}
.aksel-combobox2:has(:not(.aksel-combobox2__modal)) &::after {
border-top-left-radius: var(--ax-radius-8);
border-top-right-radius: var(--ax-radius-8);
}
.aksel-combobox2:not(:has(.aksel-combobox2__modal)) &::after {
border-top-left-radius: var(--ax-radius-8);
border-top-right-radius: var(--ax-radius-8);
}
}
@@ -189,7 +184,7 @@
border-color: var(--ax-border-neutral-subtleA);
}
.aksel-combobox2[data-size="medium"] & {
.aksel-combobox2__popup[data-size="medium"] & {
min-height: 0;
}
@@ -221,15 +216,6 @@
text-align: center;
}
/************************************************
* Group *
************************************************/
.aksel-combobox2__group-label {
padding: var(--ax-space-8) var(--ax-space-8);
font-weight: bold;
}
/************************************************
* Option *
************************************************/
@@ -253,7 +239,7 @@
margin-top: var(--ax-space-2);
float: left;
.aksel-combobox2[data-size="small"] & {
.aksel-combobox2__popup[data-size="small"] & {
--__axc-combobox2-option-checkmark-size: 1rem;
}
+16
View File
@@ -1,5 +1,21 @@
# @navikt/ds-react
## 8.21.0
### Minor Changes
- DataGrid: Add top and bottom content areas outside the table's scrollable viewport. ([#5442](https://github.com/navikt/aksel/pull/5442))
### Patch Changes
- DatePicker: Preserve keyboard focus when controlled month changes replace the focused day. ([#5408](https://github.com/navikt/aksel/pull/5408))
- Dialog, Lookup: Avoid setting aria-hidden on elements with role=alert/status when open ([#5466](https://github.com/navikt/aksel/pull/5466))
- Updated dependencies [[`f87f195`](https://github.com/navikt/aksel/commit/f87f1951fef77430c433d3e36892f18fc1387eb3)]:
- @navikt/aksel-icons@8.21.0
- @navikt/ds-tokens@8.21.0
## 8.20.1
### Patch Changes
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-react",
"version": "8.20.1",
"version": "8.21.0",
"description": "React components from the Norwegian Labour and Welfare Administration.",
"author": "Aksel, a team part of the Norwegian Labour and Welfare Administration.",
"license": "MIT",
@@ -775,7 +775,7 @@
"docgen:meta": "yarn metadata:generate-names && tsx ./scripts/extract-metadata.ts",
"metadata:generate-names": "tsx ./scripts/extract-component-names.ts",
"i18n-jsdoc": "tsx ./scripts/addJsdocToLocales.ts",
"write-packagejson": "echo '{\"type\": \"module\"}' > esm/package.json",
"write-packagejson": "echo '{\"type\": \"module\", \"sideEffects\": false}' > esm/package.json",
"clean": "rimraf cjs esm",
"build": "yarn i18n-jsdoc && yarn metadata:generate-names && concurrently \"tsc -p tsconfig.build.json --module commonjs --outDir cjs --tsBuildInfoFile cjs/.tsbuildinfo\" \"tsc -p tsconfig.build.json --module esnext --outDir esm --tsBuildInfoFile esm/.tsbuildinfo && tsc-alias -p tsconfig.build.json --dir esm -f && yarn write-packagejson\" && yarn i18n-jsdoc --cleanup && yarn copy-types",
"watch": "tsc --watch -p tsconfig.build.json --module esnext --outDir esm --tsBuildInfoFile esm/.tsbuildinfo",
@@ -788,8 +788,8 @@
"dependencies": {
"@floating-ui/react": "0.27.20",
"@floating-ui/react-dom": "^2.1.9",
"@navikt/aksel-icons": "^8.20.1",
"@navikt/ds-tokens": "^8.20.1",
"@navikt/aksel-icons": "^8.21.0",
"@navikt/ds-tokens": "^8.21.0",
"date-fns": "^4.0.0",
"react-day-picker": "10.0.2"
},
@@ -7,10 +7,6 @@ const metadata: ComponentMetadata = {
DataGrid,
"DataGrid.Table": DataGridTable,
"DataGrid.Preferences": DataGridPreferences,
"DataGrid.Pagination": DataGrid.Pagination,
"DataGrid.PaginationSummary": DataGrid.PaginationSummary,
"DataGrid.PaginationPageSize": DataGrid.PaginationPageSize,
"DataGrid.PaginationNavigation": DataGrid.PaginationNavigation,
},
keywords: ["data grid", "datagrid", "table", "tabell", "grid"],
related: ["Table"],
@@ -1,134 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import React, { useState } from "react";
import { DataGrid } from "../root/DataGridRoot";
const meta: Meta<typeof DataGrid> = {
title: "ds-react/DataGrid/Pagination",
component: DataGrid,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof DataGrid>;
type Row = { id: number; name: string; role: string };
const ROWS: Row[] = Array.from({ length: 137 }, (_, i) => ({
id: i + 1,
name: `Person ${i + 1}`,
role: ["Utvikler", "Designer", "Leder"][i % 3],
}));
const columns: DataGrid.Columns<Row> = [
{ id: "id", header: "Id", bodyCell: (row) => row.id, align: "right" },
{ id: "name", header: "Navn", bodyCell: (row) => row.name },
{ id: "role", header: "Rolle", bodyCell: (row) => row.role },
];
const sortableColumns: DataGrid.Columns<Row> = columns.map((column) => ({
...column,
isSortable: column.id === "id",
}));
function PagedGrid({ paginationAbove = false }: { paginationAbove?: boolean }) {
return (
<DataGrid
columns={columns}
data={ROWS}
getRowId={(row) => String(row.id)}
pagination={{ defaultPageSize: 10 }}
>
<DataGrid.Table
topContent={paginationAbove ? <DataGrid.Pagination /> : undefined}
bottomContent={<DataGrid.Pagination />}
/>
</DataGrid>
);
}
export const Default: Story = {
render: () => <PagedGrid />,
};
export const ClientSideLoading: Story = {
render: () => {
const [isLoading, setIsLoading] = useState(true);
return (
<>
<button type="button" onClick={() => setIsLoading((value) => !value)}>
{isLoading ? "Complete load" : "Reload data"}
</button>
<DataGrid
columns={columns}
data={isLoading ? [] : ROWS}
getRowId={(row) => String(row.id)}
isLoading={isLoading}
pagination={{ defaultPage: 2 }}
>
<DataGrid.Table
loadingContent={{ variant: "skeleton", rows: 10 }}
bottomContent={<DataGrid.Pagination />}
/>
</DataGrid>
</>
);
},
};
export const ServerSide: Story = {
render: () => {
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(10);
return (
<DataGrid
columns={columns}
data={ROWS.slice(page * pageSize, (page + 1) * pageSize)}
getRowId={(row) => String(row.id)}
pagination={{
mode: "server",
page,
onPageChange: setPage,
pageSize,
onPageSizeChange: setPageSize,
totalRows: ROWS.length,
}}
>
<DataGrid.Table bottomContent={<DataGrid.Pagination />} />
</DataGrid>
);
},
};
export const ResetPageOnSort: Story = {
render: () => {
const [sort, setSort] = useState<DataGrid.Table.SortEntry[]>([]);
const direction = sort[0]?.direction;
const sortedRows = [...ROWS].sort((a, b) =>
direction === "desc" ? b.id - a.id : a.id - b.id,
);
return (
<DataGrid
columns={sortableColumns}
data={sortedRows}
getRowId={(row) => String(row.id)}
pagination={{ defaultPageSize: 10 }}
>
<DataGrid.Table
sorting={{
sortOrder: sort,
onSortOrderChange: setSort,
allowMultiSort: false,
}}
bottomContent={<DataGrid.Pagination />}
/>
</DataGrid>
);
},
};
@@ -1,314 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import React, { useState } from "react";
import { expect, userEvent, waitFor, within } from "storybook/test";
import type { SortEntry } from "../../data/table/root/DataGridTable.types";
import { DataGrid } from "../root/DataGridRoot";
import { useDataGridRootPaginationContext } from "../root/DataGridRoot.context";
const meta: Meta<typeof DataGrid> = {
title: "ds-react/DataGrid/Pagination/tests",
component: DataGrid,
parameters: {
layout: "padded",
chromatic: { disable: true },
},
};
export default meta;
type Story = StoryObj<typeof DataGrid>;
type Row = { id: number; name: string };
const getRowId = (row: Row) => String(row.id);
const rows: Row[] = Array.from({ length: 5 }, (_, index) => ({
id: index + 1,
name: `Person ${index + 1}`,
}));
const columns: DataGrid.Columns<Row> = [
{ id: "name", header: "Name", bodyCell: (row) => row.name },
];
const sortableColumns: DataGrid.Columns<Row> = [
{ id: "name", header: "Name", bodyCell: (row) => row.name, isSortable: true },
];
function LoadingClientGrid({ controlled = false }: { controlled?: boolean }) {
const [data, setData] = useState<Row[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [page, setPage] = useState(2);
const [correctionCount, setCorrectionCount] = useState(0);
return (
<>
<output aria-label="Page corrections">{correctionCount}</output>
<button
type="button"
onClick={() => {
setData(rows);
setIsLoading(false);
}}
>
Complete load
</button>
<button
type="button"
onClick={() => {
setData([]);
setIsLoading(true);
}}
>
Reload
</button>
<button
type="button"
onClick={() => {
setData(rows.slice(0, 3));
setIsLoading(false);
}}
>
Complete smaller load
</button>
<DataGrid
columns={columns}
data={data}
getRowId={getRowId}
isLoading={isLoading}
pagination={{
page: controlled ? page : undefined,
defaultPage: 2,
defaultPageSize: 2,
pageSizeOptions: [2],
onPageChange: (nextPage) => {
setPage(nextPage);
setCorrectionCount((count) => count + 1);
},
}}
>
<DataGrid.Table bottomContent={<DataGrid.Pagination />} />
</DataGrid>
</>
);
}
const testLoadingPreservesPage: Story["play"] = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const corrections = canvas.getByRole("status", {
name: "Page corrections",
});
const completeLoad = canvas.getByRole("button", { name: "Complete load" });
const reload = canvas.getByRole("button", { name: "Reload" });
expect(corrections).toHaveTextContent("0");
await userEvent.click(completeLoad);
expect(canvas.getByText("Side 3 av 3")).toBeInTheDocument();
expect(canvas.getByRole("cell", { name: "Person 5" })).toBeInTheDocument();
await userEvent.click(reload);
expect(corrections).toHaveTextContent("0");
await userEvent.click(completeLoad);
expect(canvas.getByText("Side 3 av 3")).toBeInTheDocument();
expect(corrections).toHaveTextContent("0");
await userEvent.click(reload);
await userEvent.click(
canvas.getByRole("button", { name: "Complete smaller load" }),
);
expect(await canvas.findByText("Side 2 av 2")).toBeInTheDocument();
expect(canvas.getByRole("cell", { name: "Person 3" })).toBeInTheDocument();
expect(corrections).toHaveTextContent("1");
};
export const LoadingPreservesUncontrolledPage: Story = {
render: () => <LoadingClientGrid />,
play: testLoadingPreservesPage,
};
export const LoadingPreservesControlledPage: Story = {
render: () => <LoadingClientGrid controlled />,
play: testLoadingPreservesPage,
};
const manyRows: Row[] = Array.from({ length: 60 }, (_, index) => ({
id: index + 1,
name: `Person ${String(index + 1).padStart(2, "0")}`,
}));
function ResetControls({
onSortProgrammatically,
}: {
onSortProgrammatically: () => void;
}) {
const { page, pageSize, setPage, setPageSize } =
useDataGridRootPaginationContext();
return (
<div>
<p>{`Page ${page + 1} · ${pageSize} rows`}</p>
<button type="button" onClick={() => setPage(page + 1)}>
Next page
</button>
<button type="button" onClick={() => setPageSize(10)}>
Ten rows
</button>
<button type="button" onClick={onSortProgrammatically}>
Sort programmatically
</button>
</div>
);
}
function ResetGrid({ acceptSort }: { acceptSort: boolean }) {
const [sortOrder, setSortOrder] = useState<SortEntry[]>([]);
return (
<div style={{ display: "flex", height: 360 }}>
<DataGrid
columns={sortableColumns}
data={manyRows}
getRowId={(row) => String(row.id)}
pagination={{ defaultPageSize: 20, pageSizeOptions: [10, 20] }}
>
<ResetControls
onSortProgrammatically={() =>
setSortOrder([{ columnId: "name", direction: "desc" }])
}
/>
<DataGrid.Table
aria-label="Personer"
sorting={{
sortOrder,
onSortOrderChange: (next) => {
if (acceptSort) {
setSortOrder(next);
}
},
}}
/>
</DataGrid>
</div>
);
}
function getScrollWrapper(canvasElement: HTMLElement) {
const wrapper = canvasElement.querySelector<HTMLElement>(
".aksel-data-table__scroll-wrapper",
);
if (!wrapper) {
throw new Error("Table scroll wrapper not found");
}
return wrapper;
}
function scrollDown(wrapper: HTMLElement) {
wrapper.scrollTop = 150;
expect(wrapper.scrollTop).toBeGreaterThan(0);
}
export const ResetsPageAndTableScroll: Story = {
render: () => <ResetGrid acceptSort />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const wrapper = getScrollWrapper(canvasElement);
const view = canvasElement.ownerDocument.defaultView ?? window;
const windowScrollY = view.scrollY;
scrollDown(wrapper);
await userEvent.click(canvas.getByRole("button", { name: "Next page" }));
expect(canvas.getByText("Page 2 · 20 rows")).toBeInTheDocument();
await waitFor(() => expect(wrapper.scrollTop).toBe(0));
scrollDown(wrapper);
await userEvent.click(canvas.getByRole("button", { name: "Name" }));
expect(canvas.getByText("Page 1 · 20 rows")).toBeInTheDocument();
expect(canvas.getByRole("columnheader", { name: "Name" })).toHaveAttribute(
"aria-sort",
"ascending",
);
await waitFor(() => expect(wrapper.scrollTop).toBe(0));
scrollDown(wrapper);
await userEvent.click(canvas.getByRole("button", { name: "Name" }));
expect(canvas.getByText("Page 1 · 20 rows")).toBeInTheDocument();
await waitFor(() => expect(wrapper.scrollTop).toBe(0));
await userEvent.click(canvas.getByRole("button", { name: "Next page" }));
scrollDown(wrapper);
await userEvent.click(canvas.getByRole("button", { name: "Ten rows" }));
expect(canvas.getByText("Page 1 · 10 rows")).toBeInTheDocument();
await waitFor(() => expect(wrapper.scrollTop).toBe(0));
expect(view.scrollY).toBe(windowScrollY);
},
};
export const RejectedSortDoesNotReset: Story = {
render: () => <ResetGrid acceptSort={false} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const wrapper = getScrollWrapper(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Next page" }));
scrollDown(wrapper);
const scrollTop = wrapper.scrollTop;
await userEvent.click(canvas.getByRole("button", { name: "Name" }));
expect(canvas.getByText("Page 2 · 20 rows")).toBeInTheDocument();
expect(canvas.getByRole("columnheader", { name: "Name" })).toHaveAttribute(
"aria-sort",
"none",
);
expect(wrapper.scrollTop).toBe(scrollTop);
await userEvent.click(
canvas.getByRole("button", { name: "Sort programmatically" }),
);
expect(canvas.getByText("Page 1 · 20 rows")).toBeInTheDocument();
await waitFor(() => expect(wrapper.scrollTop).toBe(0));
},
};
function ServerResetGrid() {
const [sortOrder, setSortOrder] = useState<SortEntry[]>([]);
return (
<DataGrid
columns={sortableColumns}
data={manyRows.slice(0, 20)}
getRowId={getRowId}
pagination={{
mode: "server",
defaultPageSize: 20,
pageSizeOptions: [10, 20],
totalRows: manyRows.length,
}}
>
<DataGrid.Table
aria-label="Personer"
sorting={{ sortOrder, onSortOrderChange: setSortOrder }}
bottomContent={<DataGrid.Pagination />}
/>
</DataGrid>
);
}
export const ServerSortDoesNotResetPage: Story = {
render: () => <ServerResetGrid />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Side 1 av 3")).toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Neste side" }));
expect(canvas.getByText("Side 2 av 3")).toBeInTheDocument();
expect(canvas.getByRole("cell", { name: "Person 01" })).toBeInTheDocument();
expect(canvas.getByRole("cell", { name: "Person 20" })).toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Name" }));
expect(canvas.getByRole("columnheader", { name: "Name" })).toHaveAttribute(
"aria-sort",
"ascending",
);
expect(canvas.getByText("Side 2 av 3")).toBeInTheDocument();
},
};
@@ -1,54 +0,0 @@
import React from "react";
import type { DataGridPaginationConfig } from "../root/DataGrid.types";
import { DataGridRootPaginationContextProvider } from "../root/DataGridRoot.context";
import { DataGridPaginationLiveRegion } from "./root/DataGridPaginationRoot";
import { useDataGridPagination } from "./root/useDataGridPagination";
/**
* Mounted only when `pagination` is set, so uncontrolled defaults apply
* when pagination is enabled and no state or warnings exist while it is off.
*/
function DataGridPaginationInternal({
pagination,
rowCount,
isLoading,
children,
}: {
pagination: DataGridPaginationConfig;
rowCount: number;
isLoading: boolean;
children: React.ReactNode;
}) {
const paginationState = useDataGridPagination({
pagination,
rows:
pagination.mode === "server"
? pagination.totalRows !== undefined
? {
mode: "server",
pageRowCount: rowCount,
totalRows: pagination.totalRows,
}
: {
mode: "server",
pageRowCount: rowCount,
hasNextPage: pagination.hasNextPage,
}
: { mode: "client", rowCount },
isLoading,
});
return (
<DataGridRootPaginationContextProvider
{...paginationState}
mode={pagination.mode ?? "client"}
>
{children}
{pagination.announcePageChanges !== false && (
<DataGridPaginationLiveRegion />
)}
</DataGridRootPaginationContextProvider>
);
}
export { DataGridPaginationInternal };
@@ -1,221 +0,0 @@
import { describe, expect, test } from "vitest";
import { getPaginationRange } from "./pagination-range";
describe("getPaginationRange", () => {
test("calculates the range and page count for a known total", () => {
expect(
getPaginationRange({
page: 1,
pageSize: 10,
mode: "server",
pageRowCount: 10,
totalRows: 25,
}),
).toEqual({
page: 1,
pageCount: 3,
from: 11,
to: 20,
startIndex: 10,
endIndex: 20,
canPrevious: true,
canNext: true,
});
});
test("uses hasNextPage when the total is unknown", () => {
expect(
getPaginationRange({
page: 2,
pageSize: 10,
mode: "server",
pageRowCount: 4,
hasNextPage: false,
}),
).toEqual({
page: 2,
pageCount: undefined,
from: 21,
to: 24,
startIndex: 20,
endIndex: 24,
canPrevious: true,
canNext: false,
});
});
test("returns one page and an empty range for zero known rows", () => {
expect(
getPaginationRange({
page: 0,
pageSize: 10,
mode: "server",
pageRowCount: 0,
totalRows: 0,
}),
).toEqual({
page: 0,
pageCount: 1,
from: 0,
to: 0,
startIndex: 0,
endIndex: 0,
canPrevious: false,
canNext: false,
});
});
test("returns an empty range for an unknown-total page without data", () => {
expect(
getPaginationRange({
page: 3,
pageSize: 10,
mode: "server",
pageRowCount: 0,
hasNextPage: true,
}),
).toEqual({
page: 3,
pageCount: undefined,
from: 0,
to: 0,
startIndex: 30,
endIndex: 30,
canPrevious: true,
canNext: true,
});
});
test("clamps negative unknown-total pages to zero", () => {
expect(
getPaginationRange({
page: -1,
pageSize: 10,
mode: "server",
pageRowCount: 3,
hasNextPage: true,
}),
).toEqual({
page: 0,
pageCount: undefined,
from: 1,
to: 3,
startIndex: 0,
endIndex: 3,
canPrevious: false,
canNext: true,
});
});
test("limits the range on the last partial page", () => {
expect(
getPaginationRange({
page: 2,
pageSize: 10,
mode: "server",
pageRowCount: 5,
totalRows: 25,
}),
).toEqual({
page: 2,
pageCount: 3,
from: 21,
to: 25,
startIndex: 20,
endIndex: 25,
canPrevious: true,
canNext: false,
});
});
test("clamps an out-of-range page before calculating the range", () => {
expect(
getPaginationRange({
page: 4,
pageSize: 10,
mode: "server",
pageRowCount: 2,
totalRows: 22,
}),
).toEqual({
page: 2,
pageCount: 3,
from: 21,
to: 22,
startIndex: 20,
endIndex: 22,
canPrevious: true,
canNext: false,
});
});
test("ends the slice at the known total when the page reports extra rows", () => {
expect(
getPaginationRange({
mode: "server",
page: 2,
pageSize: 10,
pageRowCount: 10,
totalRows: 25,
}),
).toMatchObject({ from: 21, to: 25, startIndex: 20, endIndex: 25 });
});
describe("client mode", () => {
test("derives the page slice from the total row count", () => {
expect(
getPaginationRange({
mode: "client",
page: 2,
pageSize: 10,
rowCount: 25,
}),
).toEqual({
page: 2,
pageCount: 3,
from: 21,
to: 25,
startIndex: 20,
endIndex: 25,
canPrevious: true,
canNext: false,
});
});
test("clamps the page when rows shrink", () => {
expect(
getPaginationRange({
mode: "client",
page: 4,
pageSize: 10,
rowCount: 12,
}),
).toMatchObject({
page: 1,
from: 11,
to: 12,
startIndex: 10,
endIndex: 12,
});
});
test("returns an empty first page without rows", () => {
expect(
getPaginationRange({
mode: "client",
page: 3,
pageSize: 10,
rowCount: 0,
}),
).toMatchObject({
page: 0,
pageCount: 1,
from: 0,
to: 0,
startIndex: 0,
endIndex: 0,
canNext: false,
});
});
});
});
@@ -1,102 +0,0 @@
type PaginationRangeOptions = {
page: number;
pageSize: number;
} & (
| {
/** All top-level rows are available; the page is sliced from them. */
mode: "client";
rowCount: number;
pageRowCount?: never;
totalRows?: never;
hasNextPage?: never;
}
| ({
/** Only the current page is available. */
mode: "server";
pageRowCount: number;
rowCount?: never;
} & (
| {
totalRows: number;
hasNextPage?: never;
}
| {
hasNextPage: boolean;
totalRows?: never;
}
))
);
type PaginationRange = {
page: number;
pageCount: number | undefined;
/** 1-based first visible row, or 0 when the page is empty. */
from: number;
/** 1-based last visible row, or 0 when the page is empty. */
to: number;
/** 0-based inclusive start index of the page among all top-level rows. */
startIndex: number;
/** 0-based exclusive end index of the page among all top-level rows. */
endIndex: number;
canPrevious: boolean;
canNext: boolean;
};
function getPaginationRange(options: PaginationRangeOptions): PaginationRange {
const { page, pageSize } = options;
if (options.mode === "client") {
const pageCount = Math.max(1, Math.ceil(options.rowCount / pageSize));
const currentPage = Math.min(Math.max(page, 0), pageCount - 1);
const startIndex = currentPage * pageSize;
const visibleRows = Math.min(
Math.max(options.rowCount - startIndex, 0),
pageSize,
);
return {
page: currentPage,
pageCount,
from: visibleRows === 0 ? 0 : startIndex + 1,
to: visibleRows === 0 ? 0 : startIndex + visibleRows,
startIndex,
endIndex: startIndex + visibleRows,
canPrevious: currentPage > 0,
canNext: currentPage < pageCount - 1,
};
}
const { totalRows, pageRowCount, hasNextPage } = options;
const pageCount =
totalRows === undefined
? undefined
: Math.max(1, Math.ceil(totalRows / pageSize));
const currentPage =
pageCount === undefined
? Math.max(page, 0)
: Math.min(Math.max(page, 0), pageCount - 1);
const startIndex = currentPage * pageSize;
const visibleRows =
totalRows === undefined
? pageRowCount
: Math.min(pageRowCount, Math.max(totalRows - startIndex, 0));
const from = visibleRows === 0 ? 0 : startIndex + 1;
const to = visibleRows === 0 ? 0 : startIndex + visibleRows;
return {
page: currentPage,
pageCount,
from,
to,
startIndex,
endIndex: startIndex + visibleRows,
canPrevious: currentPage > 0,
canNext:
pageCount === undefined
? hasNextPage === true
: currentPage < pageCount - 1,
};
}
export { getPaginationRange };
export type { PaginationRange, PaginationRangeOptions };
@@ -1,137 +0,0 @@
import React, { forwardRef } from "react";
import {
ChevronLeftFirstIcon,
ChevronLeftIcon,
ChevronRightIcon,
ChevronRightLastIcon,
} from "@navikt/aksel-icons";
import { Button } from "../../../button";
import { cl } from "../../../utils/helpers";
import { useI18n } from "../../../utils/i18n/i18n.hooks";
import { useDataGridRootPaginationContext } from "../../root/DataGridRoot.context";
interface DataGridPaginationNavigationProps extends React.HTMLAttributes<HTMLElement> {
children?: never;
}
/**
* Provides first, previous, next, and last page controls.
*
* @see 🏷️ {@link DataGridPagin dationNavigationProps}
*
* @example
* ```jsx
* <DataGrid columns={columns} data={rows} pagination={{}}>
* <DataGrid.Pagination>
* <DataGrid.PaginationNavigation />
* </DataGrid.Pagination>
* </DataGrid>
* ```
*/
const DataGridPaginationNavigation = forwardRef<
HTMLElement,
DataGridPaginationNavigationProps
>(({ className, "aria-label": ariaLabel, ...rest }, forwardedRef) => {
const { page, pageCount, canPrevious, canNext, isLoading, setPage } =
useDataGridRootPaginationContext(
true,
"[Aksel] DataGrid.PaginationNavigation must be used within <DataGrid pagination={...}>",
);
const translate = useI18n("DataGrid");
return (
<nav
{...rest}
ref={forwardedRef}
aria-label={ariaLabel ?? translate("Pagination.navLabel")}
className={cl("aksel-data-grid__pagination-navigation", className)}
>
<Button
type="button"
className="aksel-data-grid__pagnation-navigation-button"
size="small"
variant="tertiary"
data-color="neutral"
icon={<ChevronLeftFirstIcon aria-hidden />}
aria-label={translate("Pagination.first")}
aria-disabled={isLoading || !canPrevious}
onClick={() => {
if (!isLoading && canPrevious) {
setPage(0);
}
}}
/>
<Button
type="button"
className="aksel-data-grid__pagnation-navigation-button"
size="small"
variant="tertiary"
data-color="neutral"
icon={<ChevronLeftIcon aria-hidden />}
aria-label={translate("Pagination.previous")}
aria-disabled={isLoading || !canPrevious}
onClick={() => {
if (!isLoading && canPrevious) {
setPage(page - 1);
}
}}
/>
<span
className="aksel-data-grid__pagination-navigation-page"
data-max-label={
pageCount === undefined
? translate("Pagination.pageOfUnknownTotal", {
page: "9".repeat(Math.max(3, String(page + 1).length)),
})
: translate("Pagination.pageOf", { page: pageCount, pageCount })
}
>
<span>
{pageCount === undefined
? translate("Pagination.pageOfUnknownTotal", { page: page + 1 })
: translate("Pagination.pageOf", { page: page + 1, pageCount })}
</span>
</span>
<Button
type="button"
className="aksel-data-grid__pagnation-navigation-button"
size="small"
variant="tertiary"
data-color="neutral"
icon={<ChevronRightIcon aria-hidden />}
aria-label={translate("Pagination.next")}
aria-disabled={isLoading || !canNext}
onClick={() => {
if (!isLoading && canNext) {
setPage(page + 1);
}
}}
/>
{pageCount !== undefined && (
<Button
type="button"
className="aksel-data-grid__pagnation-navigation-button"
size="small"
variant="tertiary"
data-color="neutral"
icon={<ChevronRightLastIcon aria-hidden />}
aria-label={translate("Pagination.last")}
aria-disabled={isLoading || !canNext}
onClick={() => {
if (!isLoading && canNext) {
setPage(pageCount - 1);
}
}}
/>
)}
</nav>
);
});
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace DataGridPaginationNavigation {
export type Props = DataGridPaginationNavigationProps;
}
export { DataGridPaginationNavigation };
export type { DataGridPaginationNavigationProps };
@@ -1,70 +0,0 @@
import React, { forwardRef, useMemo } from "react";
import { Select, type SelectProps } from "../../../form/select";
import { cl } from "../../../utils/helpers";
import { useI18n } from "../../../utils/i18n/i18n.hooks";
import { useDataGridRootPaginationContext } from "../../root/DataGridRoot.context";
type DataGridPaginationPageSizeProps = Omit<
SelectProps,
"children" | "label" | "value" | "onChange"
>;
/**
* Lets users choose how many rows to display per page.
*
* @see 🏷️ {@link DataGridPaginationPageSizeProps}
*
* @example
* ```jsx
* <DataGrid columns={columns} data={rows} pagination={{}}>
* <DataGrid.Pagination>
* <DataGrid.PaginationPageSize />
* </DataGrid.Pagination>
* </DataGrid>
* ```
*/
const DataGridPaginationPageSize = forwardRef<
HTMLSelectElement,
DataGridPaginationPageSizeProps
>(({ className, style, ...rest }, forwardedRef) => {
const { pageSize, pageSizeOptions, setPageSize } =
useDataGridRootPaginationContext(
true,
"[Aksel] DataGrid.PaginationPageSize must be used within <DataGrid pagination={...}>",
);
const translate = useI18n("DataGrid");
const options = useMemo(
() =>
pageSizeOptions.includes(pageSize)
? pageSizeOptions
: [...pageSizeOptions, pageSize],
[pageSize, pageSizeOptions],
);
return (
<Select
{...rest}
ref={forwardedRef}
className={cl("aksel-data-grid__pagination-page-size", className)}
label={translate("Pagination.pageSize")}
size="small"
style={{ width: "auto", ...style }}
value={pageSize}
onChange={(event) => setPageSize(Number(event.currentTarget.value))}
>
{options.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</Select>
);
});
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace DataGridPaginationPageSize {
export type Props = DataGridPaginationPageSizeProps;
}
export { DataGridPaginationPageSize };
export type { DataGridPaginationPageSizeProps };
@@ -1,27 +0,0 @@
type DataGridPaginationSharedOptions = {
/** Controlled, zero-indexed page. */
page?: number;
/**
* Initial zero-indexed page when `page` is uncontrolled.
* @default 0
*/
defaultPage?: number;
/** Called with the requested zero-indexed page. */
onPageChange?: (page: number) => void;
/** Controlled number of rows per page. */
pageSize?: number;
/**
* Initial page size when `pageSize` is uncontrolled.
* @default pageSizeOptions[0]
*/
defaultPageSize?: number;
/** Called when the page size changes. */
onPageSizeChange?: (pageSize: number) => void;
/**
* Page-size options.
* @default [10, 25, 50, 100]
*/
pageSizeOptions?: number[];
};
export type { DataGridPaginationSharedOptions };
@@ -1,100 +0,0 @@
import React, { forwardRef, useEffect, useRef, useState } from "react";
import { cl } from "../../../utils/helpers";
import { useI18n } from "../../../utils/i18n/i18n.hooks";
import { useDataGridRootPaginationContext } from "../../root/DataGridRoot.context";
import { DataGridPaginationNavigation } from "../navigation/DataGridPaginationNavigation";
import { DataGridPaginationPageSize } from "../page-size/DataGridPaginationPageSize";
import { DataGridPaginationSummary } from "../summary/DataGridPaginationSummary";
interface DataGridPaginationProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Custom layout built from the pagination parts.
* Renders the preset layout when omitted.
*/
children?: React.ReactNode;
}
/**
* Pagination bar for the parent DataGrid. Requires the `pagination` prop on `<DataGrid>`.
*
* @see 🏷️ {@link DataGridPaginationProps}
*
* @example
* ```jsx
* <DataGrid columns={columns} data={rows} pagination={{ defaultPageSize: 10 }}>
* <DataGrid.Table />
* <DataGrid.Pagination />
* </DataGrid>
* ```
*/
const DataGridPagination = forwardRef<HTMLDivElement, DataGridPaginationProps>(
({ children, className, ...rest }, forwardedRef) => {
useDataGridRootPaginationContext();
return (
<div
{...rest}
ref={forwardedRef}
className={cl("aksel-data-grid__pagination", className)}
data-layout={children ? "custom" : "preset"}
>
{children ?? (
<>
<DataGridPaginationSummary />
<DataGridPaginationPageSize />
<DataGridPaginationNavigation />
</>
)}
</div>
);
},
);
function DataGridPaginationLiveRegion() {
const { page, pageCount, pageSize, from, to, totalRows, isLoading } =
useDataGridRootPaginationContext();
const translate = useI18n("DataGrid");
const lastAnnouncedPage = useRef({ page, pageSize });
const [announcement, setAnnouncement] = useState("");
useEffect(() => {
if (
isLoading ||
(lastAnnouncedPage.current.page === page &&
lastAnnouncedPage.current.pageSize === pageSize)
) {
return;
}
const summary =
to === 0
? translate("Pagination.summaryEmpty")
: totalRows === undefined
? translate("Pagination.summaryUnknownTotal", { from, to })
: translate("Pagination.summary", { from, to, totalRows });
const pageLabel =
pageCount === undefined
? translate("Pagination.pageOfUnknownTotal", { page: page + 1 })
: translate("Pagination.pageOf", { page: page + 1, pageCount });
lastAnnouncedPage.current = { page, pageSize };
// Keep the completed message during loading and unrelated renders.
setAnnouncement(
`${summary}. ${pageLabel}. ${translate("Pagination.pageSize")}: ${pageSize}`,
);
}, [isLoading, page, pageCount, pageSize, from, to, totalRows, translate]);
return (
<div aria-live="polite" aria-atomic="true" className="aksel-sr-only">
{announcement}
</div>
);
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace DataGridPagination {
export type Props = DataGridPaginationProps;
}
export { DataGridPagination, DataGridPaginationLiveRegion };
export type { DataGridPaginationProps };
@@ -1,171 +0,0 @@
import { useEffect, useRef } from "react";
import { consoleWarning } from "../../../utils/helpers/consoleWarning";
import { useControllableState, useEventCallback } from "../../../utils/hooks";
import { getPaginationRange } from "../helpers/pagination-range";
import type { DataGridPaginationSharedOptions } from "./DataGridPagination.types";
const defaultPageSizeOptions = [10, 25, 50, 100];
type DataGridPaginationRows =
| {
/** Client mode: count of all top-level rows. DataGrid slices the page. */
mode: "client";
rowCount: number;
totalRows?: never;
hasNextPage?: never;
}
| ({
/** Server mode: count of top-level rows on the consumer-sliced page. */
mode: "server";
pageRowCount: number;
} & (
| { totalRows: number; hasNextPage?: never }
| { hasNextPage: boolean; totalRows?: never }
));
function useDataGridPagination({
pagination,
rows,
isLoading,
}: {
pagination: DataGridPaginationSharedOptions;
rows: DataGridPaginationRows;
isLoading: boolean;
}) {
const {
page: controlledPage,
defaultPage = 0,
onPageChange,
pageSize: controlledPageSize,
defaultPageSize,
onPageSizeChange,
pageSizeOptions = defaultPageSizeOptions,
} = pagination;
const [storedPage, setStoredPage] = useControllableState({
value: controlledPage,
defaultValue: defaultPage,
onChange: onPageChange,
});
const [pageSize, setStoredPageSize] = useControllableState({
value: controlledPageSize,
defaultValue:
defaultPageSize ?? pageSizeOptions[0] ?? defaultPageSizeOptions[0],
onChange: onPageSizeChange,
});
const totalRows =
rows.mode === "client"
? rows.rowCount
: "totalRows" in rows
? rows.totalRows
: undefined;
const range = getPaginationRange({ page: storedPage, pageSize, ...rows });
const previousSummaryRange = useRef({ from: range.from, to: range.to });
useEffect(() => {
if (!isLoading) {
previousSummaryRange.current = { from: range.from, to: range.to };
}
}, [isLoading, range.from, range.to]);
const setPage = useEventCallback((page: number) => {
const nextPage = getPaginationRange({ page, pageSize, ...rows }).page;
if (nextPage !== storedPage) {
setStoredPage(nextPage);
}
});
const setPageSize = useEventCallback((size: number) => {
setStoredPageSize(size);
setPage(0);
});
const lastCorrection = useRef<{
storedPage: number;
page: number;
} | null>(null);
useEffect(() => {
if (isLoading) {
return;
}
if (storedPage === range.page) {
lastCorrection.current = null;
return;
}
// Request each correction once, also when effects re-run (Strict Mode).
if (
lastCorrection.current?.storedPage === storedPage &&
lastCorrection.current.page === range.page
) {
return;
}
lastCorrection.current = { storedPage, page: range.page };
if (controlledPage !== undefined) {
consoleWarning(
`DataGrid pagination: controlled page ${storedPage} is out of range. Requesting page ${range.page}.`,
);
}
setStoredPage(range.page);
}, [controlledPage, isLoading, storedPage, range.page, setStoredPage]);
if (process.env.NODE_ENV !== "production") {
if (controlledPage !== undefined && !onPageChange) {
consoleWarning(
"DataGrid pagination: controlled `page` requires `onPageChange`.",
);
}
if (controlledPageSize !== undefined && !onPageSizeChange) {
consoleWarning(
"DataGrid pagination: controlled `pageSize` requires `onPageSizeChange`.",
);
}
if (pageSizeOptions.length === 0) {
consoleWarning(
"DataGrid pagination: `pageSizeOptions` must not be empty.",
);
}
if (new Set(pageSizeOptions).size !== pageSizeOptions.length) {
consoleWarning(
"DataGrid pagination: `pageSizeOptions` must contain unique values.",
);
}
if (pageSizeOptions.some((size) => !Number.isInteger(size) || size <= 0)) {
consoleWarning(
"DataGrid pagination: `pageSizeOptions` must contain positive integers.",
);
}
if (!pageSizeOptions.includes(pageSize)) {
consoleWarning(
"DataGrid pagination: the effective `pageSize` is missing from `pageSizeOptions`.",
);
}
if (totalRows !== undefined && totalRows < 0) {
consoleWarning("DataGrid pagination: `totalRows` must not be negative.");
}
}
const summaryRange =
totalRows === undefined && isLoading ? previousSummaryRange.current : range;
return {
...range,
from: summaryRange.from,
to: summaryRange.to,
pageSize,
pageSizeOptions,
totalRows,
isLoading,
setPage,
setPageSize,
};
}
export { useDataGridPagination };
export type { DataGridPaginationRows };
@@ -1,61 +0,0 @@
import React, { forwardRef } from "react";
import { BodyShort } from "../../../typography";
import { cl } from "../../../utils/helpers";
import { useI18n } from "../../../utils/i18n/i18n.hooks";
import { useDataGridRootPaginationContext } from "../../root/DataGridRoot.context";
interface DataGridPaginationSummaryProps extends React.HTMLAttributes<HTMLParagraphElement> {
children?: never;
}
/**
* Shows which rows are visible, e.g. "Showing 1–10 of 150 rows".
*
* @see 🏷️ {@link DataGridPaginationSummaryProps}
*
* @example
* ```jsx
* <DataGrid columns={columns} data={rows} pagination={{}}>
* <DataGrid.Pagination>
* <DataGrid.PaginationSummary />
* </DataGrid.Pagination>
* </DataGrid>
* ```
*/
const DataGridPaginationSummary = forwardRef<
HTMLParagraphElement,
DataGridPaginationSummaryProps
>(({ className, ...rest }, forwardedRef) => {
const { from, to, totalRows } = useDataGridRootPaginationContext(
true,
"[Aksel] DataGrid.PaginationSummary must be used within <DataGrid pagination={...}>",
);
const translate = useI18n("DataGrid");
let text: string;
if (to === 0) {
text = translate("Pagination.summaryEmpty");
} else if (totalRows === undefined) {
text = translate("Pagination.summaryUnknownTotal", { from, to });
} else {
text = translate("Pagination.summary", { from, to, totalRows });
}
return (
<BodyShort
{...rest}
ref={forwardedRef}
className={cl("aksel-data-grid__pagination-summary", className)}
>
{text}
</BodyShort>
);
});
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace DataGridPaginationSummary {
export type Props = DataGridPaginationSummaryProps;
}
export { DataGridPaginationSummary };
export type { DataGridPaginationSummaryProps };
@@ -1,35 +1,3 @@
import type { DataGridPaginationSharedOptions } from "../pagination/root/DataGridPagination.types";
type DataGridPaginationConfig = DataGridPaginationSharedOptions & {
/**
* Whether completed page and page-size changes are announced.
* @default true
*/
announcePageChanges?: boolean;
} & (
| {
/**
* Client mode: DataGrid receives all rows and slices the current page.
* @default "client"
*/
mode?: "client";
totalRows?: never;
hasNextPage?: never;
}
| {
/** Server mode: DataGrid receives the current page's rows. */
mode: "server";
totalRows: number;
hasNextPage?: never;
}
| {
/** Server mode: DataGrid receives the current page's rows. */
mode: "server";
totalRows?: never;
hasNextPage: boolean;
}
);
type DataGridSettings = {
/**
* Controls vertical cell padding.
@@ -80,4 +48,4 @@ type DataGridSettings = {
columnDisplay?: { id: string; visible: boolean | "always" }[];
};
export type { DataGridPaginationConfig, DataGridSettings };
export type { DataGridSettings };
@@ -2,7 +2,6 @@ import type { resolveDataGridSettings } from "../../data-grid-preferences/helper
import type { SelectionProps } from "../../data/table/hooks/useTableSelection";
import type { ColumnDefinitions } from "../../data/table/root/DataGridTable.types";
import { createStrictContext } from "../../utils/helpers";
import type { useDataGridPagination } from "../pagination/root/useDataGridPagination";
import type { DataGridSettings } from "./DataGrid.types";
type DataGridContextValue<RowT = unknown> = {
@@ -21,53 +20,4 @@ const { Provider: DataGridContextProvider, useContext: useDataGridContext } =
errorMessage: "DataGrid hooks must be used within a <DataGrid />",
});
type DataGridRootPaginationContextValue = ReturnType<
typeof useDataGridPagination
> & {
mode: "client" | "server";
};
const paginationContextErrorMessage =
"DataGrid.Pagination must be used within a <DataGrid pagination={...}>";
const {
Provider: DataGridRootPaginationContextProvider,
useContext: usePaginationContext,
} = createStrictContext<DataGridRootPaginationContextValue | { mode: "none" }>({
name: "DataGridRootPaginationContext",
errorMessage: paginationContextErrorMessage,
});
function useDataGridRootPaginationContext<S extends boolean = true>(
strict?: S,
errorMessageOverride?: string,
): S extends true
? DataGridRootPaginationContextValue
: DataGridRootPaginationContextValue | undefined;
function useDataGridRootPaginationContext(
strict = true,
errorMessageOverride?: string,
) {
const context = usePaginationContext(strict, errorMessageOverride);
if (context?.mode === "none") {
if (strict) {
const error = new Error(
errorMessageOverride ?? paginationContextErrorMessage,
);
error.name = "ContextError";
Error.captureStackTrace?.(error, useDataGridRootPaginationContext);
throw error;
}
return undefined;
}
return context;
}
export {
DataGridContextProvider,
DataGridRootPaginationContextProvider,
useDataGridContext,
useDataGridRootPaginationContext,
};
export { DataGridContextProvider, useDataGridContext };
@@ -5,53 +5,10 @@ import type { ColumnDefinitions } from "../../data/table/root/DataGridTable.type
import { DataGridTable } from "../../data/table/root/DataGridTableRoot";
import { cl } from "../../utils/helpers";
import { useDataGridSettings } from "../hooks/useDataGridSettings";
import { DataGridPaginationInternal } from "../pagination/DataGridPaginationInternal";
import { DataGridPaginationNavigation } from "../pagination/navigation/DataGridPaginationNavigation";
import { DataGridPaginationPageSize } from "../pagination/page-size/DataGridPaginationPageSize";
import { DataGridPagination } from "../pagination/root/DataGridPaginationRoot";
import { DataGridPaginationSummary } from "../pagination/summary/DataGridPaginationSummary";
import type {
DataGridPaginationConfig,
DataGridSettings,
} from "./DataGrid.types";
import {
DataGridContextProvider,
DataGridRootPaginationContextProvider,
} from "./DataGridRoot.context";
import type { DataGridSettings } from "./DataGrid.types";
import { DataGridContextProvider } from "./DataGridRoot.context";
type DataGridRowIdProps<RowT> =
| {
pagination?: never;
/**
* Function to get unique row ID from row data.
*
* If not provided, the row index will be used as ID.
* This can cause issues if your data changes dynamically,
* so it's recommended to provide a stable ID if possible.
*/
getRowId?: (rowData: RowT) => string;
}
| {
/**
* Enables root-owned pagination state. Pages are zero-indexed.
*
* Client mode (default) accepts all rows; server mode accepts the current
* page and requires exactly one of `totalRows` or `hasNextPage`.
*
* Requires `getRowId`.
*/
pagination: DataGridPaginationConfig;
/**
* Function to get unique row ID from row data.
*
* Required with `pagination`, so row IDs stay unique across pages.
*/
getRowId: (rowData: RowT) => string;
};
type DataGridProps<RowT> = DataGridBaseProps<RowT> & DataGridRowIdProps<RowT>;
interface DataGridBaseProps<RowT> {
interface DataGridProps<RowT> {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
@@ -66,6 +23,14 @@ interface DataGridBaseProps<RowT> {
* object are used to render the cells based on `columnDefinitions`.
*/
data: RowT[];
/**
* Function to get unique row ID from row data.
*
* If not provided, the row index will be used as ID.
* This can cause issues if your data changes dynamically,
* so it's recommended to provide a stable ID if possible.
*/
getRowId?: (rowData: RowT) => string;
/**
* Object with props related to row selection.
*/
@@ -112,28 +77,6 @@ interface DataGridComponent {
* </DataGrid>
*/
Preferences: typeof DataGridPreferences;
/**
* @see 🏷️ {@link DataGridPagination.Props}
*
* @example
* <DataGrid columns={columns} data={rows} getRowId={(row) => row.id} pagination={{ defaultPageSize: 10 }}>
* <DataGrid.Table />
* <DataGrid.Pagination />
* </DataGrid>
*/
Pagination: typeof DataGridPagination;
/**
* @see 🏷️ {@link DataGridPaginationSummary.Props}
*/
PaginationSummary: typeof DataGridPaginationSummary;
/**
* @see 🏷️ {@link DataGridPaginationPageSize.Props}
*/
PaginationPageSize: typeof DataGridPaginationPageSize;
/**
* @see 🏷️ {@link DataGridPaginationNavigation.Props}
*/
PaginationNavigation: typeof DataGridPaginationNavigation;
}
/**
@@ -159,7 +102,6 @@ const DataGridInternal = forwardRef<HTMLDivElement, DataGridProps<any>>(
className,
columns,
data,
pagination,
getRowId,
selection,
isLoading = false,
@@ -187,19 +129,7 @@ const DataGridInternal = forwardRef<HTMLDivElement, DataGridProps<any>>(
tableSettings={resolvedSettings}
updateTableSettings={updateSettings}
>
{pagination ? (
<DataGridPaginationInternal
pagination={pagination}
rowCount={data.length}
isLoading={isLoading}
>
{children}
</DataGridPaginationInternal>
) : (
<DataGridRootPaginationContextProvider mode="none">
{children}
</DataGridRootPaginationContextProvider>
)}
{children}
</DataGridContextProvider>
</div>
);
@@ -209,10 +139,6 @@ const DataGridInternal = forwardRef<HTMLDivElement, DataGridProps<any>>(
const DataGrid = DataGridInternal as unknown as DataGridComponent;
DataGrid.Table = DataGridTable;
DataGrid.Preferences = DataGridPreferences;
DataGrid.Pagination = DataGridPagination;
DataGrid.PaginationSummary = DataGridPaginationSummary;
DataGrid.PaginationPageSize = DataGridPaginationPageSize;
DataGrid.PaginationNavigation = DataGridPaginationNavigation;
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace DataGrid {
@@ -238,30 +164,6 @@ export namespace DataGrid {
// biome-ignore lint/suspicious/noShadow: intentional namespace re-export
export type Props = DataGridPreferences.Props;
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Pagination {
// biome-ignore lint/suspicious/noShadow: intentional namespace re-export
export type Props = DataGridPagination.Props;
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace PaginationSummary {
// biome-ignore lint/suspicious/noShadow: intentional namespace re-export
export type Props = DataGridPaginationSummary.Props;
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace PaginationPageSize {
// biome-ignore lint/suspicious/noShadow: intentional namespace re-export
export type Props = DataGridPaginationPageSize.Props;
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace PaginationNavigation {
// biome-ignore lint/suspicious/noShadow: intentional namespace re-export
export type Props = DataGridPaginationNavigation.Props;
}
}
export { DataGrid, DataGridInternal }; // DataGridRoot needs to be exported b.c. of docgen
@@ -3,7 +3,6 @@ import React, { useState } from "react";
import { expect, fn, userEvent, within } from "storybook/test";
import { Button } from "../../button";
import { DataGrid } from "../../data-grid";
import { ResetPageOnSort } from "../../data-grid/pagination/DataGridPagination.stories";
import { HGrid } from "../../primitives/grid";
import { VStack } from "../../primitives/stack";
import { Tag } from "../../tag";
@@ -499,146 +498,6 @@ export const SelectionPagination: Story = {
},
};
type SelectionPageRow = {
id: string;
name: string;
children?: SelectionPageRow[];
};
const selectionPageRows: SelectionPageRow[] = [
{
id: "alpha",
name: "Alpha",
children: [
{
id: "alpha-child",
name: "Alpha child",
children: [{ id: "alpha-grandchild", name: "Alpha grandchild" }],
},
],
},
{ id: "beta", name: "Beta" },
{
id: "gamma",
name: "Gamma",
children: [
{
id: "gamma-child",
name: "Gamma child",
children: [{ id: "gamma-grandchild", name: "Gamma grandchild" }],
},
],
},
{ id: "delta", name: "Delta" },
];
const selectionPageColumnDef: ColumnDefinitions<SelectionPageRow> = [
{ id: "name", header: "Name", bodyCell: (row) => row.name },
];
export const TestSelectionAcrossPages: Story = {
render: () => {
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
return (
<>
<output aria-label="Selected row IDs">
{JSON.stringify([...selectedRowIds].sort())}
</output>
<DataGrid
columns={selectionPageColumnDef}
data={selectionPageRows}
getRowId={(row) => row.id}
selection={{
mode: "multiple",
onSelectedRowIdsChange: setSelectedRowIds,
}}
pagination={{ defaultPageSize: 2, pageSizeOptions: [2] }}
>
<DataGrid.Table<SelectionPageRow>
subRows={{ getRows: (row) => row.children ?? [] }}
/>
<DataGrid.Pagination />
</DataGrid>
</>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const selectedIds = canvas.getByRole("status", {
name: "Selected row IDs",
});
const firstPageIds = ["alpha", "alpha-child", "alpha-grandchild", "beta"];
const secondPageIds = ["delta", "gamma", "gamma-child", "gamma-grandchild"];
expect(canvas.queryByText("Alpha child")).not.toBeInTheDocument();
expect(canvas.queryByText("Alpha grandchild")).not.toBeInTheDocument();
await userEvent.click(
canvas.getByRole("checkbox", { name: "Velg alle synlige rader" }),
);
expect(selectedIds.textContent).toBe(JSON.stringify(firstPageIds));
await userEvent.click(canvas.getByRole("button", { name: "Neste side" }));
expect(canvas.getByText("Gamma")).toBeInTheDocument();
expect(canvas.queryByText("Alpha")).not.toBeInTheDocument();
expect(selectedIds.textContent).toBe(JSON.stringify(firstPageIds));
for (const checkbox of canvas.getAllByRole("checkbox")) {
expect(checkbox).not.toBeChecked();
}
await userEvent.click(
canvas.getByRole("checkbox", { name: "Velg alle synlige rader" }),
);
expect(selectedIds.textContent).toBe(
JSON.stringify([...firstPageIds, ...secondPageIds].sort()),
);
await userEvent.click(canvas.getByRole("button", { name: "Forrige side" }));
for (const checkbox of canvas.getAllByRole("checkbox")) {
expect(checkbox).toBeChecked();
}
await userEvent.click(
canvas.getByRole("checkbox", {
name: "Fjern alle synlige valgte rader",
}),
);
expect(selectedIds.textContent).toBe(JSON.stringify(secondPageIds));
for (const checkbox of canvas.getAllByRole("checkbox")) {
expect(checkbox).not.toBeChecked();
}
await userEvent.click(canvas.getByRole("button", { name: "Neste side" }));
for (const checkbox of canvas.getAllByRole("checkbox")) {
expect(checkbox).toBeChecked();
}
await userEvent.click(
canvas.getByRole("button", { name: "Vis under-rader" }),
);
expect(
within(canvas.getByRole("row", { name: /Gamma child/ })).getByRole(
"checkbox",
),
).toBeChecked();
await userEvent.click(
canvas.getByRole("button", { name: "Vis under-rader" }),
);
expect(
within(canvas.getByRole("row", { name: /Gamma grandchild/ })).getByRole(
"checkbox",
),
).toBeChecked();
await userEvent.click(
canvas.getByRole("checkbox", {
name: "Fjern alle synlige valgte rader",
}),
);
expect(selectedIds.textContent).toBe("[]");
for (const checkbox of canvas.getAllByRole("checkbox")) {
expect(checkbox).not.toBeChecked();
}
},
};
export const StickySelection: Story = {
render: () => {
return (
@@ -904,36 +763,6 @@ export const SortableColumnsUncontrolled: Story = {
},
};
export const TestControlledSortResetsPage: Story = {
render: ResetPageOnSort.render,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const navigation = within(
canvas.getByRole("navigation", { name: "Sidenavigering" }),
);
for (const direction of ["ascending", "descending", "none"]) {
await userEvent.click(canvas.getByRole("button", { name: "Neste side" }));
await userEvent.click(canvas.getByRole("button", { name: "Neste side" }));
expect(navigation.getByText("Side 3 av 14")).toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Id" }));
expect(navigation.getByText("Side 1 av 14")).toBeInTheDocument();
expect(canvas.getByRole("columnheader", { name: "Id" })).toHaveAttribute(
"aria-sort",
direction,
);
const firstRow = canvas.getAllByRole("row")[1];
expect(
within(firstRow).getByRole("cell", {
name: direction === "descending" ? "137" : "1",
}),
).toBeInTheDocument();
expect(canvas.getAllByRole("row")).toHaveLength(11);
}
},
};
const rowClickSpy = fn();
const rowClickColumnDef: ColumnDefinitions<UserDataTest> = [
@@ -332,11 +332,18 @@ export const Cache: Story = {
expectNodeFocus("Custom row");
await userEvent.click(toggleButton);
expect(toggleButton).toHaveFocus();
table.focus();
await down();
expectNodeFocus("Row 1");
await down();
expectNodeFocus("Row 3");
await userEvent.click(toggleButton);
table.focus();
await down();
expectNodeFocus("Row 3");
await up();
expectNodeFocus("Row 1");
expectNodeFocus("Custom row");
},
};
@@ -390,169 +397,6 @@ export const FocusElementInsideTable: Story = {
},
};
type Row = { id: number; name: string };
const rows: Row[] = Array.from({ length: 53 }, (_, index) => ({
id: index + 1,
name: `Person ${index + 1}`,
}));
const columns: DataGrid.Columns<Row> = [
{ id: "id", header: "Id", bodyCell: (row) => row.id },
{ id: "name", header: "Navn", bodyCell: (row) => row.name },
];
function FixedHeightGrid() {
return (
<div style={{ display: "flex", height: 400 }}>
<DataGrid
columns={columns}
data={rows}
getRowId={(row) => String(row.id)}
pagination={{ defaultPageSize: 20, pageSizeOptions: [10, 20, 50] }}
>
<DataGrid.Table aria-label="Personer" />
<DataGrid.Pagination />
</DataGrid>
</div>
);
}
/**
* When paginating, the number of rows on the last page may be less than the page size, and the keyboard navigation should correctly clamp to the available cells.
* This means:
* - If focus is on last row/cell (lets say 20)
* - We paginate to next page that only has 14 rows
* - The focus should clamp to the last available row
*/
export const KeyboardCellClampedOnShorterLastPage: Story = {
render: () => (
<>
<button type="button">Før tabellen</button>
<FixedHeightGrid />
</>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const table = canvas.getByRole("table", { name: "Personer" });
const lastPage = canvas.getByRole("button", { name: "Siste side" });
const beforeTable = canvas.getByRole("button", { name: "Før tabellen" });
await userEvent.click(beforeTable);
await userEvent.tab();
await userEvent.keyboard("{Control>}{End}{/Control}");
expect(canvas.getByRole("cell", { name: "Person 20" })).toHaveFocus();
await userEvent.click(lastPage);
expect(canvas.getByText("Side 3 av 3")).toBeInTheDocument();
expect(within(table).getAllByRole("row")).toHaveLength(14);
expect(lastPage).toHaveFocus();
await userEvent.click(beforeTable);
await userEvent.tab();
expect(canvas.getByRole("cell", { name: "Person 53" })).toHaveFocus();
await userEvent.keyboard("{ArrowUp}");
expect(canvas.getByRole("cell", { name: "Person 52" })).toHaveFocus();
await userEvent.keyboard("{ArrowLeft}");
expect(canvas.getByRole("cell", { name: "52" })).toHaveFocus();
},
};
function ServerLoadingGrid({
loadingContent,
}: {
loadingContent?: DataGrid.Table.LoadingContent;
}) {
const [page, setPage] = useState(0);
const [loadedPage, setLoadedPage] = useState(0);
const [isLoading, setIsLoading] = useState(false);
return (
<>
<button type="button">Før tabellen</button>
<DataGrid
columns={columns}
data={rows.slice(loadedPage * 20, (loadedPage + 1) * 20)}
getRowId={(row) => String(row.id)}
isLoading={isLoading}
pagination={{
mode: "server",
page,
onPageChange: (nextPage) => {
setPage(nextPage);
setIsLoading(true);
},
defaultPageSize: 20,
pageSizeOptions: [20],
totalRows: rows.length,
}}
>
<DataGrid.Table aria-label="Personer" loadingContent={loadingContent} />
<DataGrid.Pagination />
</DataGrid>
<button
type="button"
onClick={() => {
setLoadedPage(page);
setIsLoading(false);
}}
>
Complete request
</button>
</>
);
}
const testKeyboardCellThroughLoading: Story["play"] = async ({
canvasElement,
}) => {
const canvas = within(canvasElement);
const before = canvas.getByRole("button", { name: "Før tabellen" });
const complete = canvas.getByRole("button", { name: "Complete request" });
await userEvent.click(before);
await userEvent.tab();
await userEvent.keyboard("{Control>}{End}{/Control}");
expect(canvas.getByRole("cell", { name: "Person 20" })).toHaveFocus();
const next = canvas.getByRole("button", { name: "Neste side" });
await userEvent.click(next);
expect(next).toHaveFocus();
await userEvent.click(complete);
await userEvent.click(before);
await userEvent.tab();
expect(canvas.getByRole("cell", { name: "Person 40" })).toHaveFocus();
await userEvent.click(canvas.getByRole("button", { name: "Siste side" }));
await userEvent.click(complete);
await userEvent.click(before);
await userEvent.tab();
expect(canvas.getByRole("cell", { name: "Person 53" })).toHaveFocus();
await userEvent.keyboard("{ArrowUp}");
expect(canvas.getByRole("cell", { name: "Person 52" })).toHaveFocus();
};
export const KeyboardCellPreservedThroughSkeletonLoading: Story = {
render: () => <ServerLoadingGrid />,
play: testKeyboardCellThroughLoading,
};
export const KeyboardCellPreservedThroughContentLoading: Story = {
render: () => (
<ServerLoadingGrid
loadingContent={{ variant: "content", content: "Loading rows" }}
/>
),
play: testKeyboardCellThroughLoading,
};
export const KeyboardCellPreservedThroughOverlayLoading: Story = {
render: () => <ServerLoadingGrid loadingContent={{ variant: "overlay" }} />,
play: testKeyboardCellThroughLoading,
};
function keyboardUtils() {
return {
expectNodeFocus(text: string) {
@@ -84,7 +84,7 @@ const sortableData: SortableRow[] = [
export const AutoLayoutMinimal: StoryObj<{
table: DataGrid.Table.Props<MinimalRow>;
grid: Omit<DataGrid.Props<MinimalRow>, "children" | "pagination">;
grid: Omit<DataGrid.Props<MinimalRow>, "children">;
}> = {
render: (args) => {
return (
@@ -103,7 +103,7 @@ export const AutoLayoutMinimal: StoryObj<{
export const AutoLayoutOverflowX: StoryObj<{
table: DataGrid.Table.Props<PersonInfo>;
grid: Omit<DataGrid.Props<PersonInfo>, "children" | "pagination">;
grid: Omit<DataGrid.Props<PersonInfo>, "children">;
}> = {
render: (args) => {
return (
@@ -133,7 +133,7 @@ export const AutoLayoutOverflowX: StoryObj<{
export const AutoLayoutNoCellTruncation: StoryObj<{
table: DataGrid.Table.Props<PersonInfo>;
grid: Omit<DataGrid.Props<PersonInfo>, "children" | "pagination">;
grid: Omit<DataGrid.Props<PersonInfo>, "children">;
}> = {
render: (args) => {
return (
@@ -162,7 +162,7 @@ export const AutoLayoutNoCellTruncation: StoryObj<{
export const AutoLayoutSortable: StoryObj<{
table: DataGrid.Table.Props<SortableRow>;
grid: Omit<DataGrid.Props<SortableRow>, "children" | "pagination">;
grid: Omit<DataGrid.Props<SortableRow>, "children">;
}> = {
render: (args) => {
return (
@@ -201,7 +201,7 @@ export const AutoLayoutSortable: StoryObj<{
export const FixedLayoutMinimal: StoryObj<{
table: DataGrid.Table.Props<MinimalRow>;
grid: Omit<DataGrid.Props<MinimalRow>, "children" | "pagination">;
grid: Omit<DataGrid.Props<MinimalRow>, "children">;
}> = {
render: (args) => {
return (
@@ -218,7 +218,7 @@ export const FixedLayoutMinimal: StoryObj<{
export const FixedLayoutDynamicWidth: StoryObj<{
table: DataGrid.Table.Props<MinimalRow>;
grid: Omit<DataGrid.Props<MinimalRow>, "children" | "pagination">;
grid: Omit<DataGrid.Props<MinimalRow>, "children">;
}> = {
render: (args) => {
return (
@@ -1,12 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useValueAsRef } from "../../../utils/hooks";
import {
type GridCache,
buildTableGridMap,
findFirstCell,
findFirstCellInRow,
isCellFocusable,
} from "../helpers/table-grid-nav";
import { type GridCache, buildTableGridMap } from "../helpers/table-grid-nav";
/**
* Manages the table grid cache and observes DOM changes that require grid rebuilding.
@@ -19,32 +13,9 @@ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
dirty: true,
});
const [activeCell, setActiveCellState] = useState<Element | null>(null);
const [activeCell, setActiveCell] = useState<Element | null>(null);
const activeCellRef = useValueAsRef(activeCell);
const observerRef = useRef<MutationObserver | null>(null);
const loadingCellPositionRef = useRef<{ x: number; y: number } | undefined>(
undefined,
);
const getTableGrid = useCallback((_tableRef: HTMLTableElement) => {
if (gridCacheRef.current.dirty || !gridCacheRef.current.grid) {
gridCacheRef.current.grid = buildTableGridMap(_tableRef);
gridCacheRef.current.dirty = false;
}
return gridCacheRef.current.grid;
}, []);
const setActiveCell = useCallback(
(cell: Element | null) => {
if (tableRef && cell) {
// Retain the active cell's coordinates before its row is removed.
getTableGrid(tableRef);
}
setActiveCellState(cell);
},
[getTableGrid, tableRef],
);
useEffect(() => {
if (!tableRef || !enabled) {
@@ -54,36 +25,8 @@ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
observerRef.current = new MutationObserver(() => {
gridCacheRef.current.dirty = true;
const isLoading = tableRef.getAttribute("aria-busy") === "true";
const currentCell = activeCellRef.current;
const hasLoadingPosition = loadingCellPositionRef.current !== undefined;
const wasRemoved =
currentCell !== null && !tableRef.contains(currentCell);
// Restore saved coordinates even if loading left no active cell.
if (wasRemoved || (!isLoading && hasLoadingPosition)) {
const position =
loadingCellPositionRef.current ??
(currentCell
? gridCacheRef.current.grid?.positions.get(currentCell)
: undefined);
if (isLoading) {
// Keep the original coordinates across temporary loading rows.
loadingCellPositionRef.current = position;
}
const { grid } = getTableGrid(tableRef);
const rowIndex = Math.min(position?.y ?? 0, grid.length - 1);
const row = grid[rowIndex] ?? [];
const cell = row[Math.min(position?.x ?? 0, row.length - 1)];
const nextCell = isCellFocusable(cell)
? cell
: (findFirstCellInRow(grid, rowIndex) ?? findFirstCell(grid));
// Update the tracked cell without moving focus from external controls.
setActiveCell(nextCell);
}
if (!isLoading) {
loadingCellPositionRef.current = undefined;
if (activeCellRef.current && !activeCellRef.current.isConnected) {
setActiveCell(null);
}
});
@@ -91,7 +34,7 @@ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["colspan", "rowspan", "hidden", "style", "aria-busy"],
attributeFilter: ["colspan", "rowspan", "hidden", "style"],
});
return () => {
@@ -100,7 +43,7 @@ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
observerRef.current = null;
}
};
}, [tableRef, enabled, activeCellRef, getTableGrid, setActiveCell]);
}, [tableRef, enabled, activeCellRef]);
/**
* If keyboard-nav is re-enabled, mark grid as dirty since
@@ -112,6 +55,15 @@ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
}
}, [enabled]);
const getTableGrid = useCallback((_tableRef: HTMLTableElement) => {
if (gridCacheRef.current.dirty || !gridCacheRef.current.grid) {
gridCacheRef.current.grid = buildTableGridMap(_tableRef);
gridCacheRef.current.dirty = false;
}
return gridCacheRef.current.grid;
}, []);
return {
getTableGrid,
activeCell,
@@ -22,7 +22,6 @@ type UseTableKeyboardNavOptions = {
function useTableKeyboardNav({ enabled }: UseTableKeyboardNavOptions) {
const [tableRef, setTableRef] = useState<HTMLTableElement | null>(null);
const [isFocusWithin, setIsFocusWithin] = useState(false);
const { getTableGrid, activeCell, setActiveCell } = useGridCache(
tableRef,
enabled,
@@ -123,7 +122,6 @@ function useTableKeyboardNav({ enabled }: UseTableKeyboardNavOptions) {
* Make sure td/th elements have tabIndex={-1} so they can receive focus on click
*/
const handleTableFocusIn = useEventCallback((event: FocusEvent): void => {
setIsFocusWithin(true);
const target = event.target as Element | null;
if (tableRef && target === tableRef) {
@@ -148,15 +146,6 @@ function useTableKeyboardNav({ enabled }: UseTableKeyboardNavOptions) {
}
});
const handleTableFocusOut = useEventCallback((event: FocusEvent): void => {
if (
!(event.relatedTarget instanceof Node) ||
!tableRef?.contains(event.relatedTarget)
) {
setIsFocusWithin(false);
}
});
/**
* Attach event listeners for keyboard navigation and focus management.
*/
@@ -167,27 +156,16 @@ function useTableKeyboardNav({ enabled }: UseTableKeyboardNavOptions) {
tableRef.addEventListener("keydown", handleTableKeyDown);
tableRef.addEventListener("focusin", handleTableFocusIn);
tableRef.addEventListener("focusout", handleTableFocusOut);
return () => {
tableRef.removeEventListener("keydown", handleTableKeyDown);
tableRef.removeEventListener("focusin", handleTableFocusIn);
tableRef.removeEventListener("focusout", handleTableFocusOut);
};
}, [
tableRef,
handleTableKeyDown,
handleTableFocusIn,
handleTableFocusOut,
enabled,
]);
}, [tableRef, handleTableKeyDown, handleTableFocusIn, enabled]);
return {
/*
* Table should only have tabIndex until the focus is moved inside and is enabled
* This allows focus to "escape" the table and move to other elements on the page.
*/
tabIndex: enabled ? (isFocusWithin ? -1 : 0) : undefined,
/* Table should only have tabIndex until the focus is moved inside and is enabled */
tabIndex: enabled ? 0 : undefined,
setTableRef,
};
}
@@ -1,67 +0,0 @@
import { type RefObject, useMemo, useRef } from "react";
import {
useDataGridContext,
useDataGridRootPaginationContext,
} from "../../../data-grid/root/DataGridRoot.context";
import { useClientLayoutEffect } from "../../../utils-external";
import type { SortEntry } from "../root/DataGridTable.types";
function useTablePagination() {
const { data } = useDataGridContext();
const pagination = useDataGridRootPaginationContext(false);
const clientPagination =
pagination?.mode === "client" ? pagination : undefined;
const startIndex = clientPagination?.startIndex;
const endIndex = clientPagination?.endIndex;
const items = useMemo(
() => (startIndex === undefined ? data : data.slice(startIndex, endIndex)),
[data, startIndex, endIndex],
);
return items;
}
/**
* Resets client-side pagination to page 0 when the applied sort model changes. Server-side
* pagination leaves this to the consumer to avoid fetching the old page with the new sort.
* Scrolls the table's own container to top when the applied sort, page, or page size changes.
* Comparing applied sort state means rejected controlled sort requests do not reset.
*/
function useTablePaginationReset({
sortState,
scrollWrapperRef,
}: {
sortState: SortEntry[];
scrollWrapperRef: RefObject<HTMLElement | null>;
}) {
const pagination = useDataGridRootPaginationContext(false);
const page = pagination?.page;
const pageSize = pagination?.pageSize;
const setPage = pagination?.setPage;
const isClientMode = pagination?.mode === "client";
const sortKey = JSON.stringify(
sortState.map(({ columnId, direction }) => [columnId, direction]),
);
const previous = useRef({ sortKey, page, pageSize });
useClientLayoutEffect(() => {
const prev = previous.current;
previous.current = { sortKey, page, pageSize };
const sortChanged = prev.sortKey !== sortKey;
if (sortChanged && isClientMode) {
setPage?.(0);
}
if (sortChanged || prev.page !== page || prev.pageSize !== pageSize) {
if (scrollWrapperRef.current) {
scrollWrapperRef.current.scrollTop = 0;
}
}
}, [sortKey, page, pageSize, setPage, isClientMode, scrollWrapperRef]);
}
export { useTablePagination, useTablePaginationReset };
@@ -24,10 +24,6 @@ import {
} from "../hooks/useTableDetailsPanel";
import { type SubRowsProps, useTableItems } from "../hooks/useTableItems";
import { useTableKeyboardNav } from "../hooks/useTableKeyboardNav";
import {
useTablePagination,
useTablePaginationReset,
} from "../hooks/useTablePagination";
import { useTableSelection } from "../hooks/useTableSelection";
import { type TableSortOptions, useTableSort } from "../hooks/useTableSort";
import { DataTableLoadingState } from "../loading-state/DataTableLoadingState";
@@ -179,20 +175,19 @@ const DataGridTableInternal = forwardRef<
}: DataGridTableProps<unknown>,
forwardedRef,
) => {
const { columnDefinitions, getRowId, selection, isLoading, tableSettings } =
useDataGridContext();
const items = useTablePagination();
const {
columnDefinitions,
data,
getRowId,
selection,
isLoading,
tableSettings,
} = useDataGridContext();
const sortingState = useTableSort(sorting);
const scrollWrapperRef = useRef<HTMLDivElement>(null);
useTablePaginationReset({
sortState: sortingState.sortState,
scrollWrapperRef,
});
const tableItems = useTableItems({
items,
items: data,
getRowId,
subRows,
});
@@ -258,7 +253,6 @@ const DataGridTableInternal = forwardRef<
>
<TableElementWrapper
enabled={true}
scrollWrapperRef={scrollWrapperRef}
hasStickyColumns={
!!(
tableSettings?.stickyColumns?.start ||
@@ -340,17 +334,14 @@ function TableElementWrapper({
children,
enabled,
hasStickyColumns,
scrollWrapperRef,
}: {
children: React.ReactNode;
enabled: boolean;
hasStickyColumns: boolean;
scrollWrapperRef: React.Ref<HTMLDivElement>;
}) {
const [applyStickyStyles, setApplyStickyStyles] = useState<boolean>(false);
const tableWrapperRef = useRef<HTMLDivElement>(null);
const mergedWrapperRefs = useMergeRefs(tableWrapperRef, scrollWrapperRef);
const tableRef = useRef<HTMLTableElement>(null);
const rafRef = useRef<number | null>(null);
const { tabIndex, setTableRef } = useTableKeyboardNav({
@@ -422,7 +413,7 @@ function TableElementWrapper({
return (
<div className="aksel-data-table__border-wrapper">
<div ref={mergedWrapperRefs} className="aksel-data-table__scroll-wrapper">
<div ref={tableWrapperRef} className="aksel-data-table__scroll-wrapper">
<Slot
tabIndex={tabIndex}
/* @ts-expect-error Ref is not typed correctly to handle this case */
+149 -121
View File
@@ -1,4 +1,4 @@
import React from "react";
import React, { forwardRef } from "react";
import { Dialog } from "../../dialog";
import { BodyShort, ErrorMessage } from "../../typography";
import { cl } from "../../utils/helpers";
@@ -36,109 +36,114 @@ interface ComboboxProps<
name?: string;
}
function Combobox<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>({
label,
hideLabel,
name,
size: sizeProp,
error,
errorId: errorIdProp,
disabled: disabledProp,
description,
id,
readOnly: readOnlyProp,
multiple,
...rest
}: ComboboxProps<T>) {
const {
inputProps,
errorId,
showErrorMsg,
hasError,
size,
inputDescriptionId,
readOnly,
} = useFormField(
const Combobox = forwardRef(
<T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>>(
{
description,
disabled: disabledProp,
label,
hideLabel,
name,
size: sizeProp,
error,
errorId: errorIdProp,
disabled: disabledProp,
description,
id,
readOnly: readOnlyProp,
size: sizeProp,
},
"combobox",
);
multiple,
...rest
}: ComboboxProps<T>,
ref: React.ForwardedRef<HTMLButtonElement>,
) => {
const {
inputProps,
errorId,
showErrorMsg,
hasError,
size,
inputDescriptionId,
readOnly,
} = useFormField(
{
description,
disabled: disabledProp,
error,
errorId: errorIdProp,
id,
readOnly: readOnlyProp,
size: sizeProp,
},
"combobox",
);
return (
<ComboboxRoot
size={size}
disabled={inputProps.disabled}
multiple={multiple}
{...rest}
>
<ComboboxLabel
htmlFor={inputProps.id}
hide={hideLabel}
readOnly={readOnly}
return (
<ComboboxRoot
size={size}
disabled={inputProps.disabled}
multiple={multiple}
{...rest}
>
{label}
</ComboboxLabel>
{!!description && (
<BodyShort
className={cl(
"aksel-form-field__description aksel-combobox2__description",
{ "aksel-sr-only": hideLabel },
)}
id={inputDescriptionId}
size={size}
as="div"
<ComboboxLabel
htmlFor={inputProps.id}
hide={hideLabel}
readOnly={readOnly}
>
{description}
</BodyShort>
)}
<ComboboxTrigger readOnly={readOnly} {...inputProps}>
<ComboboxField hasError={hasError} />
</ComboboxTrigger>
<div
className="aksel-form-field__error aksel-combobox2__error"
id={errorId}
aria-relevant="additions removals"
aria-live="polite"
>
{showErrorMsg && (
<ErrorMessage size={size} showIcon>
{error}
</ErrorMessage>
)}
</div>
{name &&
rest.value.map((val) => (
<input key={val} type="hidden" name={name} value={val} />
))}
<ComboboxOverlay
mobileHeader={
<Dialog.Header>
<Dialog.Title>{label}</Dialog.Title>
{!!description && (
<Dialog.Description>{description}</Dialog.Description>
{label}
</ComboboxLabel>
{!!description && (
<BodyShort
className={cl(
"aksel-form-field__description aksel-combobox2__description",
{ "aksel-sr-only": hideLabel },
)}
</Dialog.Header>
}
>
<ComboboxPopup>
<ComboboxFilter />
<ComboboxList />
</ComboboxPopup>
</ComboboxOverlay>
</ComboboxRoot>
);
}
id={inputDescriptionId}
size={size}
as="div"
>
{description}
</BodyShort>
)}
<ComboboxTrigger readOnly={readOnly} {...inputProps}>
<ComboboxField ref={ref} hasError={hasError} />
</ComboboxTrigger>
<div
className="aksel-form-field__error aksel-combobox2__error"
id={errorId}
aria-relevant="additions removals"
aria-live="polite"
>
{showErrorMsg && (
<ErrorMessage size={size} showIcon>
{error}
</ErrorMessage>
)}
</div>
{name &&
rest.value.map((val) => (
<input key={val} type="hidden" name={name} value={val} />
))}
<ComboboxOverlay
mobileHeader={
<Dialog.Header>
<Dialog.Title>{label}</Dialog.Title>
{!!description && (
<Dialog.Description>{description}</Dialog.Description>
)}
</Dialog.Header>
}
>
<ComboboxPopup>
<ComboboxFilter />
<ComboboxList />
</ComboboxPopup>
</ComboboxOverlay>
</ComboboxRoot>
);
},
) as <T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>>(
props: ComboboxProps<T> & React.RefAttributes<HTMLButtonElement>,
) => React.ReactElement;
// ----------- ComboboxSingle ----------- //
@@ -149,44 +154,67 @@ interface ComboboxSingleProps<
* Called when selected option changes.
*/
onChange?: (
newValue: NonNullable<ComboboxProps<T>["value"]>[number] | undefined,
newValue: NonNullable<ComboboxProps<T>["value"]>[number] | null,
) => void;
/**
* Value of the selected option.
*/
value?: NonNullable<ComboboxProps<T>["value"]>[number];
value?: NonNullable<ComboboxProps<T>["value"]>[number] | null;
/**
* 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,
/**
* Component for selecting a value from a list of options that can be filtered.
*
* **WARNING: This component is in active development and may receive breaking changes outside major releases!**
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/TODO)
* @see 🏷️ {@link ComboboxSingleProps}
*
* @example
* ```jsx
* <ComboboxSingle
* label="Select country"
* name="country"
* options={countries}
* />
* ```
*/
const ComboboxSingle = forwardRef(
<T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>>(
{ onChange, value, defaultValue, ...rest }: ComboboxSingleProps<T>,
ref: React.ForwardedRef<HTMLButtonElement>,
) => {
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...
const [localValue, setLocalValue] = useControllableState({
defaultValue,
value,
onChange,
});
return (
<Combobox
{...rest}
value={localValue ? [localValue] : []}
defaultValue={defaultValue ? [defaultValue] : []}
multiple={false}
onToggleOption={onToggleOption}
/>
);
}
const onToggleOption: ComboboxProps<T>["onToggleOption"] = (
option,
isSelected,
) => {
setLocalValue(isSelected ? option.value : null);
}; // 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}
ref={ref}
value={localValue ? [localValue] : []}
defaultValue={defaultValue ? [defaultValue] : []}
multiple={false}
onToggleOption={onToggleOption}
/>
);
},
) as <T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>>(
props: ComboboxSingleProps<T> & React.RefAttributes<HTMLButtonElement>,
) => React.ReactElement;
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace ComboboxSingle {
@@ -157,7 +157,7 @@ export const Name = () => (
onSubmit={(event) => {
event.preventDefault();
const formData = new FormData(event.target);
const land = formData.getAll("land");
const land = formData.get("land");
console.log(land);
}}
>
@@ -168,7 +168,7 @@ export const Name = () => (
);
export const Controlled = () => {
const [value, setValue] = useState<MyOption["value"] | undefined>("opt-1");
const [value, setValue] = useState<MyOption["value"] | null>("opt-1");
return (
<VStack gap="space-8" minWidth="250px">
@@ -180,7 +180,7 @@ export const Controlled = () => {
/>
Selected: {value}
<div>
<button type="button" onClick={() => setValue(undefined)}>
<button type="button" onClick={() => setValue(null)}>
Clear
</button>
</div>
@@ -1,5 +1,5 @@
import type { Meta } from "@storybook/react-vite";
import React, { useState } from "react";
import React, { useRef, useState } from "react";
import { expect, userEvent, within } from "storybook/test";
import { ComboboxSingle } from "./Combobox";
import { ComboboxFilter } from "./filter/ComboboxFilter";
@@ -60,7 +60,7 @@ const groupedOptions = [
function BaseCombobox<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>(props: Partial<ComboboxSingle.Props<T>>) {
const [value, setValue] = useState<string | undefined>("option-1");
const [value, setValue] = useState<string | null>("opt-1");
return (
<ComboboxSingle
@@ -81,6 +81,27 @@ AccessibleNameAndDesc.play = async ({ canvasElement }) => {
await expect(trigger).toHaveAccessibleDescription("Landet hvor du er født.");
};
export const FocusWithRef = () => {
const ref = useRef<HTMLButtonElement>(null);
return (
<div>
<button type="button" onClick={() => ref.current?.focus()}>
Fokuser combobox
</button>
<ComboboxSingle ref={ref} options={countries} label="Velg land" />
</div>
);
};
FocusWithRef.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const combobox = await canvas.findByRole("combobox", { name: "Velg land" });
await expect(combobox).not.toHaveFocus();
await userEvent.click(
canvas.getByRole("button", { name: "Fokuser combobox" }),
);
await expect(combobox).toHaveFocus();
};
let optionMemoTestRenderCnt = 0;
const optionMemoTestRenderFn = (
optOrGroup: (typeof groupedOptions)[number],
@@ -93,7 +114,7 @@ const optionMemoTestRenderFn = (
return optOrGroup.label;
};
export const OptionMemoization = () => {
const [value, setValue] = useState(["option-1"]);
const [value, setValue] = useState(["opt-1"]);
return (
<div style={{ minHeight: "450px" }}>
@@ -1,6 +1,7 @@
import React, { forwardRef } from "react";
import React, { forwardRef, useEffect, useState } from "react";
import { ChevronDownIcon } from "@navikt/aksel-icons";
import { cl } from "../../../utils/helpers";
import { useI18n } from "../../../utils/i18n/i18n.hooks";
import {
type ComboboxRootProps,
useComboboxRootContext,
@@ -14,10 +15,23 @@ interface ComboboxFieldProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
const ComboboxField = forwardRef<HTMLButtonElement, ComboboxFieldProps>(
({ children, hasError, ...rest }, ref) => {
const { options, value, size } = useComboboxRootContext();
const translate = useI18n("Combobox2");
const labels = getLabels(value, options);
const label =
children ?? (labels.length > 1 ? `${labels.length} valgt` : labels[0]);
children ??
(labels.length > 1
? translate("selectedCount", { count: labels.length })
: labels[0]);
// Workaround for issue where VO on Safari announces the old label when focusing this button right after selecting a new option
const [ariaLabel, setAriaLabel] = useState<string>();
useEffect(() => {
if (label) {
setAriaLabel(label);
setTimeout(() => setAriaLabel(undefined), 100);
}
}, [label]);
return (
<div className="aksel-combobox2__field-container">
@@ -30,8 +44,7 @@ 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
aria-label={ariaLabel}
{...rest}
>
{label}
@@ -42,6 +42,7 @@ export const ComboboxFilter = (props: ComboboxFilterProps) => {
}
}}
aria-autocomplete="list"
aria-expanded="true"
inputMode="text"
/>
</Listbox.InputSlot>
@@ -55,7 +55,7 @@ function ComboboxListContent<
useComboboxRootContext();
const memoizedOnToggleOption = useEventCallback(onToggleOption);
const deferredFilterString = useDeferredValue(filterString);
const maybeAnnouceHits = useHitsAnnouncer(deferredFilterString, filterString);
const maybeAnnouceHits = useHitsAnnouncer(deferredFilterString);
const translate = useI18n("Combobox2");
const localOnToggleOption = useCallback(
@@ -80,6 +80,7 @@ function ComboboxListContent<
maybeAnnouceHits(filteredOptions);
if (filteredOptions.length === 0) {
// TODO: Consider moving outside of role=listbox
return (
<div className="aksel-combobox2__no-results">{translate("noHits")}</div>
);
@@ -18,9 +18,9 @@ function getNumberOfOptions<T extends ComboboxOptionData>(
}, 0);
}
function useHitsAnnouncer(deferredFilterString: string, filterString: string) {
function useHitsAnnouncer(deferredFilterString: string) {
const translate = useI18n("Combobox2");
const shouldAnnounceRef = useRef(false);
const oldFilterStringRef = useRef(deferredFilterString);
const announce = useAnnounce();
const announceHits = useMemo(() => {
@@ -50,20 +50,20 @@ function useHitsAnnouncer(deferredFilterString: string, filterString: string) {
ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>
)[],
) => {
// The options are filtered based on deferredFilterString, so we need to wait for it to settle.
if (deferredFilterString !== filterString) {
shouldAnnounceRef.current = true;
} else if (shouldAnnounceRef.current) {
if (filteredOptions.length === 0) {
announceHits.clear();
announceNoHits();
} else {
const hits = getNumberOfOptions(filteredOptions);
announceNoHits.clear();
announceHits(hits);
}
shouldAnnounceRef.current = false;
// Announce number of hits when filter string changes.
// We can't do this in a useEffect b.c. of how ComboboxList is structured. Consider refactoring.
if (deferredFilterString === oldFilterStringRef.current) {
return;
}
if (filteredOptions.length === 0) {
announceHits.clear();
announceNoHits();
} else {
const hits = getNumberOfOptions(filteredOptions);
announceNoHits.clear();
announceHits(hits);
}
oldFilterStringRef.current = deferredFilterString;
};
}
@@ -3,6 +3,7 @@ import { CheckmarkHeavyIcon } from "@navikt/aksel-icons";
import { Listbox } from "../../../utils/components/Listbox/root/ListboxRoot";
import type { ComboboxListProps } from "../list/ComboboxList";
import type { ComboboxOptionData } from "../root/ComboboxRoot";
import { highlightSubstring } from "./highlightSubstring";
export interface ComboboxOptionProps<T extends ComboboxOptionData> {
option: T;
@@ -49,31 +50,6 @@ function ComboboxOptionComponent<T extends ComboboxOptionData>({
);
}
const highlightSubstring = (text: string, substring: string) => {
const indexOfHighlightedText = text
.toLocaleLowerCase()
.indexOf(substring.toLocaleLowerCase());
if (indexOfHighlightedText === -1) {
// This can happen if the consumer has implemented their own filtering logic
return text;
}
const start = text.substring(0, indexOfHighlightedText);
const highlight = text.substring(
indexOfHighlightedText,
indexOfHighlightedText + substring.length,
);
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>}
{end}
</span>
);
};
export const ComboboxOption = React.memo(
ComboboxOptionComponent,
) as typeof ComboboxOptionComponent;
@@ -0,0 +1,27 @@
import { describe, expect, test } from "vitest";
import { getHighlightRange } from "./highlightSubstring";
const highlighted = (text: string, substring: string) => {
const range = getHighlightRange(text, substring);
return range && text.substring(...range);
};
describe("getHighlightRange", () => {
test("matches case-insensitively", () => {
expect(getHighlightRange("Norge", "OR")).toEqual([1, 3]);
});
test("returns null when there is no match", () => {
expect(getHighlightRange("Norge", "x")).toBeUndefined();
});
test("handles characters that get longer when lowercased", () => {
expect(highlighted("İstanbul", "an")).toBe("an");
expect(highlighted("İzmir og Antalya", "an")).toBe("An");
expect(highlighted("İzmir", "İz")).toBe("İz");
});
test("includes the whole original character when only part of its lowercase form matches", () => {
expect(highlighted("İstanbul", "i")).toBe("İ");
});
});
@@ -0,0 +1,50 @@
import React from "react";
function highlightSubstring(text: string, substring: string) {
const range = getHighlightRange(text, substring);
if (!range) {
// This can happen if the consumer has implemented their own filtering logic
return text;
}
const [rangeStart, rangeEnd] = range;
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 it doesn't work in normal browsers
<span aria-label={text}>
{text.substring(0, rangeStart)}
<mark>{text.substring(rangeStart, rangeEnd)}</mark>
{text.substring(rangeEnd)}
</span>
);
}
/**
* Finds the first case-insensitive occurrence of `substring` in `text`.
* Handles characters whose lowercase form has a different length than the original (e.g. "İ").
* @returns The `[start, end]` range of the match in `text`, or `undefined` if there is no match.
*/
function getHighlightRange(text: string, substring: string) {
const lowerSubstring = substring.toLocaleLowerCase();
const matchIndex = text.toLocaleLowerCase().indexOf(lowerSubstring);
if (matchIndex === -1) {
return;
}
const matchEnd = matchIndex + lowerSubstring.length;
let start = 0;
let origPos = 0;
let lowerPos = 0;
for (const char of text) {
if (lowerPos <= matchIndex) {
start = origPos;
}
origPos += char.length;
lowerPos += char.toLocaleLowerCase().length;
if (lowerPos >= matchEnd) {
return [start, origPos] as const;
}
}
}
export { highlightSubstring, getHighlightRange };
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useLayoutEffect, useRef, useState } from "react";
import { Listbox } from "../../../utils/components/Listbox/root/ListboxRoot";
import { createStrictContext } from "../../../utils/helpers";
import { useComboboxRootContext } from "../root/ComboboxRoot";
@@ -29,7 +29,7 @@ export const ComboboxPopup = ({ children }: ComboboxPopupProps) => {
const rootContext = useComboboxRootContext();
const ref = useRef<HTMLDivElement | null>(null);
useEffect(
useLayoutEffect(
function onOpen() {
const canHover = window.matchMedia("(hover: hover)").matches;
@@ -39,4 +39,12 @@ function ListboxOptions({ children, ...rest }: ListboxOptionsProps) {
);
}
/**
* TODO: Things that should be fixed before we allow focus directly
* on the listbox (i.e. when there's no filter input/combobox elm.)
* - Set aria-activedescendant
* - Consider aria-multiselectable
* - Probably aria-label(ledby)?
*/
export { ListboxOptions };
@@ -1,25 +1,12 @@
/** Defaults for internal components, independent of the public locales. Cant be overridden by users. */
const internalTranslations = {
DataGrid: {
Pagination: {
summary: "Rad {from}–{to} av {totalRows}",
summaryUnknownTotal: "Rad {from}–{to}",
summaryEmpty: "Ingen rader",
pageSize: "Rader per side",
pageOf: "Side {page} av {pageCount}",
pageOfUnknownTotal: "Side {page}",
first: "Første side",
previous: "Forrige side",
next: "Neste side",
last: "Siste side",
navLabel: "Sidenavigering",
},
},
TEST_DATA: {
label: "Internal grid",
selected: "{count} rows selected",
},
Combobox2: {
/** The "value" that is shown when multiple options are selected. */
selectedCount: "{count} valgt",
/** Label for filter input. Only visible to screen readers. */
filter: "Filtrer",
noHits: "Ingen treff",
+2
View File
@@ -1,5 +1,7 @@
# @navikt/ds-tailwind
## 8.21.0
## 8.20.1
## 8.20.0
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tailwind",
"version": "8.20.1",
"version": "8.21.0",
"description": "Tailwind config based on @navikt/ds-tokens",
"author": "Aksel | Nav designsystem team",
"keywords": [
@@ -28,7 +28,7 @@
},
"homepage": "https://aksel.nav.no/grunnleggende/kode/tailwind/",
"devDependencies": {
"@navikt/ds-tokens": "^8.20.1",
"@navikt/ds-tokens": "^8.21.0",
"@types/node": "^24.10.9",
"tailwindcss": "^3.3.3",
"tsx": "^4.23.15",
+2
View File
@@ -1,5 +1,7 @@
# @navikt/ds-tokens
## 8.21.0
## 8.20.1
## 8.20.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tokens",
"version": "8.20.1",
"version": "8.21.0",
"description": "Design-tokens for Nav designsystem",
"author": "Aksel | Nav designsystem team",
"keywords": [
+20
View File
@@ -1,5 +1,25 @@
# Changelog
## 8.21.0
### @navikt/aksel-icons
- Icons: Add 46 icons for text editing, tables, keyboard input, and filtering. ([#5458](https://github.com/navikt/aksel/pull/5458))
### @navikt/ds-css
- DataGrid: Add top and bottom content areas outside the table's scrollable viewport. ([#5442](https://github.com/navikt/aksel/pull/5442))
* DataTable: Constrain details panel width to the visible table area. ([#5433](https://github.com/navikt/aksel/pull/5433))
### @navikt/ds-react
- DataGrid: Add top and bottom content areas outside the table's scrollable viewport. ([#5442](https://github.com/navikt/aksel/pull/5442))
* DatePicker: Preserve keyboard focus when controlled month changes replace the focused day. ([#5408](https://github.com/navikt/aksel/pull/5408))
* Dialog, Lookup: Avoid setting aria-hidden on elements with role=alert/status when open ([#5466](https://github.com/navikt/aksel/pull/5466))
## 8.20.1
### @navikt/ds-css
+6 -6
View File
@@ -11,11 +11,11 @@
"sanity:update": "yarn workspace website update:sanity"
},
"dependencies": {
"@navikt/aksel": "^8.20.1",
"@navikt/aksel-icons": "^8.20.1",
"@navikt/ds-css": "^8.20.1",
"@navikt/ds-react": "^8.20.1",
"@navikt/ds-tailwind": "^8.20.1",
"@navikt/ds-tokens": "^8.20.1"
"@navikt/aksel": "^8.21.0",
"@navikt/aksel-icons": "^8.21.0",
"@navikt/ds-css": "^8.21.0",
"@navikt/ds-react": "^8.21.0",
"@navikt/ds-tailwind": "^8.21.0",
"@navikt/ds-tokens": "^8.21.0"
}
}
@@ -1,27 +1,33 @@
import Fuse from "fuse.js";
import { type TokenDocT, tokens } from "@navikt/ds-tokens/token_docs";
function tokenIndex(_tokens: TokenDocT[]) {
return new Fuse(_tokens, {
threshold: 0.25,
keys: [
{ name: "name", weight: 0.9 },
{ name: "group", weight: 0.3 },
{ name: "category", weight: 0.7 },
{ name: "categoryTitle", weight: 0.7 },
{ name: "comment", weight: 0.3 },
{ name: "type", weight: 0.7 },
{ name: "role", weight: 0.7 },
{ name: "modifier", weight: 0.7 },
],
//shouldSort: false,
ignoreLocation: true,
});
let tokenSearchIndex: Fuse<TokenDocT> | undefined;
function getTokenSearchIndex() {
if (!tokenSearchIndex) {
tokenSearchIndex = new Fuse(tokens, {
threshold: 0.25,
keys: [
{ name: "name", weight: 0.9 },
{ name: "group", weight: 0.3 },
{ name: "category", weight: 0.7 },
{ name: "categoryTitle", weight: 0.7 },
{ name: "comment", weight: 0.3 },
{ name: "type", weight: 0.7 },
{ name: "role", weight: 0.7 },
{ name: "modifier", weight: 0.7 },
],
//shouldSort: false,
ignoreLocation: true,
});
}
return tokenSearchIndex;
}
function searchTokens(query: string): TokenDocT[] {
return query
? tokenIndex(tokens)
? getTokenSearchIndex()
.search(query)
.map((t) => t.item)
: tokens;
@@ -36,32 +36,41 @@ async function globalSearch(
}
const fuse = await getSearchIndex();
const fuseResults = fuse
.search(query)
.filter((x) => x.score !== undefined && x.score < 0.3)
.sort((a, b) => Number(isOverrideHit(b)) - Number(isOverrideHit(a)));
const overrideResults: FuseResult<SearchPageT>[] = [];
const regularResults: FuseResult<SearchPageT>[] = [];
let totalHits = 0;
const topResults =
fuseResults.length > MAX_TOP_RESULTS
? fuseResults
.filter((x) => x.score !== undefined && x.score < 0.1)
.slice(0, MAX_TOP_RESULTS)
: [];
const topResultSet = new Set(topResults);
const groups = new Map<SearchResultPageTypesT, SearchHitGroupT>();
for (const result of fuseResults) {
if (topResultSet.has(result)) {
for (const result of fuse.search(query)) {
if (result.score === undefined || result.score >= 0.3) {
continue;
}
const type = result.item._type;
const group = groups.get(type) ?? { type, total: 0, hits: [] };
groups.set(type, group);
totalHits++;
(isOverrideHit(result) ? overrideResults : regularResults).push(result);
}
group.total++;
if (group.hits.length < MAX_HITS_PER_TYPE) {
group.hits.push(toSearchHit(result));
const groups = new Map<SearchResultPageTypesT, SearchHitGroupT>();
const topResults: SearchHitT[] = [];
for (const rankedResults of [overrideResults, regularResults]) {
for (const result of rankedResults) {
if (
totalHits > MAX_TOP_RESULTS &&
topResults.length < MAX_TOP_RESULTS &&
result.score !== undefined &&
result.score < 0.1
) {
topResults.push(toSearchHit(result));
continue;
}
const type = result.item._type;
const group = groups.get(type) ?? { type, total: 0, hits: [] };
groups.set(type, group);
group.total++;
if (group.hits.length < MAX_HITS_PER_TYPE) {
group.hits.push(toSearchHit(result));
}
}
}
@@ -72,8 +81,8 @@ async function globalSearch(
return {
result: {
totalHits: fuseResults.length,
topResults: topResults.map(toSearchHit),
totalHits,
topResults,
groupedHits,
},
query,
+3 -3
View File
@@ -11,9 +11,9 @@
"preview": "vite preview"
},
"dependencies": {
"@navikt/aksel-icons": "^8.20.1",
"@navikt/ds-css": "^8.20.1",
"@navikt/ds-react": "^8.20.1",
"@navikt/aksel-icons": "^8.21.0",
"@navikt/ds-css": "^8.21.0",
"@navikt/ds-react": "^8.21.0",
"fuse.js": "7.4.2",
"react": "19.3.1",
"react-dom": "19.3.1"
+3 -3
View File
@@ -19,9 +19,9 @@
"devDependencies": {
"@modelcontextprotocol/inspector": "^2.11.0",
"@modelcontextprotocol/sdk": "^1.32.0",
"@navikt/aksel": "^8.20.1",
"@navikt/aksel-icons": "^8.20.1",
"@navikt/ds-tokens": "^8.20.1",
"@navikt/aksel": "^8.21.0",
"@navikt/aksel-icons": "^8.21.0",
"@navikt/ds-tokens": "^8.21.0",
"@sanity/client": "7.27.0",
"@types/express": "^5.0.3",
"@types/node": "^24.10.9",
+3 -3
View File
@@ -20,9 +20,9 @@
"typescript": "6.0.3"
},
"dependencies": {
"@navikt/aksel-icons": "^8.20.1",
"@navikt/ds-css": "^8.20.1",
"@navikt/ds-react": "^8.20.1",
"@navikt/aksel-icons": "^8.21.0",
"@navikt/ds-css": "^8.21.0",
"@navikt/ds-react": "^8.21.0",
"react": "19.3.1"
}
}
+39 -39
View File
@@ -4880,7 +4880,7 @@ __metadata:
languageName: node
linkType: hard
"@navikt/aksel-icons@npm:^8.20.1, @navikt/aksel-icons@workspace:@navikt/aksel-icons":
"@navikt/aksel-icons@npm:^8.21.0, @navikt/aksel-icons@workspace:@navikt/aksel-icons":
version: 0.0.0-use.local
resolution: "@navikt/aksel-icons@workspace:@navikt/aksel-icons"
dependencies:
@@ -4911,8 +4911,8 @@ __metadata:
version: 0.0.0-use.local
resolution: "@navikt/aksel-stylelint@workspace:@navikt/aksel-stylelint"
dependencies:
"@navikt/ds-css": "npm:^8.20.1"
"@navikt/ds-tokens": "npm:^8.20.1"
"@navikt/ds-css": "npm:^8.21.0"
"@navikt/ds-tokens": "npm:^8.21.0"
concurrently: "npm:10.0.6"
postcss-selector-parser: "npm:^7.1.7"
postcss-value-parser: "npm:^4.2.0"
@@ -4923,7 +4923,7 @@ __metadata:
languageName: unknown
linkType: soft
"@navikt/aksel@npm:^8.20.1, @navikt/aksel@workspace:@navikt/aksel":
"@navikt/aksel@npm:^8.21.0, @navikt/aksel@workspace:@navikt/aksel":
version: 0.0.0-use.local
resolution: "@navikt/aksel@workspace:@navikt/aksel"
dependencies:
@@ -4956,11 +4956,11 @@ __metadata:
languageName: node
linkType: hard
"@navikt/ds-css@npm:^8.20.1, @navikt/ds-css@workspace:@navikt/core/css":
"@navikt/ds-css@npm:^8.21.0, @navikt/ds-css@workspace:@navikt/core/css":
version: 0.0.0-use.local
resolution: "@navikt/ds-css@workspace:@navikt/core/css"
dependencies:
"@navikt/ds-tokens": "npm:^8.20.1"
"@navikt/ds-tokens": "npm:^8.21.0"
browserslist: "npm:^4.28.7"
esbuild: "npm:^0.28.2"
fast-glob: "npm:3.3.3"
@@ -4969,14 +4969,14 @@ __metadata:
languageName: unknown
linkType: soft
"@navikt/ds-react@npm:^8.20.1, @navikt/ds-react@workspace:@navikt/core/react":
"@navikt/ds-react@npm:^8.21.0, @navikt/ds-react@workspace:@navikt/core/react":
version: 0.0.0-use.local
resolution: "@navikt/ds-react@workspace:@navikt/core/react"
dependencies:
"@floating-ui/react": "npm:0.27.20"
"@floating-ui/react-dom": "npm:^2.1.9"
"@navikt/aksel-icons": "npm:^8.20.1"
"@navikt/ds-tokens": "npm:^8.20.1"
"@navikt/aksel-icons": "npm:^8.21.0"
"@navikt/ds-tokens": "npm:^8.21.0"
"@testing-library/dom": "npm:10.4.2"
"@testing-library/jest-dom": "npm:^7.0.1"
"@testing-library/react": "npm:^16.3.3"
@@ -5010,11 +5010,11 @@ __metadata:
languageName: unknown
linkType: soft
"@navikt/ds-tailwind@npm:^8.20.1, @navikt/ds-tailwind@workspace:@navikt/core/tailwind":
"@navikt/ds-tailwind@npm:^8.21.0, @navikt/ds-tailwind@workspace:@navikt/core/tailwind":
version: 0.0.0-use.local
resolution: "@navikt/ds-tailwind@workspace:@navikt/core/tailwind"
dependencies:
"@navikt/ds-tokens": "npm:^8.20.1"
"@navikt/ds-tokens": "npm:^8.21.0"
"@types/node": "npm:^24.10.9"
tailwindcss: "npm:^3.3.3"
tsx: "npm:^4.23.15"
@@ -5023,7 +5023,7 @@ __metadata:
languageName: unknown
linkType: soft
"@navikt/ds-tokens@npm:^8.20.1, @navikt/ds-tokens@workspace:@navikt/core/tokens":
"@navikt/ds-tokens@npm:^8.21.0, @navikt/ds-tokens@workspace:@navikt/core/tokens":
version: 0.0.0-use.local
resolution: "@navikt/ds-tokens@workspace:@navikt/core/tokens"
dependencies:
@@ -9761,9 +9761,9 @@ __metadata:
resolution: "aksel-icons-figma-plugin@workspace:apps/figma-icon-plugin"
dependencies:
"@figma/plugin-typings": "npm:^1.131.0"
"@navikt/aksel-icons": "npm:^8.20.1"
"@navikt/ds-css": "npm:^8.20.1"
"@navikt/ds-react": "npm:^8.20.1"
"@navikt/aksel-icons": "npm:^8.21.0"
"@navikt/ds-css": "npm:^8.21.0"
"@navikt/ds-react": "npm:^8.21.0"
"@rollup/pluginutils": "npm:^5.4.0"
"@svgr/core": "npm:^8.1.0"
"@types/node": "npm:^24.10.9"
@@ -9785,9 +9785,9 @@ __metadata:
dependencies:
"@modelcontextprotocol/inspector": "npm:^2.11.0"
"@modelcontextprotocol/sdk": "npm:^1.32.0"
"@navikt/aksel": "npm:^8.20.1"
"@navikt/aksel-icons": "npm:^8.20.1"
"@navikt/ds-tokens": "npm:^8.20.1"
"@navikt/aksel": "npm:^8.21.0"
"@navikt/aksel-icons": "npm:^8.21.0"
"@navikt/ds-tokens": "npm:^8.21.0"
"@sanity/client": "npm:7.27.0"
"@types/express": "npm:^5.0.3"
"@types/node": "npm:^24.10.9"
@@ -9809,9 +9809,9 @@ __metadata:
"@babel/preset-env": "npm:^7.29.0"
"@babel/preset-react": "npm:^7.28.5"
"@babel/preset-typescript": "npm:^7.28.5"
"@navikt/aksel-icons": "npm:^8.20.1"
"@navikt/ds-css": "npm:^8.20.1"
"@navikt/ds-react": "npm:^8.20.1"
"@navikt/aksel-icons": "npm:^8.21.0"
"@navikt/ds-css": "npm:^8.21.0"
"@navikt/ds-react": "npm:^8.21.0"
"@types/node": "npm:^24.10.9"
babel-loader: "npm:^10.1.1"
playroom: "npm:^1.4.0"
@@ -9873,12 +9873,12 @@ __metadata:
version: 0.0.0-use.local
resolution: "aksel.nav.no@workspace:aksel.nav.no"
dependencies:
"@navikt/aksel": "npm:^8.20.1"
"@navikt/aksel-icons": "npm:^8.20.1"
"@navikt/ds-css": "npm:^8.20.1"
"@navikt/ds-react": "npm:^8.20.1"
"@navikt/ds-tailwind": "npm:^8.20.1"
"@navikt/ds-tokens": "npm:^8.20.1"
"@navikt/aksel": "npm:^8.21.0"
"@navikt/aksel-icons": "npm:^8.21.0"
"@navikt/ds-css": "npm:^8.21.0"
"@navikt/ds-react": "npm:^8.21.0"
"@navikt/ds-tailwind": "npm:^8.21.0"
"@navikt/ds-tokens": "npm:^8.21.0"
languageName: unknown
linkType: soft
@@ -10696,25 +10696,25 @@ __metadata:
linkType: hard
"body-parser@npm:^2.2.1":
version: 2.3.0
resolution: "body-parser@npm:2.3.0"
version: 2.4.0
resolution: "body-parser@npm:2.4.0"
dependencies:
bytes: "npm:^3.1.2"
content-type: "npm:^2.0.0"
content-type: "npm:^2.1.0"
debug: "npm:^4.4.3"
http-errors: "npm:^2.0.1"
iconv-lite: "npm:^0.7.2"
iconv-lite: "npm:^0.7.3"
on-finished: "npm:^2.4.1"
qs: "npm:^6.15.2"
qs: "npm:^6.16.1"
raw-body: "npm:^3.0.2"
type-is: "npm:^2.1.0"
checksum: 10/94f741ff525358dd0d47f0a042936dc61b9d2e348ff1228c5ffd79f1902c673a317bbb9495b3f7d1db4483befe6ffed1c42bc4cdd8da8f41690530810cfe4dad
checksum: 10/e34971abf7051529d4fae37d3714382e7fa8198bc969f1ec4932af48aef53bba71523545c034ce04fa808d3b15e0d63849e6e93b4f1131d65227e1f414437f68
languageName: node
linkType: hard
"body-parser@npm:~1.20.5":
version: 1.20.8
resolution: "body-parser@npm:1.20.8"
version: 1.20.9
resolution: "body-parser@npm:1.20.9"
dependencies:
bytes: "npm:~3.1.2"
content-type: "npm:~1.0.5"
@@ -10728,7 +10728,7 @@ __metadata:
raw-body: "npm:~2.5.3"
type-is: "npm:~1.6.18"
unpipe: "npm:~1.0.0"
checksum: 10/e5a13ff68066a49823403f93ffe056611d9696182e6de194cd1a3c5b2159457494d4133b4666bad591d7a5a6eb924dd34dd15c83aa2d11c63800ed4d2395b5eb
checksum: 10/e9ccf7a920205d674a83cdfae0e83ed3cfe32af668706034e9832a8bf3b80f06fbc18a7c26759611f72d334311683a4b1ff73661690038a352e8c3b4a424b709
languageName: node
linkType: hard
@@ -15440,7 +15440,7 @@ __metadata:
languageName: node
linkType: hard
"iconv-lite@npm:^0.7.0, iconv-lite@npm:^0.7.2, iconv-lite@npm:~0.7.0":
"iconv-lite@npm:^0.7.0, iconv-lite@npm:^0.7.2, iconv-lite@npm:^0.7.3, iconv-lite@npm:~0.7.0":
version: 0.7.3
resolution: "iconv-lite@npm:0.7.3"
dependencies:
@@ -20419,7 +20419,7 @@ __metadata:
languageName: node
linkType: hard
"qs@npm:^6.12.3, qs@npm:^6.14.0, qs@npm:^6.15.2, qs@npm:~6.15.1":
"qs@npm:^6.12.3, qs@npm:^6.14.0, qs@npm:~6.15.1":
version: 6.15.3
resolution: "qs@npm:6.15.3"
dependencies:
@@ -20429,7 +20429,7 @@ __metadata:
languageName: node
linkType: hard
"qs@npm:~6.16.0":
"qs@npm:^6.16.1, qs@npm:~6.16.0":
version: 6.16.1
resolution: "qs@npm:6.16.1"
dependencies: