mirror of
https://github.com/navikt/aksel.git
synced 2026-10-28 08:24:05 +00:00
Compare commits
165
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3412d13b8 | ||
|
|
945672e126 | ||
|
|
d52b156bd2 | ||
|
|
122d0c3bd8 | ||
|
|
7bee95f190 | ||
|
|
746f8a514c | ||
|
|
8f37601c22 | ||
|
|
c53f3d42f2 | ||
|
|
eb270dee6c | ||
|
|
661ab81055 | ||
|
|
8176159439 | ||
|
|
58aaceaa49 | ||
|
|
319bc09eeb | ||
|
|
a7876ed078 | ||
|
|
6713cd6d30 | ||
|
|
136676ffa0 | ||
|
|
234c19f16d | ||
|
|
a2c0f09980 | ||
|
|
c3669539c6 | ||
|
|
0a0be96cd9 | ||
|
|
f2cff91d4f | ||
|
|
2994628c79 | ||
|
|
5bdfe05012 | ||
|
|
cd701af0df | ||
|
|
cd37887e1c | ||
|
|
1b5c0b50df | ||
|
|
91bd94ec4b | ||
|
|
4bf535b311 | ||
|
|
846cb036c4 | ||
|
|
b904f4ef04 | ||
|
|
863b6208d9 | ||
|
|
faa8f96507 | ||
|
|
dffaace465 | ||
|
|
23963c93b1 | ||
|
|
af7c24b5f0 | ||
|
|
00deddb6f6 | ||
|
|
47ef2953fa | ||
|
|
bb5939d21e | ||
|
|
a71d4b849a | ||
|
|
d604deb25c | ||
|
|
3fe9563078 | ||
|
|
62d2f5af01 | ||
|
|
882b3e1bcf | ||
|
|
de34578da9 | ||
|
|
c4b3fc0b23 | ||
|
|
53780d0c67 | ||
|
|
d476be6604 | ||
|
|
a0c9494b07 | ||
|
|
bf857007cd | ||
|
|
e6db21586e | ||
|
|
619ebd5088 | ||
|
|
1b539a9b13 | ||
|
|
8859bbb7ea | ||
|
|
dff2c96b4b | ||
|
|
2177f3ab64 | ||
|
|
51629f9229 | ||
|
|
03643665be | ||
|
|
8b63010549 | ||
|
|
4de40e0dc6 | ||
|
|
2c5d5f0c60 | ||
|
|
a2d0ffda0a | ||
|
|
45aa8baf10 | ||
|
|
3f7b72369e | ||
|
|
8009289ca4 | ||
|
|
ee50050d27 | ||
|
|
4e1cfddd84 | ||
|
|
643ab79c8d | ||
|
|
d134397fd0 | ||
|
|
d8e62db7f1 | ||
|
|
0cf700322c | ||
|
|
2c468cc8c7 | ||
|
|
d722f23a5e | ||
|
|
317940ec17 | ||
|
|
fb0330cf9d | ||
|
|
b9eb6568ad | ||
|
|
cc379525c0 | ||
|
|
6bb7becad7 | ||
|
|
8a513c96cc | ||
|
|
b4e5a3b8bd | ||
|
|
64dd1a529b | ||
|
|
3f9daed6a0 | ||
|
|
a53637c2d7 | ||
|
|
c9e48dfbd0 | ||
|
|
ef2e1f88e3 | ||
|
|
05e88b37b8 | ||
|
|
8ec9677d20 | ||
|
|
2b6c530fef | ||
|
|
f784aac8bb | ||
|
|
1490249f0c | ||
|
|
31c4296568 | ||
|
|
af64a52f83 | ||
|
|
0d6db896c0 | ||
|
|
91b4d8827e | ||
|
|
ea9ff5e63d | ||
|
|
3ab3498ddb | ||
|
|
40e1717fe9 | ||
|
|
06e3234ecd | ||
|
|
43f7fc70f0 | ||
|
|
2994c8d5ac | ||
|
|
1bca520e92 | ||
|
|
c5dd3097e5 | ||
|
|
a8cca91bcd | ||
|
|
bdddbd75d1 | ||
|
|
5e778e84a2 | ||
|
|
53dc01c64d | ||
|
|
c4cfbc66e1 | ||
|
|
9c2e1bfbad | ||
|
|
93982b8572 | ||
|
|
771906190c | ||
|
|
8092686b61 | ||
|
|
fda0c1686a | ||
|
|
651e8dcc96 | ||
|
|
78ef1dac67 | ||
|
|
fac620ff17 | ||
|
|
6023c4e1ac | ||
|
|
40d3e86678 | ||
|
|
ba1392f169 | ||
|
|
6e8a37a487 | ||
|
|
ecbce15748 | ||
|
|
3d87a3f50c | ||
|
|
a3ecfd5e88 | ||
|
|
d4150ec674 | ||
|
|
72df0780d3 | ||
|
|
05b1428679 | ||
|
|
13bf57f2a7 | ||
|
|
8b2ec8ca29 | ||
|
|
ad6fb1fcf4 | ||
|
|
a133f2a18d | ||
|
|
87c726a96f | ||
|
|
c0e50fc4ef | ||
|
|
21a79d7fd2 | ||
|
|
a710c1d0a2 | ||
|
|
ba90cf472b | ||
|
|
7e7bb85571 | ||
|
|
c76204d624 | ||
|
|
9e57079fd1 | ||
|
|
494c93f683 | ||
|
|
4de9033080 | ||
|
|
812b7cbc3b | ||
|
|
11b5c91372 | ||
|
|
95b8baaf5a | ||
|
|
470a8207f2 | ||
|
|
0984db737e | ||
|
|
93e79eae93 | ||
|
|
98aef6d317 | ||
|
|
b82d24b180 | ||
|
|
3d19c6821d | ||
|
|
6ea5fc4e63 | ||
|
|
69b8c5ec07 | ||
|
|
845099aa7f | ||
|
|
3eee1e9689 | ||
|
|
d3cc8ef625 | ||
|
|
d185b1d25b | ||
|
|
5b3df59f5a | ||
|
|
b97a1e7357 | ||
|
|
f5be404ff4 | ||
|
|
29a8538a0d | ||
|
|
d90c9be95e | ||
|
|
fce0c8f05b | ||
|
|
e91c8cb68a | ||
|
|
cff31812ec | ||
|
|
1085541c20 | ||
|
|
2a5a0e18cd | ||
|
|
3f5153d700 | ||
|
|
9673de4c26 |
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@navikt/ds-react": minor
|
||||
"@navikt/ds-css": patch
|
||||
---
|
||||
|
||||
DataGrid: Adds client- and server-side pagination through the `pagination` prop and `DataGrid.Pagination` controls.
|
||||
@@ -5,17 +5,18 @@
|
||||
- Narrow search space; don't replace reading code.
|
||||
- Before editing: read target file + nearest related files (`index.ts`, story, test, exports).
|
||||
- Prefer known paths/scripts over broad searches.
|
||||
- User often edits files between turns. Before reviewing/refactoring, re-read the files (or `git diff`); never rely on earlier reads.
|
||||
- Ignore release/deploy/changelog/ops scripts unless asked.
|
||||
|
||||
## Behavior
|
||||
|
||||
- Only respond in same language as the input. Prefer english always.
|
||||
- Use caveman-skill unless asked not to
|
||||
- Only respond in same language as the input. Prefer English always.
|
||||
- Use caveman-skill unless asked not to.
|
||||
- No apologies, no "I think", no change summaries.
|
||||
- Answer-first. 0-2 sentences max.
|
||||
- No thinking aloud; do next edit. Explain only when asked or non-obvious.
|
||||
- Show only what changes. Minimal diffs; don't paste whole files.
|
||||
- Edits scoped to one file unless asked. Ask only when truly blocked.
|
||||
- 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.
|
||||
|
||||
## Repo summary
|
||||
@@ -27,12 +28,13 @@
|
||||
## Start and validate
|
||||
|
||||
1. `corepack enable`
|
||||
2. `corepack yarn install` (needs `NPM_AUTH_TOKEN` with `read:packages`). Without a token: `corepack yarn workspaces focus @navikt/aksel-icons @navikt/ds-tokens @navikt/ds-css @navikt/ds-react @navikt/ds-tailwind @navikt/aksel @navikt/aksel-stylelint`
|
||||
2. `corepack yarn install` (needs `NPM_AUTH_TOKEN` with `read:packages`). Without a token: `corepack yarn workspaces focus aksel @navikt/aksel-icons @navikt/ds-tokens @navikt/ds-css @navikt/ds-react @navikt/ds-tailwind @navikt/aksel @navikt/aksel-stylelint`
|
||||
3. `corepack yarn boot` (builds all public packages; rerun after `corepack yarn clean`)
|
||||
|
||||
- Run agent commands through `corepack yarn`. Order: existing root/workspace script → Yarn-local binary → add a pinned dependency only when new tooling is required.
|
||||
- Never use `npx`, `pnpx`, `pnpm dlx`, `yarn dlx`, global binaries or curl-piped installers. Public consumer docs (`npx @navikt/aksel`) are intentional; keep them.
|
||||
- If Corepack/Yarn fails because of user-level configuration, report the exact error. Never edit user-home config.
|
||||
- Under the `navikt/cplt` sandbox, `Parse error when loading ~/.yarnrc.yml` means the home rc file is unreadable (EPERM), not a repo issue. Stop retrying, report it and ask the user to fix it outside the sandbox. Don't touch the repo `.yarnrc.yml`.
|
||||
- Choosing checks: use the `aksel-local-validation` skill. Changed-file lint first, then the affected workspace, then root suites only when needed.
|
||||
|
||||
## Definition of done
|
||||
@@ -47,7 +49,8 @@
|
||||
- `corepack yarn tsc --noEmit --incremental false --project <tsconfig>` - targeted type-check
|
||||
- `corepack yarn workspace <name> <script>` - e.g. `@navikt/ds-react test` (Vitest + tsc), `website test`
|
||||
- `corepack yarn lint` / `corepack yarn test` / `corepack yarn boot` - root suites (slow; cross-workspace changes only)
|
||||
- `corepack yarn storybook` (:6006) / `corepack yarn storybook:aksel` (website examples, :6007, no secrets) / `corepack yarn storybook:test` (play tests, Playwright Firefox) / `corepack yarn dev` (website, :3000, needs Sanity token)
|
||||
- `corepack yarn storybook` (:6006) / `corepack yarn storybook:aksel` (website examples, :6007, no secrets) / `corepack yarn storybook:test` (play tests, Playwright Firefox; Chromium when `COPILOT_CLI` is set) / `corepack yarn dev` (website, :3000, needs Sanity token)
|
||||
- Targeted play tests: `corepack yarn vitest run --config ./vitest.config.ts --project=storybook <path/to/X.tests.stories.tsx>` (repo root). Run after changing play tests. Under Copilot CLI this runs Chromium; if the browser can't launch, report tests as not run with the exact command.
|
||||
|
||||
## Where to edit
|
||||
|
||||
@@ -80,8 +83,10 @@ Path-specific rules load automatically from `.github/instructions/` (see each `a
|
||||
## Coding defaults
|
||||
|
||||
- Small diffs. No drive-by refactors or new deps unless asked.
|
||||
- Propose the simplest maintainable solution first (explicit lists, member-name checks). No TS compiler API/AST tricks in tests (slow). Ask before escalating complexity.
|
||||
- Copy nearest existing pattern before writing new code.
|
||||
- Stable public APIs. Prefer additive props/exports.
|
||||
- `node:` imports for Node builtins. Never import from `esm/`/`cjs/` output.
|
||||
- Preserve JSDoc on public props/components when changing public APIs.
|
||||
- Reuse helpers: `cl`, `composeEventHandlers`, `useId`, `omit`, `useClientLayoutEffect`, `useEventListener`.
|
||||
- No tests for trivial logic. Only tests for complex logic that has a higher risk of breaking.
|
||||
@@ -15,7 +15,14 @@ updates:
|
||||
registries:
|
||||
- npm-github
|
||||
groups:
|
||||
next:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- "@next/eslint-plugin-next"
|
||||
- "@next/bundle-analyzer"
|
||||
- "next"
|
||||
sanity:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- "sanity"
|
||||
- "sanity-*"
|
||||
@@ -23,17 +30,55 @@ updates:
|
||||
- "next-sanity"
|
||||
- "@portabletext/*"
|
||||
eslint:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- "eslint"
|
||||
- "@eslint/*"
|
||||
react:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- "react"
|
||||
- "react-dom"
|
||||
storybook:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- "*storybook*"
|
||||
vitest:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- "vitest"
|
||||
- "@vitest/*"
|
||||
# Security updates ignore version-update groups, so mirror them to keep lockstep packages together.
|
||||
next-security:
|
||||
applies-to: security-updates
|
||||
patterns:
|
||||
- "@next/eslint-plugin-next"
|
||||
- "@next/bundle-analyzer"
|
||||
- "next"
|
||||
sanity-security:
|
||||
applies-to: security-updates
|
||||
patterns:
|
||||
- "sanity"
|
||||
- "sanity-*"
|
||||
- "@sanity/*"
|
||||
- "next-sanity"
|
||||
- "@portabletext/*"
|
||||
eslint-security:
|
||||
applies-to: security-updates
|
||||
patterns:
|
||||
- "eslint"
|
||||
- "@eslint/*"
|
||||
react-security:
|
||||
applies-to: security-updates
|
||||
patterns:
|
||||
- "react"
|
||||
- "react-dom"
|
||||
storybook-security:
|
||||
applies-to: security-updates
|
||||
patterns:
|
||||
- "*storybook*"
|
||||
vitest-security:
|
||||
applies-to: security-updates
|
||||
patterns:
|
||||
- "vitest"
|
||||
- "@vitest/*"
|
||||
|
||||
@@ -12,4 +12,4 @@ applyTo: "**/*.test.ts,**/*.test.tsx,**/*.stories.tsx"
|
||||
- Query by role and accessible name (`getByRole("button", { name })`). Avoid snapshots unless nearby tests already use them.
|
||||
- Cover the main path plus 1-2 meaningful edge cases. A behavior change updates the nearest story and test in the same change.
|
||||
- ds-react unit tests run with `TZ=UTC`. Date logic must not depend on the local timezone.
|
||||
- Browser tests need Playwright Firefox. Yarn has `enableScripts: false`, so if the browser is missing, report it instead of installing globally.
|
||||
- Browser tests need Playwright Firefox (Chromium under Copilot CLI, see `vitest.config.ts`). Yarn has `enableScripts: false`, so if the browser is missing, report it instead of installing globally.
|
||||
@@ -69,7 +69,7 @@ Skip anything ESLint, Stylelint, Biome, Prettier or TypeScript already catches.
|
||||
|
||||
### Tests and docs (🟡)
|
||||
|
||||
- A behavior change has a story `play` test or Vitest test proving the user-facing contract. Queries use role and accessible name.
|
||||
- Complex logic with a higher risk of breaking has a story `play` test or Vitest test proving the user-facing contract. Queries use role and accessible name.
|
||||
- Tests are deterministic: no dependence on timezone, current time or randomness.
|
||||
- Edge cases that are easy to miss: controlled vs. uncontrolled, `ref`/`className`/`...rest` forwarding, `as` prop, empty and very long content.
|
||||
- Website examples (`aksel.nav.no/website/pages/eksempler/`) are updated when usage changes.
|
||||
|
||||
@@ -95,7 +95,7 @@ Move stories to component root dir. If stories import from internal paths, updat
|
||||
|
||||
### 5. Update `index.ts`
|
||||
|
||||
All exports are **named** — no `default` exports anywhere. The component `index.ts` re-exports the compound component, its sub-components, and their types from the root file:
|
||||
All exports are **named** — no `default` exports anywhere. For compound components, the root file is the single export source: it assembles the compound component, imports every publicly exported sub-component and its prop types, and exports all public values and types together. The component `index.ts` only re-exports these names from `./root/<Component>Root` — never directly from sub-component files or shared type files.
|
||||
|
||||
```ts
|
||||
"use client";
|
||||
@@ -111,13 +111,18 @@ export type {
|
||||
} from "./root/<Component>Root";
|
||||
```
|
||||
|
||||
Group values first, then types. If types live in `<Component>.types.ts`, re-export them from there.
|
||||
Group values first, then types. If types live in `<Component>.types.ts`, import them into the root file and include them in its type export block. The `index.ts` still re-exports them from the root file.
|
||||
|
||||
#### Compound root file export pattern
|
||||
|
||||
In `<Component>Root.tsx`, assemble the compound component with `Object.assign` — **do not** use an `as <Component>Component` cast or a dedicated `<Component>Component` interface:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
<Component>Trigger,
|
||||
type <Component>TriggerProps,
|
||||
} from "../trigger/<Component>Trigger";
|
||||
|
||||
const <Component>Root = forwardRef<HTMLDivElement, <Component>Props>((props, ref) => {
|
||||
// ...
|
||||
});
|
||||
@@ -138,6 +143,8 @@ export { <Component>, <Component>Trigger };
|
||||
export type { <Component>Props, <Component>TriggerProps };
|
||||
```
|
||||
|
||||
Follow `accordion/root/AccordionRoot.tsx`: export the imported sub-component values and prop types alongside the compound component at the bottom of the root file. Do not add internal-only sub-components to the public export chain. Sub-components must not import from the root file or component `index.ts`; use shared type/context/helper files to avoid circular dependencies.
|
||||
|
||||
Why `Object.assign` instead of `as`: the cast types the const as an interface that has no runtime `valueDeclaration`, so `react-docgen-typescript` (used by `yarn docgen:meta`) fails to extract props and can't document the component when it is exported via a grouped `export { }` statement. `Object.assign` lets TypeScript infer the intersection type from the value, so metadata generation works with named, bottom-of-file exports.
|
||||
|
||||
Sub-component files follow the same rule — named value export plus named type export at the bottom, no inline `export interface` and no default:
|
||||
@@ -173,6 +180,8 @@ Concretely: grep for `export` lines in both versions and diff them. All componen
|
||||
|
||||
**Package root.** Verify `@navikt/core/react/src/index.ts` still re-exports the component. No changes needed there unless the component's `index.ts` path changed (it shouldn't).
|
||||
|
||||
**Root-only re-exports.** For compound components, verify every value and type export in the component `index.ts` comes from `./root/<Component>Root`, and every forwarded name is exported by that root file.
|
||||
|
||||
**No case-duplicate tracked paths.** This is the most common restructure bug. On case-insensitive filesystems (macOS, Windows) git can end up tracking **two** entries that differ only in casing (e.g. `accordion.stories.tsx` **and** `Accordion.stories.tsx`) while the working tree shows only one file. The stale entry then breaks tests and checkout on other machines. `ls` / file search will NOT reveal it — you must ask git. Run:
|
||||
|
||||
```sh
|
||||
@@ -281,6 +290,7 @@ dialog/
|
||||
|
||||
- **No breaking changes.** Public export names and prop shapes must be identical after restructuring.
|
||||
- **Named exports only.** No `default` exports; group named exports at the bottom of each file (values first, then types).
|
||||
- **Compound exports flow through the root.** Component `index.ts` re-exports public values and types only from `root/<Component>Root.tsx`, not directly from sub-component or shared type files.
|
||||
- **Compound roots use `Object.assign`.** No `as <Component>Component` cast or `<Component>Component` interface — it breaks `yarn docgen:meta` prop extraction.
|
||||
- Component rules (React 17 compatibility, `"use client"`, `forwardRef`/`className`/`...rest`/`as`, tokens) live in `.github/instructions/ds-react-component.instructions.md`. They still apply.
|
||||
- Keep JSDoc on public props and components. The component-level JSDoc block sits directly above the `Object.assign` call; sub-component `@see` tags go on the keys inside the `Object.assign` object literal.
|
||||
@@ -289,6 +299,6 @@ dialog/
|
||||
|
||||
- Canonical example: `@navikt/core/react/src/dialog/`
|
||||
- Package public API: `@navikt/core/react/src/index.ts`
|
||||
- Component `index.ts` pattern: `@navikt/core/react/src/dialog/index.ts`
|
||||
- Component export pattern: `@navikt/core/react/src/accordion/root/AccordionRoot.tsx` and `@navikt/core/react/src/accordion/index.ts`
|
||||
- Context pattern: `@navikt/core/react/src/dialog/root/DialogRoot.context.ts`
|
||||
- Meta: `@navikt/core/react/src/accordion/Accordion.meta.ts`
|
||||
@@ -17,6 +17,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
|
||||
@@ -14,15 +14,15 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
prebuild-app-for-playwright:
|
||||
name: Build app for Playwright
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write # Push E2E image to GHCR
|
||||
env:
|
||||
IMAGE_NAME: ${{ github.repository }}-e2e
|
||||
REGISTRY: ghcr.io
|
||||
@@ -31,6 +31,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup and build Next.js application
|
||||
uses: ./.github/actions/build-website
|
||||
@@ -61,6 +62,9 @@ jobs:
|
||||
needs: [prebuild-app-for-playwright]
|
||||
name: Playwright shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read # Pull E2E image from GHCR
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
services:
|
||||
@@ -81,6 +85,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
|
||||
@@ -24,6 +24,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup
|
||||
@@ -41,6 +42,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup
|
||||
@@ -58,6 +60,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup
|
||||
@@ -78,6 +81,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
@@ -22,14 +22,14 @@ jobs:
|
||||
name: Run Chromatic
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
@@ -33,6 +33,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
ref: main
|
||||
|
||||
- name: Setup project
|
||||
@@ -61,6 +62,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup
|
||||
@@ -87,6 +89,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
@@ -33,6 +33,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
@@ -12,6 +12,7 @@ jobs:
|
||||
upload:
|
||||
name: Build and upload token config
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
|
||||
permissions:
|
||||
contents: "read"
|
||||
@@ -22,6 +23,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
|
||||
@@ -12,6 +12,7 @@ jobs:
|
||||
upload:
|
||||
name: Upload fonts to CDN
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -20,6 +21,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: "actions/checkout@v7"
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Upload fonts to CDN
|
||||
id: upload
|
||||
|
||||
@@ -15,15 +15,14 @@ jobs:
|
||||
name: Build and deploy to dev-gcp
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: "write"
|
||||
contents: "read"
|
||||
id-token: "write"
|
||||
deployments: "write"
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup and build Next.js application
|
||||
uses: ./.github/actions/build-website
|
||||
@@ -46,7 +45,7 @@ jobs:
|
||||
identity_provider: ${{ secrets.NAIS_WORKLOAD_IDENTITY_PROVIDER }}
|
||||
|
||||
- name: Deploy to dev-gcp
|
||||
uses: nais/deploy/actions/deploy@v2
|
||||
uses: nais/deploy/actions/deploy@v3
|
||||
env:
|
||||
RESOURCE: aksel.nav.no/nais-dev.yaml
|
||||
CLUSTER: dev-gcp
|
||||
|
||||
@@ -26,6 +26,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup
|
||||
@@ -48,7 +49,7 @@ jobs:
|
||||
identity_provider: ${{ secrets.NAIS_WORKLOAD_IDENTITY_PROVIDER }}
|
||||
|
||||
- name: Deploy to prod-gcp
|
||||
uses: nais/deploy/actions/deploy@v2
|
||||
uses: nais/deploy/actions/deploy@v3
|
||||
env:
|
||||
RESOURCE: apps/mcp-server/.nais/app.yaml
|
||||
CLUSTER: prod-gcp
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
- main
|
||||
paths:
|
||||
- "aksel.nav.no/**"
|
||||
- "apps/playroom/**"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -23,6 +24,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup
|
||||
@@ -42,15 +45,14 @@ jobs:
|
||||
name: Build and deploy to prod-gcp
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: "write"
|
||||
contents: "read"
|
||||
id-token: "write"
|
||||
deployments: "write"
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup and build Next.js application
|
||||
uses: ./.github/actions/build-website
|
||||
@@ -76,7 +78,7 @@ jobs:
|
||||
identity_provider: ${{ secrets.NAIS_WORKLOAD_IDENTITY_PROVIDER }}
|
||||
|
||||
- name: Deploy to prod-gcp
|
||||
uses: nais/deploy/actions/deploy@v2
|
||||
uses: nais/deploy/actions/deploy@v3
|
||||
env:
|
||||
RESOURCE: aksel.nav.no/nais-prod.yaml
|
||||
CLUSTER: prod-gcp
|
||||
|
||||
@@ -16,7 +16,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/v7-aksel.nav.no'
|
||||
permissions:
|
||||
packages: "write"
|
||||
contents: "read"
|
||||
id-token: "write"
|
||||
steps:
|
||||
@@ -24,6 +23,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
identity_provider: ${{ secrets.NAIS_WORKLOAD_IDENTITY_PROVIDER }}
|
||||
|
||||
- name: Deploy to prod-gcp
|
||||
uses: nais/deploy/actions/deploy@v2
|
||||
uses: nais/deploy/actions/deploy@v3
|
||||
env:
|
||||
RESOURCE: v7-aksel.nav.no/nais-prod.yaml
|
||||
CLUSTER: prod-gcp
|
||||
|
||||
@@ -14,7 +14,8 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
jobs:
|
||||
build:
|
||||
name: Release packages
|
||||
if: ${{ !contains(github.event.head_commit.message, '[ci skip]') }}
|
||||
# Only publish from main, also when dispatched manually
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: "write"
|
||||
@@ -41,31 +42,38 @@ jobs:
|
||||
|
||||
- name: Create release Pull Request or publish to NPM
|
||||
id: changesets
|
||||
uses: changesets/action@v2
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version-script: yarn create-version
|
||||
publish-script: yarn release
|
||||
create-github-releases: false
|
||||
version: yarn create-version
|
||||
publish: yarn release
|
||||
createGithubReleases: false
|
||||
env:
|
||||
# Use OIDC for npm authentication instead of NPM_TOKEN
|
||||
# https://github.com/changesets/changesets/issues/1152#issuecomment-3190884868
|
||||
NPM_TOKEN: ""
|
||||
# Needed for push to main
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create .npmrc for GPR
|
||||
run: |
|
||||
cat << EOF > "$HOME/.npmrc"
|
||||
@navikt:registry=https://npm.pkg.github.com
|
||||
//npm.pkg.github.com/:_authToken=$NPM_TOKEN
|
||||
EOF
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.GIT_REPO }}
|
||||
|
||||
- name: Publish to GPR
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
id: changesets-gpr
|
||||
uses: changesets/action@v2
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
publish-script: yarn changeset publish
|
||||
create-github-releases: false
|
||||
publish: yarn changeset publish
|
||||
createGithubReleases: false
|
||||
env:
|
||||
NPM_TOKEN: ""
|
||||
# changeset v3 publishes with `yarn npm publish`, which reads the @navikt scope (GPR) from .yarnrc.yml
|
||||
NPM_AUTH_TOKEN: ${{ secrets.GIT_REPO }}
|
||||
# Needed for push to main
|
||||
github-token: ${{ secrets.GIT_REPO }}
|
||||
GITHUB_TOKEN: ${{ secrets.GIT_REPO }}
|
||||
|
||||
- name: Generate icon.zip
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
|
||||
+10
-3
@@ -1,6 +1,13 @@
|
||||
CHANGED=`git diff $1 $2 --stat -- yarn.lock | wc -l`
|
||||
if [ $CHANGED -gt 0 ];
|
||||
then
|
||||
echo
|
||||
echo "*** 🟡 yarn.lock has changed 🟡 ***"
|
||||
fi
|
||||
printf '\n\033[1;33m'
|
||||
printf ' ╔══════════════════════════════════════════╗\n'
|
||||
printf ' ║ ║\n'
|
||||
printf ' ║ !! yarn.lock has changed !! ║\n'
|
||||
printf ' ║ ║\n'
|
||||
printf ' ║ Run: yarn install ║\n'
|
||||
printf ' ║ ║\n'
|
||||
printf ' ╚══════════════════════════════════════════╝\n'
|
||||
printf '\033[0m\n'
|
||||
fi
|
||||
+10
-3
@@ -1,6 +1,13 @@
|
||||
CHANGED=`git diff HEAD@{1} --stat -- yarn.lock | wc -l`
|
||||
if [ $CHANGED -gt 0 ];
|
||||
then
|
||||
echo
|
||||
echo "*** 🟡 yarn.lock has changed 🟡 ***"
|
||||
fi
|
||||
printf '\n\033[1;33m'
|
||||
printf ' ╔══════════════════════════════════════════╗\n'
|
||||
printf ' ║ ║\n'
|
||||
printf ' ║ !! yarn.lock has changed !! ║\n'
|
||||
printf ' ║ ║\n'
|
||||
printf ' ║ Run: yarn install ║\n'
|
||||
printf ' ║ ║\n'
|
||||
printf ' ╚══════════════════════════════════════════╝\n'
|
||||
printf '\033[0m\n'
|
||||
fi
|
||||
+2
-1
@@ -1 +1,2 @@
|
||||
yarn lint-staged
|
||||
# Runs the local lint-staged checks before committing
|
||||
./node_modules/.bin/lint-staged
|
||||
@@ -17,6 +17,7 @@ defaultSemverRangePrefix: ""
|
||||
npmMinimalAgeGate: 7d
|
||||
npmPreapprovedPackages:
|
||||
- "@navikt/*"
|
||||
|
||||
enableTelemetry: false
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.18.0.cjs
|
||||
@@ -1,5 +1,25 @@
|
||||
# @navikt/aksel-icons
|
||||
|
||||
## 8.20.0
|
||||
|
||||
## 8.19.2
|
||||
|
||||
## 8.19.1
|
||||
|
||||
## 8.19.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Icons: Added new icon MenuHamburgerMagnifyingGlass ([#5368](https://github.com/navikt/aksel/pull/5368))
|
||||
|
||||
## 8.18.0
|
||||
|
||||
## 8.17.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Package: Fixed regression where package was published without dist-files ([`9673de4`](https://github.com/navikt/aksel/commit/9673de4c264479dd8ca04897003532595acad255))
|
||||
|
||||
## 8.17.1
|
||||
|
||||
## 8.17.0
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.5 3.25C17.9518 3.25 20.75 6.04822 20.75 9.5C20.75 11.6221 19.6911 13.4953 18.0742 14.625L20.667 19.6562C20.8567 20.0244 20.7119 20.4773 20.3438 20.667C19.9756 20.8567 19.5227 20.7119 19.333 20.3438L16.751 15.3301C16.0524 15.6 15.2938 15.75 14.5 15.75C11.0482 15.75 8.25 12.9518 8.25 9.5C8.25 6.04822 11.0482 3.25 14.5 3.25ZM15.8994 17.25C15.993 17.25 16.0792 17.3026 16.1221 17.3857L16.6377 18.3857C16.723 18.552 16.6021 18.75 16.415 18.75H3.5C3.08579 18.75 2.75 18.4142 2.75 18C2.75 17.5858 3.08579 17.25 3.5 17.25H15.8994ZM14.5 4.75C11.8766 4.75 9.75 6.87665 9.75 9.5C9.75 12.1234 11.8766 14.25 14.5 14.25C17.1234 14.25 19.25 12.1234 19.25 9.5C19.25 6.87665 17.1234 4.75 14.5 4.75ZM6.75195 11.25C6.86836 11.25 6.96896 11.3306 6.99805 11.4434C7.08182 11.7679 7.1863 12.084 7.30957 12.3906C7.37768 12.56 7.2558 12.75 7.07324 12.75H3.5C3.08579 12.75 2.75 12.4142 2.75 12C2.75 11.5858 3.08579 11.25 3.5 11.25H6.75195ZM7.55664 5.25C7.75557 5.25 7.8765 5.47207 7.77734 5.64453C7.60333 5.94718 7.4499 6.26318 7.31738 6.58984C7.27831 6.68596 7.18482 6.75 7.08105 6.75H3.5C3.08579 6.75 2.75 6.41421 2.75 6C2.75 5.58579 3.08579 5.25 3.5 5.25H7.55664Z" fill="#202733"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,12 @@
|
||||
name: MenuHamburgerMagnifyingGlass
|
||||
category: Interface
|
||||
sub_category: Menu
|
||||
keywords:
|
||||
- Menu
|
||||
- meny
|
||||
- hamburger
|
||||
- search
|
||||
- søk
|
||||
- forstørrelsesglass
|
||||
variant: Stroke
|
||||
created_at: 07.10.2026
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel-icons",
|
||||
"version": "8.17.1",
|
||||
"version": "8.20.0",
|
||||
"author": "Aksel | Nav",
|
||||
"homepage": "https://aksel.nav.no/ikoner",
|
||||
"repository": {
|
||||
@@ -75,19 +75,20 @@
|
||||
"@figma/rest-api-spec": "^0.41.0",
|
||||
"@svgr/cli": "8.1.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "4.17.25",
|
||||
"@types/react": "19.2.14",
|
||||
"adm-zip": "^0.6.1",
|
||||
"concurrently": "10.0.4",
|
||||
"concurrently": "10.0.6",
|
||||
"copyfiles": "^2.4.1",
|
||||
"fast-glob": "3.3.3",
|
||||
"hast-util-select": "6.0.4",
|
||||
"js-yaml": "^5.4.2",
|
||||
"js-yaml": "^5.5.0",
|
||||
"lodash": "4.18.1",
|
||||
"prettier": "3.9.6",
|
||||
"react": "19.2.8",
|
||||
"react": "19.3.0",
|
||||
"rehype-parse": "9.0.1",
|
||||
"tsc-alias": "1.9.1",
|
||||
"tsx": "^4.23.1",
|
||||
"tsc-alias": "1.9.7",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "6.0.3",
|
||||
"unified": "11.0.5",
|
||||
"vitest": "4.1.11"
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @navikt/aksel-stylelint
|
||||
|
||||
## 8.20.0
|
||||
|
||||
## 8.19.2
|
||||
|
||||
## 8.19.1
|
||||
|
||||
## 8.19.0
|
||||
|
||||
## 8.18.0
|
||||
|
||||
## 8.17.2
|
||||
|
||||
## 8.17.1
|
||||
|
||||
## 8.17.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel-stylelint",
|
||||
"version": "8.17.1",
|
||||
"version": "8.20.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
@@ -43,14 +43,14 @@
|
||||
"dev": "yarn watch:lint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@navikt/ds-css": "^8.17.1",
|
||||
"@navikt/ds-tokens": "^8.17.1",
|
||||
"concurrently": "10.0.4",
|
||||
"postcss-selector-parser": "^7.1.5",
|
||||
"@navikt/ds-css": "^8.20.0",
|
||||
"@navikt/ds-tokens": "^8.20.0",
|
||||
"concurrently": "10.0.6",
|
||||
"postcss-selector-parser": "^7.1.7",
|
||||
"postcss-value-parser": "^4.2.0",
|
||||
"stylelint": "^17.14.1",
|
||||
"stylelint": "^17.15.0",
|
||||
"stylelint-test-rule-node": "^1.2.0",
|
||||
"tsx": "^4.23.1",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,29 @@
|
||||
# @navikt/aksel
|
||||
|
||||
## 8.20.0
|
||||
|
||||
## 8.19.2
|
||||
|
||||
## 8.19.1
|
||||
|
||||
## 8.19.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- CLI: Update clipboardy to v5.3.2 ([#5357](https://github.com/navikt/aksel/pull/5357))
|
||||
|
||||
- CLI: Update figlet to 1.11.3 ([#5360](https://github.com/navikt/aksel/pull/5360))
|
||||
|
||||
## 8.18.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Codemod: Migrations now run in folders that are not git repositories, and a missing git install gets its own error message. `codemod v8` no longer stops before `v8-tokens` because of changes made by earlier migrations in the same run. ([`470a820`](https://github.com/navikt/aksel/commit/470a8207f23aee71243b986ef2836967c2498e71))
|
||||
|
||||
- Codemod: `v8-tokens` no longer removes whitespace before replaced tokens or rewrites longer names that contain a token name. ([#5322](https://github.com/navikt/aksel/pull/5322))
|
||||
|
||||
## 8.17.2
|
||||
|
||||
## 8.17.1
|
||||
|
||||
## 8.17.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel",
|
||||
"version": "8.17.1",
|
||||
"version": "8.20.0",
|
||||
"description": "Aksel command line interface. Codemods and other utilities for Aksel users.",
|
||||
"author": "Aksel | Nav designsystem team",
|
||||
"license": "MIT",
|
||||
@@ -42,11 +42,11 @@
|
||||
"dependencies": {
|
||||
"chalk": "5.6.2",
|
||||
"cli-progress": "3.12.0",
|
||||
"clipboardy": "5.3.1",
|
||||
"clipboardy": "5.3.2",
|
||||
"commander": "15.0.0",
|
||||
"enquirer": "2.4.1",
|
||||
"fast-glob": "3.3.3",
|
||||
"figlet": "1.11.0",
|
||||
"figlet": "1.12.0",
|
||||
"is-git-clean": "1.1.0",
|
||||
"jscodeshift": "17.4.0",
|
||||
"lodash": "4.18.1"
|
||||
@@ -55,6 +55,7 @@
|
||||
"@types/cli-progress": "3.11.6",
|
||||
"@types/is-git-clean": "1.1.2",
|
||||
"@types/jscodeshift": "17.3.0",
|
||||
"@types/lodash": "4.17.25",
|
||||
"rimraf": "6.1.3",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.11"
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Command } from "commander";
|
||||
import isGitClean from "is-git-clean";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { validateGit } from "../validation";
|
||||
|
||||
vi.mock("is-git-clean", () => ({ default: { sync: vi.fn() } }));
|
||||
|
||||
const gitSync = vi.mocked(isGitClean.sync);
|
||||
|
||||
function createProgram() {
|
||||
const error = vi.fn((message: string) => {
|
||||
throw new Error(message);
|
||||
});
|
||||
return { program: { error } as unknown as Command, error };
|
||||
}
|
||||
|
||||
function gitError(props: Record<string, unknown>) {
|
||||
return Object.assign(new Error("Command failed: git status"), props);
|
||||
}
|
||||
|
||||
describe("validateGit", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("passes when the working tree is clean", () => {
|
||||
gitSync.mockReturnValue(true);
|
||||
const { program, error } = createProgram();
|
||||
|
||||
validateGit({}, program);
|
||||
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("fails when the working tree has changes", () => {
|
||||
gitSync.mockReturnValue(false);
|
||||
const { program, error } = createProgram();
|
||||
|
||||
expect(() => validateGit({}, program)).toThrow(/stash or commit/);
|
||||
expect(error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test.each([
|
||||
"fatal: not a git repository (or any of the parent directories): .git\n",
|
||||
"fatal: Not a git repository (or any of the parent directories): .git\n",
|
||||
])("passes outside a git repository (%j)", (stderr) => {
|
||||
gitSync.mockImplementation(() => {
|
||||
throw gitError({ status: 128, stderr });
|
||||
});
|
||||
const { program, error } = createProgram();
|
||||
|
||||
validateGit({}, program);
|
||||
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("reports a missing git binary instead of asking to stash", () => {
|
||||
gitSync.mockImplementation(() => {
|
||||
throw gitError({ code: "ENOENT" });
|
||||
});
|
||||
const { program, error } = createProgram();
|
||||
|
||||
expect(() => validateGit({}, program)).toThrow(/git/i);
|
||||
expect(error.mock.calls[0][0]).not.toMatch(/stash or commit/);
|
||||
expect(error.mock.calls[0][0]).toMatch(/--force/);
|
||||
});
|
||||
|
||||
test("fails on other git errors", () => {
|
||||
gitSync.mockImplementation(() => {
|
||||
throw gitError({
|
||||
status: 128,
|
||||
stderr: "fatal: detected dubious ownership",
|
||||
});
|
||||
});
|
||||
const { program } = createProgram();
|
||||
|
||||
expect(() => validateGit({}, program)).toThrow(/stash or commit/);
|
||||
});
|
||||
|
||||
test.each([{ dryRun: true }, { force: true }])(
|
||||
"skips the check with force",
|
||||
(options) => {
|
||||
const { program, error } = createProgram();
|
||||
|
||||
validateGit(options, program);
|
||||
|
||||
expect(gitSync).not.toHaveBeenCalled();
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/* @ts-expect-error No avaliable types for testUtils */
|
||||
import { applyTransform } from "jscodeshift/dist/testUtils";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import * as transform from "../update-js-tokens";
|
||||
|
||||
const run = (source: string) =>
|
||||
applyTransform({ ...transform, parser: "tsx" }, {}, { source });
|
||||
|
||||
describe("update-js-tokens keeps boundaries intact", () => {
|
||||
test("does not replace inside longer identifiers", () => {
|
||||
expect(
|
||||
run(
|
||||
`import { NavdsGlobalColorGray100 } from "@navikt/ds-tokens/dist/tokens";\n\nconst a = [NavdsGlobalColorGray100, MyNavdsGlobalColorGray100, NavdsGlobalColorGray1000];`,
|
||||
),
|
||||
).toBe(
|
||||
`import { AGray100 } from "@navikt/ds-tokens/dist/tokens";\n\nconst a = [AGray100, MyNavdsGlobalColorGray100, NavdsGlobalColorGray1000];`,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not replace similar names when aliased", () => {
|
||||
expect(
|
||||
run(
|
||||
`import { NavdsGlobalColorGray100 as gray } from "@navikt/ds-tokens/dist/tokens";\n\nconst a = [gray, grayish, mygray];`,
|
||||
),
|
||||
).toBe(
|
||||
`import { AGray100 } from "@navikt/ds-tokens/dist/tokens";\n\nconst a = [AGray100, grayish, mygray];`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { API, JSCodeshift } from "jscodeshift";
|
||||
import { tokens } from "../../../tokens-map.js";
|
||||
import { createIdentifierRegex } from "../../../utils/identifier-regex";
|
||||
import {
|
||||
getImportSpecifier,
|
||||
getImportSpecifierName,
|
||||
@@ -60,8 +61,7 @@ export default function transformer(file: JSCodeshift, api: API) {
|
||||
|
||||
let code = root.toSource(getLineTerminator(file.source));
|
||||
|
||||
const rgx = new RegExp(`(${localName})`, "gm");
|
||||
code = code.replace(rgx, out);
|
||||
code = code.replace(createIdentifierRegex(String(localName)), out);
|
||||
root = j(code);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { applyTransform } from "jscodeshift/dist/testUtils";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
// eslint-disable-next-line import/default
|
||||
import prettier from "prettier";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Matches a JS identifier as a whole word.
|
||||
* Lookarounds are zero-width, so replacing the match never removes surrounding whitespace,
|
||||
* and longer identifiers containing the name (`MyToken`, `Token2`, `$Token`) are left untouched.
|
||||
*/
|
||||
function createIdentifierRegex(identifier: string) {
|
||||
const escaped = identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`(?<![\\w$])${escaped}(?![\\w$])`, "gm");
|
||||
}
|
||||
|
||||
export { createIdentifierRegex };
|
||||
@@ -218,8 +218,25 @@ describe("getTokenRegex JS", () => {
|
||||
|
||||
expect("ATextRed200").not.toMatch(regex);
|
||||
expect("AATextRed200").not.toMatch(regex);
|
||||
expect("AATextRed").not.toMatch(regex);
|
||||
expect("$ATextRed").not.toMatch(regex);
|
||||
});
|
||||
|
||||
/* Since we cant do a positive lookbehind, these cases will be matched */
|
||||
expect("AATextRed").toMatch(regex);
|
||||
test("should not include surrounding whitespace in the match", () => {
|
||||
const regex = getTokenRegex("--a-text-red", "js");
|
||||
|
||||
expect(" ATextRed ".match(regex)).toEqual(["ATextRed"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTokenRegex does not consume leading whitespace", () => {
|
||||
test.each([
|
||||
["css", " var(--a-text-red)", "--a-text-red"],
|
||||
["scss", "margin: 0 $a-text-red", "$a-text-red"],
|
||||
["less", "margin: 0 @a-text-red", "@a-text-red"],
|
||||
] as const)("%s", (format, input, expected) => {
|
||||
expect(input.match(getTokenRegex("--a-text-red", format))).toEqual([
|
||||
expected,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -39,26 +39,29 @@ function getFrameworkRegexes({
|
||||
return regexes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms replace the full match. We use lookarounds so that the regex does not consume surrounding characters.
|
||||
*/
|
||||
function getTokenRegex(
|
||||
variable: string,
|
||||
format: "css" | "scss" | "less" | "js",
|
||||
) {
|
||||
switch (format) {
|
||||
case "css":
|
||||
return new RegExp(`(\\s|^)?(${variable})(?=\\s|$|[^\\w-])`, "gm");
|
||||
return new RegExp(`(?<![\\w-])(${variable})(?![\\w-])`, "gm");
|
||||
case "scss":
|
||||
return new RegExp(
|
||||
`(\\s|^)?(\\${translateToken(variable, "scss")})(?=\\s|$|[^\\w-])`,
|
||||
`(?<![\\w-])(\\${translateToken(variable, "scss")})(?![\\w-])`,
|
||||
"gm",
|
||||
);
|
||||
case "less":
|
||||
return new RegExp(
|
||||
`(\\s|^)?(${translateToken(variable, "less")})(?=\\s|$|[^\\w-])`,
|
||||
`(?<![\\w-])(${translateToken(variable, "less")})(?![\\w-])`,
|
||||
"gm",
|
||||
);
|
||||
case "js":
|
||||
return new RegExp(
|
||||
`(\\s|^)?(${translateToken(variable, "js")})(?=\\s|$|[^\\w-])`,
|
||||
`(?<![\\w$])(${translateToken(variable, "js")})(?![\\w$])`,
|
||||
"gm",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { runTooling } from "./run-tooling.js";
|
||||
|
||||
const program = new Command();
|
||||
|
||||
function v8TokensCommand() {
|
||||
function v8TokensCommand({ skipGitCheck = false } = {}) {
|
||||
program.allowExcessArguments();
|
||||
program.name(`${chalk.blueBright(`npx @navikt/aksel v8-tokens`)}`);
|
||||
|
||||
@@ -24,7 +24,10 @@ function v8TokensCommand() {
|
||||
program.parse();
|
||||
const options = program.opts();
|
||||
|
||||
runTooling(options as Parameters<typeof runTooling>["0"], program);
|
||||
return runTooling(
|
||||
{ ...(options as Parameters<typeof runTooling>["0"]), skipGitCheck },
|
||||
program,
|
||||
);
|
||||
}
|
||||
|
||||
export { v8TokensCommand };
|
||||
@@ -31,6 +31,11 @@ type ToolingOptions = {
|
||||
dryRun: boolean;
|
||||
glob: string;
|
||||
ext: string;
|
||||
/**
|
||||
* Set when the caller has already validated git (e.g. `codemod v8`),
|
||||
* since earlier migrations in the same run leave the tree dirty.
|
||||
*/
|
||||
skipGitCheck?: boolean;
|
||||
};
|
||||
|
||||
type CodeshiftOptions = {
|
||||
@@ -90,6 +95,15 @@ export async function runTooling(
|
||||
// Show initial status
|
||||
const initialStatus = getStatus(filepaths);
|
||||
|
||||
// Only check git before the first write: later tasks see our own changes.
|
||||
let gitChecked = !!options.skipGitCheck;
|
||||
const ensureGitChecked = () => {
|
||||
if (!gitChecked) {
|
||||
validateGit(options, program);
|
||||
gitChecked = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Task execution loop
|
||||
let task: TaskName = await getNextTask(initialStatus.status);
|
||||
let currentStatus = initialStatus;
|
||||
@@ -105,6 +119,7 @@ export async function runTooling(
|
||||
program,
|
||||
currentStatus,
|
||||
() => getStatus(filepaths, "no-print"),
|
||||
ensureGitChecked,
|
||||
);
|
||||
} catch (error) {
|
||||
program.error(
|
||||
@@ -117,8 +132,6 @@ export async function runTooling(
|
||||
|
||||
task = await getNextTask(currentStatus.status);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,6 +144,7 @@ async function executeTask(
|
||||
program: Command,
|
||||
statusStore: TokenStatus,
|
||||
updateStatus: () => TokenStatus,
|
||||
ensureGitChecked: () => void,
|
||||
): Promise<TokenStatus> {
|
||||
switch (task) {
|
||||
case "status":
|
||||
@@ -147,9 +161,7 @@ async function executeTask(
|
||||
case "less-tokens":
|
||||
case "js-tokens":
|
||||
case "tailwind-tokens": {
|
||||
if (!options.force) {
|
||||
validateGit(options, program);
|
||||
}
|
||||
ensureGitChecked();
|
||||
const scopedFiles = getScopedFilesForTask(
|
||||
task,
|
||||
filepaths,
|
||||
@@ -179,9 +191,7 @@ async function executeTask(
|
||||
"tailwind-tokens",
|
||||
] as const;
|
||||
|
||||
if (!options.force) {
|
||||
validateGit(options, program);
|
||||
}
|
||||
ensureGitChecked();
|
||||
|
||||
let currentStatus = statusStore;
|
||||
const summaryData: {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Command } from "commander";
|
||||
import Enquirer from "enquirer";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { validateGit } from "../../validation";
|
||||
import { TokenStatus } from "../config/TokenStatus";
|
||||
import { runTooling } from "../run-tooling";
|
||||
|
||||
vi.mock("enquirer", () => ({ default: { prompt: vi.fn() } }));
|
||||
vi.mock("fast-glob", () => ({ default: vi.fn(async () => []) }));
|
||||
vi.mock("jscodeshift/src/Runner", () => ({
|
||||
run: vi.fn(async () => ({ error: 0, ok: 0, nochange: 0, skip: 0 })),
|
||||
}));
|
||||
vi.mock("../../validation", () => ({ validateGit: vi.fn() }));
|
||||
vi.mock("../tasks/status", () => ({
|
||||
getStatus: vi.fn(() => new TokenStatus()),
|
||||
}));
|
||||
vi.mock("../tasks/print-remaining", () => ({ printRemaining: vi.fn() }));
|
||||
|
||||
const prompt = vi.mocked(Enquirer.prompt);
|
||||
const program = { error: vi.fn() } as unknown as Command;
|
||||
const baseOptions = { force: false, dryRun: false, glob: "**/*.css", ext: "" };
|
||||
|
||||
function selectTasks(...tasks: string[]) {
|
||||
for (const task of [...tasks, "exit"]) {
|
||||
prompt.mockResolvedValueOnce({ task });
|
||||
}
|
||||
}
|
||||
|
||||
describe("runTooling", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns instead of exiting the process when the user exits", async () => {
|
||||
selectTasks();
|
||||
|
||||
await expect(runTooling(baseOptions, program)).resolves.toBeUndefined();
|
||||
expect(process.exit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("checks git once, even when several migrations run in a row", async () => {
|
||||
selectTasks("css-tokens", "scss-tokens", "run-all-migrations");
|
||||
|
||||
await runTooling(baseOptions, program);
|
||||
|
||||
expect(validateGit).toHaveBeenCalledTimes(1);
|
||||
expect(program.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("skips the git check when the caller already did it", async () => {
|
||||
selectTasks("css-tokens", "run-all-migrations");
|
||||
|
||||
await runTooling({ ...baseOptions, skipGitCheck: true }, program);
|
||||
|
||||
expect(validateGit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not check git for read-only tasks", async () => {
|
||||
selectTasks("status", "print-remaining-tokens");
|
||||
|
||||
await runTooling(baseOptions, program);
|
||||
|
||||
expect(validateGit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/* @ts-expect-error No avaliable types for testUtils */
|
||||
import { applyTransform } from "jscodeshift/dist/testUtils";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import * as jsTransform from "../v8-tokens-js";
|
||||
import lessTransform from "../v8-tokens-less";
|
||||
import scssTransform from "../v8-tokens-scss";
|
||||
|
||||
/*
|
||||
* Fixture tests are compared after Prettier, which hides whitespace bugs.
|
||||
* These tests compare the exact output.
|
||||
*/
|
||||
const runJs = (source: string) =>
|
||||
applyTransform({ ...jsTransform, parser: "tsx" }, {}, { source });
|
||||
|
||||
const runStyle = (
|
||||
transformer: typeof scssTransform | typeof lessTransform,
|
||||
source: string,
|
||||
) => transformer({ path: "test", source });
|
||||
|
||||
describe("v8-tokens-js keeps boundaries intact", () => {
|
||||
const importLine = `import { ASurfaceDefault } from "@navikt/ds-tokens/dist/tokens";\n`;
|
||||
const expectedImport = `import { BgDefault } from "@navikt/ds-tokens/js";\n`;
|
||||
|
||||
test.each([
|
||||
["return ASurfaceDefault;", "return BgDefault;"],
|
||||
["const a = ASurfaceDefault;", "const a = BgDefault;"],
|
||||
["const a = [ASurfaceDefault];", "const a = [BgDefault];"],
|
||||
["const a = {...ASurfaceDefault};", "const a = {...BgDefault};"],
|
||||
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing token inside template literal
|
||||
["const a = `${ASurfaceDefault}`;", "const a = `${BgDefault}`;"],
|
||||
])("%s", (input, output) => {
|
||||
expect(runJs(`${importLine}\n${input}`)).toBe(
|
||||
`${expectedImport}\n${output}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not replace inside longer identifiers", () => {
|
||||
expect(
|
||||
runJs(
|
||||
`${importLine}\nconst a = [MyASurfaceDefault, ASurfaceDefault2, $ASurfaceDefault];`,
|
||||
),
|
||||
).toBe(
|
||||
`${expectedImport}\nconst a = [MyASurfaceDefault, ASurfaceDefault2, $ASurfaceDefault];`,
|
||||
);
|
||||
});
|
||||
|
||||
test("replaces aliased imports without touching similar names", () => {
|
||||
expect(
|
||||
runJs(
|
||||
`import { ASurfaceDefault as surf } from "@navikt/ds-tokens/dist/tokens";\n\nconst a = [surf, surface, mysurf];`,
|
||||
),
|
||||
).toBe(`${expectedImport}\nconst a = [BgDefault, surface, mysurf];`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v8-tokens-scss keeps whitespace", () => {
|
||||
test.each([
|
||||
["margin: 0 $a-spacing-4;", "margin: 0 $ax-space-16;"],
|
||||
["color: $a-text-default;", "color: $ax-text-neutral;"],
|
||||
["color:$a-text-default;", "color:$ax-text-neutral;"],
|
||||
["$a-text-default", "$ax-text-neutral"],
|
||||
["\n\t$a-text-default", "\n\t$ax-text-neutral"],
|
||||
["color: common.$a-text-default;", "color: common.$ax-text-neutral;"],
|
||||
[
|
||||
"padding: $a-spacing-4 $a-spacing-4;",
|
||||
"padding: $ax-space-16 $ax-space-16;",
|
||||
],
|
||||
])("%s", (input, output) => {
|
||||
expect(runStyle(scssTransform, input)).toBe(output);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v8-tokens-less keeps whitespace", () => {
|
||||
test.each([
|
||||
["margin: 0 @a-spacing-4;", "margin: 0 @ax-space-16;"],
|
||||
["color: @a-text-default;", "color: @ax-text-neutral;"],
|
||||
["color:@a-text-default;", "color:@ax-text-neutral;"],
|
||||
[
|
||||
"padding: @a-spacing-4 @a-spacing-4;",
|
||||
"padding: @ax-space-16 @ax-space-16;",
|
||||
],
|
||||
])("%s", (input, output) => {
|
||||
expect(runStyle(lessTransform, input)).toBe(output);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { API, FileInfo } from "jscodeshift";
|
||||
import { createIdentifierRegex } from "../../utils/identifier-regex";
|
||||
import { getImportSpecifier } from "../../utils/imports";
|
||||
import { getLineTerminator } from "../../utils/lineterminator";
|
||||
import moveAndRenameImport from "../../utils/packageImports";
|
||||
@@ -63,8 +64,9 @@ export default function transformer(file: FileInfo, api: API) {
|
||||
|
||||
let code = root.toSource(getLineTerminator(src));
|
||||
|
||||
const rgx = new RegExp(`(\\s|^)?(${localName})(?=\\s|$|[^\\w-])`, "gm");
|
||||
code = code.replace(rgx, jsToken);
|
||||
if (localName) {
|
||||
code = code.replace(createIdentifierRegex(localName), jsToken);
|
||||
}
|
||||
src = code;
|
||||
|
||||
root = j(code);
|
||||
|
||||
@@ -43,7 +43,14 @@ export function validateGit(options: any, program: Command) {
|
||||
try {
|
||||
clean = isGitClean.sync(process.cwd());
|
||||
} catch (err: any) {
|
||||
if (err?.stderr && err.stderr.indexOf("Not a git repository") >= 0) {
|
||||
if (err?.code === "ENOENT") {
|
||||
program.error(
|
||||
`${chalk.yellow(
|
||||
"\nCould not find git, so we can't check for uncommitted changes.",
|
||||
)}${"\nInstall git, or use the --force flag to override this safety check."}`,
|
||||
);
|
||||
}
|
||||
if (/not a git repository/i.test(String(err?.stderr ?? ""))) {
|
||||
clean = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,14 @@ async function run() {
|
||||
|
||||
if (args[0] === "codemod") {
|
||||
if (args.includes("v8-tokens")) {
|
||||
v8TokensCommand();
|
||||
return;
|
||||
await v8TokensCommand();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
codemodCommand((migration) => {
|
||||
codemodCommand(async (migration) => {
|
||||
if (migration === "v8-tokens") {
|
||||
v8TokensCommand();
|
||||
/* `codemod v8` has already checked git before running earlier migrations */
|
||||
await v8TokensCommand({ skipGitCheck: true });
|
||||
}
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
# @navikt/ds-css
|
||||
|
||||
## 8.20.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Checkbox: Add `CheckboxCard` and `CheckboxCardGroup` components ([#5364](https://github.com/navikt/aksel/pull/5364))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ToggleGroup: Add xsmall as size ([#5411](https://github.com/navikt/aksel/pull/5411))
|
||||
|
||||
- RadioCard, CheckboxCard: Updated border-color in unchecked error-state, input-color in checked disabled state ([#5425](https://github.com/navikt/aksel/pull/5425))
|
||||
|
||||
## 8.19.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Search: Icon more visible in forced-colors mode ([#5386](https://github.com/navikt/aksel/pull/5386))
|
||||
|
||||
## 8.19.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Accordion: `@media print` now auto-opens every Accordion ([#5363](https://github.com/navikt/aksel/pull/5363))
|
||||
|
||||
- ExpansionCard: `@media print` now auto-opens every ExpansionCard ([#5363](https://github.com/navikt/aksel/pull/5363))
|
||||
|
||||
- ReadMore: `@media print` now auto-opens every ReadMore ([#5363](https://github.com/navikt/aksel/pull/5363))
|
||||
|
||||
## 8.19.0
|
||||
|
||||
## 8.18.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ActionMenu: Faster and smoother open transition. ([#5296](https://github.com/navikt/aksel/pull/5296))
|
||||
|
||||
## 8.17.2
|
||||
|
||||
## 8.17.1
|
||||
|
||||
## 8.17.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-css",
|
||||
"version": "8.17.1",
|
||||
"version": "8.20.0",
|
||||
"description": "CSS for Nav Designsystem",
|
||||
"author": "Aksel | Nav designsystem team",
|
||||
"keywords": [
|
||||
@@ -32,12 +32,12 @@
|
||||
"css:get-version": "node config/get-version.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@navikt/ds-tokens": "^8.17.1",
|
||||
"@navikt/ds-tokens": "^8.20.0",
|
||||
"browserslist": "^4.28.7",
|
||||
"esbuild": "^0.28.2",
|
||||
"fast-glob": "3.3.3",
|
||||
"lightningcss": "^1.33.0",
|
||||
"tsx": "^4.23.1"
|
||||
"tsx": "^4.23.15"
|
||||
},
|
||||
"browserslist": [
|
||||
">= 0.2% in NO",
|
||||
|
||||
@@ -123,6 +123,11 @@
|
||||
|
||||
.aksel-accordion--indent > .aksel-accordion__item > .aksel-accordion__content {
|
||||
box-shadow: -2px 0 0 0 var(--ax-border-neutral-subtleA);
|
||||
|
||||
@media print {
|
||||
border-left: 1px solid black;
|
||||
margin-left: calc(var(--ax-space-24) - 2px);
|
||||
}
|
||||
}
|
||||
|
||||
.aksel-accordion__item {
|
||||
@@ -163,4 +168,16 @@
|
||||
border-bottom: 1px solid var(--ax-border-neutral-subtleA);
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
& > .aksel-accordion__content {
|
||||
visibility: visible;
|
||||
|
||||
/* Override grid layout for print. Grid causes element to stretch unexpectedly across printed pages in FF */
|
||||
display: block;
|
||||
margin-block: var(--ax-space-8);
|
||||
margin-block-end: var(--ax-space-24);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,13 +21,13 @@
|
||||
/* stylelint-disable-next-line csstools/value-no-unknown-custom-properties */
|
||||
transform-origin: var(--__axc-action-menu-content-transform-origin);
|
||||
transition:
|
||||
transform 150ms ease allow-discrete,
|
||||
opacity 150ms ease allow-discrete;
|
||||
transform 100ms cubic-bezier(0.25, 0.46, 0.45, 0.94) allow-discrete,
|
||||
opacity 100ms cubic-bezier(0.25, 0.46, 0.45, 0.94) allow-discrete;
|
||||
|
||||
&[data-state="open"] {
|
||||
@starting-style {
|
||||
opacity: 0.8;
|
||||
transform: scale3d(0.94, 0.94, 0.94);
|
||||
opacity: 0.1;
|
||||
transform: scale3d(0.95, 0.95, 0.95);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.aksel-data-table__border-wrapper {
|
||||
@@ -710,3 +711,80 @@ html[data-dragging-cursor="true"] *::after {
|
||||
}
|
||||
|
||||
/* ------------------------ DataGrid.Preferences End ------------------------ */
|
||||
|
||||
/* ------------------------ DataGrid.Pagination Start ----------------------- */
|
||||
.aksel-data-grid__pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ax-space-8) var(--ax-space-16);
|
||||
padding: var(--ax-space-12) var(--ax-space-16);
|
||||
background-color: var(--ax-bg-raised);
|
||||
border: 1px solid var(--ax-border-neutral-subtle);
|
||||
border-radius: var(--ax-radius-12);
|
||||
}
|
||||
|
||||
.aksel-data-grid__pagination-page-size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ax-space-8);
|
||||
|
||||
& > .aksel-form-field__label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& > .aksel-select__container,
|
||||
& .aksel-select__input {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.aksel-data-grid__pagination-navigation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ax-space-4);
|
||||
|
||||
/* TODO: Avoids focus loss now, but is unconventional so consider revisiting this approach */
|
||||
& button[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 */
|
||||
& button[aria-disabled="true"] {
|
||||
color: GrayText;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.aksel-data-grid__pagination-summary,
|
||||
.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;
|
||||
}
|
||||
|
||||
.aksel-data-table__border-wrapper + .aksel-data-grid__pagination,
|
||||
.aksel-data-grid__pagination + .aksel-data-table__border-wrapper {
|
||||
margin-block-start: var(--ax-space-8);
|
||||
}
|
||||
|
||||
/* ------------------------- DataGrid.Pagination End ------------------------ */
|
||||
@@ -19,6 +19,10 @@
|
||||
box-shadow: 0 0 0 1px var(--ax-border-default);
|
||||
border-color: var(--ax-border-default);
|
||||
}
|
||||
|
||||
@media print {
|
||||
border-color: black;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------ ExpansionCard Small-size ------------------------ */
|
||||
@@ -166,6 +170,17 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
/* Override grid layout for print. Grid causes element to stretch unexpectedly across printed pages in FF */
|
||||
display: block;
|
||||
visibility: visible;
|
||||
padding-block: var(--__axc-expansioncard-padding-block);
|
||||
|
||||
& .aksel-expansioncard__content-inner {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.aksel-expansioncard__content-inner {
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
color: var(--ax-text-neutral);
|
||||
width: 100%;
|
||||
min-height: 3rem;
|
||||
display: inline-block;
|
||||
display: block;
|
||||
position: relative;
|
||||
padding: var(--ax-space-8);
|
||||
padding-right: var(--ax-space-40);
|
||||
@@ -81,10 +81,11 @@
|
||||
|
||||
.aksel-combobox2__field-chevron {
|
||||
position: absolute;
|
||||
font-size: 1.5rem;
|
||||
right: var(--ax-space-8);
|
||||
top: 0;
|
||||
margin-top: 0.75rem; /* We don't use dynamic centering because we don't want it to move if height changes */
|
||||
font-size: 1.5rem;
|
||||
pointer-events: none;
|
||||
margin-top: 12px; /* We don't use dynamic centering because we don't want it to move if height changes */
|
||||
color: var(--ax-text-neutral);
|
||||
|
||||
@media (forced-colors: active) {
|
||||
@@ -92,7 +93,7 @@
|
||||
}
|
||||
|
||||
.aksel-combobox2[data-size="small"] & {
|
||||
margin-top: 4px;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +237,12 @@
|
||||
.aksel-combobox2__option:is([aria-selected="true"], [aria-selected="true"][data-virtual-focus="true"])
|
||||
.aksel-listbox__option-inner {
|
||||
background-color: var(--ax-bg-moderate-pressedA);
|
||||
|
||||
@media (forced-colors: active) {
|
||||
background-color: SelectedItem;
|
||||
color: SelectedItemText;
|
||||
forced-color-adjust: none;
|
||||
}
|
||||
}
|
||||
|
||||
.aksel-combobox2__option-checkmark {
|
||||
|
||||
@@ -19,6 +19,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Checkbox and Radio card */
|
||||
.aksel-checkbox-card,
|
||||
.aksel-radio-card {
|
||||
width: 100%;
|
||||
align-content: flex-start;
|
||||
padding: var(--ax-space-16) var(--ax-space-20);
|
||||
cursor: pointer;
|
||||
background-color: var(--ax-bg-default);
|
||||
border: 1px solid var(--ax-border-neutral-subtleA);
|
||||
border-radius: var(--ax-radius-8);
|
||||
|
||||
&:focus-within::after,
|
||||
&:is(.aksel-checkbox--small, .aksel-radio--small):focus-within::after {
|
||||
inset: 0;
|
||||
border-radius: calc(var(--ax-radius-8) - 1px);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Checkbox and Radio card group */
|
||||
.aksel-checkbox-card-group > .aksel-checkboxes,
|
||||
.aksel-radio-card-group > .aksel-radio-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ax-space-12);
|
||||
|
||||
> .aksel-hgrid:not(.aksel-hgrid-gap) {
|
||||
gap: var(--ax-space-12);
|
||||
}
|
||||
}
|
||||
|
||||
.aksel-checkbox-card-group.aksel-checkbox-group--small > .aksel-checkboxes,
|
||||
.aksel-radio-card-group.aksel-radio-group--small > .aksel-radio-buttons {
|
||||
gap: var(--ax-space-8);
|
||||
|
||||
> .aksel-hgrid:not(.aksel-hgrid-gap) {
|
||||
gap: var(--ax-space-8);
|
||||
}
|
||||
}
|
||||
|
||||
/* Small */
|
||||
.aksel-checkbox--small,
|
||||
.aksel-radio--small {
|
||||
@@ -30,6 +70,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.aksel-checkbox-card.aksel-checkbox--small,
|
||||
.aksel-radio-card.aksel-radio--small {
|
||||
padding: var(--ax-space-12) var(--ax-space-16);
|
||||
}
|
||||
|
||||
/* Label */
|
||||
.aksel-checkbox__label,
|
||||
.aksel-radio__label {
|
||||
@@ -203,9 +248,38 @@
|
||||
--__axc-radio-checkbox-marker-border: 7px;
|
||||
}
|
||||
|
||||
/* Card border in hover and checked states */
|
||||
.aksel-checkbox-card:is(
|
||||
.aksel-checkbox--error:not(:where(.aksel-checkbox--disabled, .aksel-checkbox--readonly)),
|
||||
:hover:not(:where(.aksel-checkbox--disabled, .aksel-checkbox--readonly, .aksel-checkbox--error)),
|
||||
:has(.aksel-checkbox__input:checked):not(:where(.aksel-checkbox--disabled, .aksel-checkbox--readonly))
|
||||
),
|
||||
.aksel-radio-card:is(
|
||||
.aksel-radio--error:not(:where(.aksel-radio--disabled, .aksel-radio--readonly)),
|
||||
:hover:not(:where(.aksel-radio--disabled, .aksel-radio--readonly, .aksel-radio--error)),
|
||||
:has(> .aksel-radio__input:checked):not(:where(.aksel-radio--disabled, .aksel-radio--readonly))
|
||||
) {
|
||||
border-color: var(--ax-border-default);
|
||||
box-shadow: 0 0 0 1px var(--ax-border-default);
|
||||
}
|
||||
|
||||
/* Card background in checked state */
|
||||
.aksel-checkbox-card:has(.aksel-checkbox__input:checked):not(
|
||||
:where(.aksel-checkbox--disabled, .aksel-checkbox--readonly, .aksel-checkbox--error)
|
||||
),
|
||||
.aksel-radio-card:has(> .aksel-radio__input:checked):not(
|
||||
:where(.aksel-radio--disabled, .aksel-radio--readonly, .aksel-radio--error)
|
||||
) {
|
||||
background-color: var(--ax-bg-accent-moderateA);
|
||||
}
|
||||
|
||||
/* Marker colors in unchecked hover state (unless disabled) */
|
||||
.aksel-checkbox__input:hover:not(:disabled, :checked, :indeterminate),
|
||||
.aksel-radio__input:hover:not(:disabled, :checked) {
|
||||
.aksel-radio__input:hover:not(:disabled, :checked),
|
||||
.aksel-checkbox-card:hover:not(:where(.aksel-checkbox--disabled, .aksel-checkbox--readonly))
|
||||
.aksel-checkbox__input:not(:disabled, :checked, :indeterminate),
|
||||
.aksel-radio-card:hover:not(:where(.aksel-radio--disabled, .aksel-radio--readonly))
|
||||
> .aksel-radio__input:not(:disabled, :checked) {
|
||||
border-color: var(--ax-border-strong);
|
||||
background-color: var(--ax-bg-moderate-hoverA);
|
||||
}
|
||||
@@ -222,15 +296,25 @@
|
||||
opacity: var(--ax-opacity-disabled);
|
||||
}
|
||||
|
||||
/* Card background in readonly state */
|
||||
.aksel-checkbox-card:where(.aksel-checkbox--readonly),
|
||||
.aksel-radio-card:where(.aksel-radio--readonly) {
|
||||
background-color: var(--ax-bg-neutral-moderate);
|
||||
}
|
||||
|
||||
/* Cursor in disabled state on otherwise clickable elements */
|
||||
.aksel-checkbox--disabled :where(.aksel-checkbox__input, .aksel-checkbox__label),
|
||||
.aksel-radio--disabled > :where(.aksel-radio__input, .aksel-radio__label) {
|
||||
.aksel-radio--disabled > :where(.aksel-radio__input, .aksel-radio__label),
|
||||
.aksel-checkbox-card.aksel-checkbox--disabled,
|
||||
.aksel-radio-card.aksel-radio--disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Cursor in readonly state on otherwise clickable elements */
|
||||
.aksel-checkbox--readonly :where(.aksel-checkbox__input, .aksel-checkbox__label),
|
||||
.aksel-radio--readonly > :where(.aksel-radio__input, .aksel-radio__label) {
|
||||
.aksel-radio--readonly > :where(.aksel-radio__input, .aksel-radio__label),
|
||||
.aksel-checkbox-card.aksel-checkbox--readonly,
|
||||
.aksel-radio-card.aksel-radio--readonly {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,10 @@
|
||||
pointer-events: none;
|
||||
font-size: 1.5rem;
|
||||
|
||||
@media (forced-colors: active) {
|
||||
color: CanvasText;
|
||||
}
|
||||
|
||||
.aksel-search--disabled & {
|
||||
opacity: var(--ax-opacity-disabled);
|
||||
}
|
||||
|
||||
@@ -49,5 +49,9 @@
|
||||
outline: 2px solid var(--ax-border-focus);
|
||||
outline-offset: -2px;
|
||||
background-color: var(--ax-bg-neutral-moderate-hoverA);
|
||||
|
||||
@media (forced-colors: active) {
|
||||
outline-color: Highlight;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,10 @@
|
||||
|
||||
.aksel-read-more[data-state="closed"] & {
|
||||
display: none;
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Modifier xsmall size */
|
||||
.aksel-toggle-group--xsmall {
|
||||
& .aksel-toggle-group__button {
|
||||
padding: var(--ax-space-1) var(--ax-space-8);
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
|
||||
& .aksel-toggle-group__button-inner > svg {
|
||||
font-size: var(--ax-font-size-large);
|
||||
}
|
||||
}
|
||||
|
||||
/* High contrast */
|
||||
@media (forced-colors: active) {
|
||||
.aksel-toggle-group {
|
||||
|
||||
@@ -1,5 +1,87 @@
|
||||
# @navikt/ds-react
|
||||
|
||||
## 8.20.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Checkbox: Add `CheckboxCard` and `CheckboxCardGroup` components ([#5364](https://github.com/navikt/aksel/pull/5364))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Lookup, Badge: Mark as deprecated when imported from @navikt/ds-react/PREVIEW ([#5401](https://github.com/navikt/aksel/pull/5401))
|
||||
|
||||
- ToggleGroup: Add xsmall as size ([#5411](https://github.com/navikt/aksel/pull/5411))
|
||||
|
||||
- Build: `next build --webpack` now works when package is part of `optimizePackageImports` ([#5422](https://github.com/navikt/aksel/pull/5422))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@8.20.0
|
||||
- @navikt/aksel-icons@8.20.0
|
||||
|
||||
## 8.19.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- TextField: Now inherits `readOnly` from Fieldset. No longer gets read-only styling when `disabled` and `readOnly`. ([#5328](https://github.com/navikt/aksel/pull/5328))
|
||||
|
||||
- Textarea: Now inherits `readOnly` from Fieldset. No longer gets read-only styling when `disabled` and `readOnly`. ([#5328](https://github.com/navikt/aksel/pull/5328))
|
||||
|
||||
- Fieldset: Nested Fieldsets now inherits the parent's `disabled` state. ([#5328](https://github.com/navikt/aksel/pull/5328))
|
||||
|
||||
- Button: `loading={true}` and `disabled={false}` now disable the element. ([#5328](https://github.com/navikt/aksel/pull/5328))
|
||||
|
||||
- Switch: `loading={true}` and `disabled={false}` now disable the element. ([#5328](https://github.com/navikt/aksel/pull/5328))
|
||||
|
||||
- Search.Button: `loading={true}` and `disabled={false}` now disable SearchButton. ([#5328](https://github.com/navikt/aksel/pull/5328))
|
||||
|
||||
- Badge: No longer sets aria-hidden internally automatically. ([#5397](https://github.com/navikt/aksel/pull/5397))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@8.19.2
|
||||
- @navikt/aksel-icons@8.19.2
|
||||
|
||||
## 8.19.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@8.19.1
|
||||
- @navikt/aksel-icons@8.19.1
|
||||
|
||||
## 8.19.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`c5dd309`](https://github.com/navikt/aksel/commit/c5dd3097e51ef2a9f79964248398bd158660bd3b)]:
|
||||
- @navikt/aksel-icons@8.19.0
|
||||
- @navikt/ds-tokens@8.19.0
|
||||
|
||||
## 8.18.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Radio: Add RadioCard and RadioCardGroup components ([#5276](https://github.com/navikt/aksel/pull/5276))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Badge, Lookup: now available outside of Preview scope ([#5344](https://github.com/navikt/aksel/pull/5344))
|
||||
|
||||
- DatePicker: Use without hook now correctly closes picker on date-selection ([#5327](https://github.com/navikt/aksel/pull/5327))
|
||||
|
||||
- ActionMenu: Menu is no longer modal, Page scroll and interaction outside the menu are no longer blocked while open. ([#5296](https://github.com/navikt/aksel/pull/5296))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@8.18.0
|
||||
- @navikt/aksel-icons@8.18.0
|
||||
|
||||
## 8.17.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`9673de4`](https://github.com/navikt/aksel/commit/9673de4c264479dd8ca04897003532595acad255)]:
|
||||
- @navikt/aksel-icons@8.17.2
|
||||
- @navikt/ds-tokens@8.17.2
|
||||
|
||||
## 8.17.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-react",
|
||||
"version": "8.17.1",
|
||||
"version": "8.20.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",
|
||||
@@ -68,22 +68,42 @@
|
||||
},
|
||||
"./PREVIEW/Lookup": {
|
||||
"import": {
|
||||
"types": "./esm/lookup/index.d.ts",
|
||||
"default": "./esm/lookup/index.js"
|
||||
"types": "./esm/lookup/index-deprecated.d.ts",
|
||||
"default": "./esm/lookup/index-deprecated.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./cjs/lookup/index.d.ts",
|
||||
"default": "./cjs/lookup/index.js"
|
||||
"types": "./cjs/lookup/index-deprecated.d.ts",
|
||||
"default": "./cjs/lookup/index-deprecated.js"
|
||||
}
|
||||
},
|
||||
"./PREVIEW/Badge": {
|
||||
"import": {
|
||||
"types": "./esm/badge/index.d.ts",
|
||||
"default": "./esm/badge/index.js"
|
||||
"types": "./esm/badge/index-deprecated.d.ts",
|
||||
"default": "./esm/badge/index-deprecated.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./cjs/badge/index.d.ts",
|
||||
"default": "./cjs/badge/index.js"
|
||||
"types": "./cjs/badge/index-deprecated.d.ts",
|
||||
"default": "./cjs/badge/index-deprecated.js"
|
||||
}
|
||||
},
|
||||
"./PREVIEW/RadioCard": {
|
||||
"import": {
|
||||
"types": "./esm/form/radio-card/index.d.ts",
|
||||
"default": "./esm/form/radio-card/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./cjs/form/radio-card/index.d.ts",
|
||||
"default": "./cjs/form/radio-card/index.js"
|
||||
}
|
||||
},
|
||||
"./PREVIEW/CheckboxCard": {
|
||||
"import": {
|
||||
"types": "./esm/form/checkbox-card/index.d.ts",
|
||||
"default": "./esm/form/checkbox-card/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./cjs/form/checkbox-card/index.d.ts",
|
||||
"default": "./cjs/form/checkbox-card/index.js"
|
||||
}
|
||||
},
|
||||
"./types/theme": {
|
||||
@@ -719,6 +739,26 @@
|
||||
"default": "./cjs/dialog/index.js"
|
||||
}
|
||||
},
|
||||
"./Lookup": {
|
||||
"import": {
|
||||
"types": "./esm/lookup/index.d.ts",
|
||||
"default": "./esm/lookup/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./cjs/lookup/index.d.ts",
|
||||
"default": "./cjs/lookup/index.js"
|
||||
}
|
||||
},
|
||||
"./Badge": {
|
||||
"import": {
|
||||
"types": "./esm/badge/index.d.ts",
|
||||
"default": "./esm/badge/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./cjs/badge/index.d.ts",
|
||||
"default": "./cjs/badge/index.js"
|
||||
}
|
||||
},
|
||||
"./package.json": "./package.json",
|
||||
"./Theme": {
|
||||
"import": {
|
||||
@@ -748,8 +788,8 @@
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "0.27.20",
|
||||
"@floating-ui/react-dom": "^2.1.9",
|
||||
"@navikt/aksel-icons": "^8.17.1",
|
||||
"@navikt/ds-tokens": "^8.17.1",
|
||||
"@navikt/aksel-icons": "^8.20.0",
|
||||
"@navikt/ds-tokens": "^8.20.0",
|
||||
"date-fns": "^4.0.0",
|
||||
"react-day-picker": "10.0.1"
|
||||
},
|
||||
@@ -757,23 +797,22 @@
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@types/jscodeshift": "17.3.0",
|
||||
"@types/node": "^24.10.9",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"aksel": "workspace:^",
|
||||
"concurrently": "10.0.4",
|
||||
"concurrently": "10.0.6",
|
||||
"copyfiles": "^2.4.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"jscodeshift": "17.4.0",
|
||||
"jsdom": "29.1.1",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"rimraf": "6.1.3",
|
||||
"swr": "^2.4.2",
|
||||
"tsc-alias": "1.9.1",
|
||||
"tsx": "^4.23.1",
|
||||
"tsc-alias": "1.9.7",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.11"
|
||||
},
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { type ParsedMeta, parseMetaFiles } from "./parse-meta";
|
||||
import { type ParsedMeta, readMetaFiles } from "./parse-meta";
|
||||
import { validateMetas } from "./validate-meta";
|
||||
|
||||
describe("component metadata files", () => {
|
||||
test("all meta files satisfy the metadata spec", () => {
|
||||
const metas = parseMetaFiles();
|
||||
const metas = readMetaFiles();
|
||||
expect(validateMetas(metas)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fg from "fast-glob";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import ts from "typescript";
|
||||
import { META_GLOB, packageRoot, tsconfigPath } from "./paths";
|
||||
@@ -34,6 +35,21 @@ interface ParsedMeta {
|
||||
utils: ResolvedEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Syntax-only contents of one `*.meta.ts` file. `components`/`utils` hold the
|
||||
* map labels, e.g. `"Accordion.Item"`, without resolving their declarations.
|
||||
*/
|
||||
interface MetaSummary extends Omit<ParsedMeta, "components" | "utils"> {
|
||||
components: string[];
|
||||
utils: string[];
|
||||
}
|
||||
|
||||
/** Syntactic parts of a meta file, shared by the summary and full parsers. */
|
||||
interface MetaSource extends Omit<ParsedMeta, "components" | "utils"> {
|
||||
componentsMap: ts.ObjectLiteralExpression;
|
||||
utilsMap: ts.ObjectLiteralExpression | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts exported "metadata" object through AST
|
||||
*/
|
||||
@@ -146,14 +162,122 @@ function createTsProgram(metaFiles: string[]): ts.Program {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the display label of a `components`/`utils` map entry, or
|
||||
* `undefined` for entries that are neither `Name` nor `"Label": Name`.
|
||||
*/
|
||||
function getEntryLabel(entry: ts.ObjectLiteralElementLike) {
|
||||
if (ts.isShorthandPropertyAssignment(entry)) {
|
||||
return entry.name.text;
|
||||
}
|
||||
if (ts.isPropertyAssignment(entry) && ts.isIdentifier(entry.initializer)) {
|
||||
return ts.isStringLiteral(entry.name)
|
||||
? entry.name.text
|
||||
: entry.name.getText();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a `components`/`utils` map from the metadata object. Throws when the
|
||||
* property exists but is not an object literal.
|
||||
*/
|
||||
function readEntryMap(
|
||||
metadataNode: ts.ObjectLiteralExpression,
|
||||
key: "components" | "utils",
|
||||
relMeta: string,
|
||||
) {
|
||||
const prop = getObjectProperty(metadataNode, key);
|
||||
if (!prop) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
!ts.isPropertyAssignment(prop) ||
|
||||
!ts.isObjectLiteralExpression(prop.initializer)
|
||||
) {
|
||||
throw new Error(`[${relMeta}] metadata.${key} must be an object literal.`);
|
||||
}
|
||||
return prop.initializer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the syntactic parts of a meta file. Needs no type information, so it
|
||||
* works on a standalone `ts.SourceFile`.
|
||||
*/
|
||||
function readMetaSource(
|
||||
sourceFile: ts.SourceFile,
|
||||
metaFile: string,
|
||||
): MetaSource {
|
||||
const relMeta = path.relative(packageRoot, metaFile);
|
||||
|
||||
const metadataNode = findMetadataObject(sourceFile);
|
||||
if (!metadataNode) {
|
||||
throw new Error(`[${relMeta}] Could not find a "metadata" object.`);
|
||||
}
|
||||
|
||||
const name = readStringLiteralProperty(metadataNode, "name");
|
||||
if (!name) {
|
||||
throw new Error(`[${relMeta}] metadata.name must be a string literal.`);
|
||||
}
|
||||
|
||||
const componentsMap = readEntryMap(metadataNode, "components", relMeta);
|
||||
if (!componentsMap) {
|
||||
throw new Error(
|
||||
`[${relMeta}] metadata.components must be an object literal.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
dir: path.relative(packageRoot, path.dirname(metaFile)),
|
||||
metaFile: relMeta,
|
||||
keywords: readStringArrayProperty(metadataNode, "keywords"),
|
||||
related: readStringArrayProperty(metadataNode, "related"),
|
||||
componentsMap,
|
||||
utilsMap: readEntryMap(metadataNode, "utils", relMeta),
|
||||
};
|
||||
}
|
||||
|
||||
function findMetaFiles() {
|
||||
return fg.sync(META_GLOB, { cwd: packageRoot, absolute: true }).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads every `*.meta.ts` file under the package without building a
|
||||
* TypeScript program. Much faster than {@link parseMetaFiles}, but entries are
|
||||
* not resolved to declarations. Use for spec validation only.
|
||||
*/
|
||||
function readMetaFiles(): MetaSummary[] {
|
||||
const getLabels = (map: ts.ObjectLiteralExpression) =>
|
||||
map.properties
|
||||
.map(getEntryLabel)
|
||||
.filter((label): label is string => Boolean(label));
|
||||
|
||||
return findMetaFiles().map((metaFile) => {
|
||||
const sourceFile = ts.createSourceFile(
|
||||
metaFile,
|
||||
readFileSync(metaFile, "utf8"),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const { componentsMap, utilsMap, ...meta } = readMetaSource(
|
||||
sourceFile,
|
||||
metaFile,
|
||||
);
|
||||
return {
|
||||
...meta,
|
||||
components: getLabels(componentsMap),
|
||||
utils: utilsMap ? getLabels(utilsMap) : [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads every `*.meta.ts` file under the package and resolves its
|
||||
* `components`/`utils` identifiers to their declarations.
|
||||
*/
|
||||
function parseMetaFiles(): ParsedMeta[] {
|
||||
const metaFiles = fg
|
||||
.sync(META_GLOB, { cwd: packageRoot, absolute: true })
|
||||
.sort();
|
||||
const metaFiles = findMetaFiles();
|
||||
|
||||
if (metaFiles.length === 0) {
|
||||
return [];
|
||||
@@ -177,30 +301,19 @@ function parseMetaFiles(): ParsedMeta[] {
|
||||
const entries: ResolvedEntry[] = [];
|
||||
|
||||
for (const entry of mapExpression.properties) {
|
||||
let label: string | undefined;
|
||||
let symbol: ts.Symbol | undefined;
|
||||
|
||||
if (ts.isShorthandPropertyAssignment(entry)) {
|
||||
label = entry.name.text;
|
||||
symbol = resolveAliasedSymbol(
|
||||
typeChecker.getShorthandAssignmentValueSymbol(entry),
|
||||
);
|
||||
} else if (
|
||||
ts.isPropertyAssignment(entry) &&
|
||||
ts.isIdentifier(entry.initializer)
|
||||
) {
|
||||
label = ts.isStringLiteral(entry.name)
|
||||
? entry.name.text
|
||||
: entry.name.getText();
|
||||
symbol = resolveAliasedSymbol(
|
||||
typeChecker.getSymbolAtLocation(entry.initializer),
|
||||
);
|
||||
}
|
||||
|
||||
const label = getEntryLabel(entry);
|
||||
if (!label) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let symbol: ts.Symbol | undefined;
|
||||
if (ts.isShorthandPropertyAssignment(entry)) {
|
||||
symbol = typeChecker.getShorthandAssignmentValueSymbol(entry);
|
||||
} else if (ts.isPropertyAssignment(entry)) {
|
||||
symbol = typeChecker.getSymbolAtLocation(entry.initializer);
|
||||
}
|
||||
symbol = resolveAliasedSymbol(symbol);
|
||||
|
||||
const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0];
|
||||
if (!symbol || !declaration) {
|
||||
throw new Error(
|
||||
@@ -222,74 +335,24 @@ function parseMetaFiles(): ParsedMeta[] {
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a `components`/`utils` map from the metadata object and resolves its
|
||||
* entries. Throws when a required map is missing or either map is not an
|
||||
* object literal.
|
||||
*/
|
||||
function resolveEntryMap(
|
||||
metadataNode: ts.ObjectLiteralExpression,
|
||||
key: "components" | "utils",
|
||||
metaFile: string,
|
||||
relMeta: string,
|
||||
required: boolean,
|
||||
): ResolvedEntry[] {
|
||||
const prop = getObjectProperty(metadataNode, key);
|
||||
if (!prop) {
|
||||
if (required) {
|
||||
throw new Error(
|
||||
`[${relMeta}] metadata.${key} must be an object literal.`,
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
if (
|
||||
!ts.isPropertyAssignment(prop) ||
|
||||
!ts.isObjectLiteralExpression(prop.initializer)
|
||||
) {
|
||||
throw new Error(
|
||||
`[${relMeta}] metadata.${key} must be an object literal.`,
|
||||
);
|
||||
}
|
||||
return resolveEntries(prop.initializer, metaFile);
|
||||
}
|
||||
|
||||
function parseMetaFile(metaFile: string): ParsedMeta {
|
||||
const relMeta = path.relative(packageRoot, metaFile);
|
||||
return metaFiles.map((metaFile) => {
|
||||
const sourceFile = tsProgram.getSourceFile(metaFile);
|
||||
if (!sourceFile) {
|
||||
throw new Error(`Could not load meta file: ${relMeta}`);
|
||||
throw new Error(
|
||||
`Could not load meta file: ${path.relative(packageRoot, metaFile)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const metadataNode = findMetadataObject(sourceFile);
|
||||
if (!metadataNode) {
|
||||
throw new Error(`[${relMeta}] Could not find a "metadata" object.`);
|
||||
}
|
||||
|
||||
const name = readStringLiteralProperty(metadataNode, "name");
|
||||
if (!name) {
|
||||
throw new Error(`[${relMeta}] metadata.name must be a string literal.`);
|
||||
}
|
||||
|
||||
const { componentsMap, utilsMap, ...meta } = readMetaSource(
|
||||
sourceFile,
|
||||
metaFile,
|
||||
);
|
||||
return {
|
||||
name,
|
||||
dir: path.relative(packageRoot, path.dirname(metaFile)),
|
||||
metaFile: relMeta,
|
||||
keywords: readStringArrayProperty(metadataNode, "keywords"),
|
||||
related: readStringArrayProperty(metadataNode, "related"),
|
||||
components: resolveEntryMap(
|
||||
metadataNode,
|
||||
"components",
|
||||
metaFile,
|
||||
relMeta,
|
||||
true,
|
||||
),
|
||||
utils: resolveEntryMap(metadataNode, "utils", metaFile, relMeta, false),
|
||||
...meta,
|
||||
components: resolveEntries(componentsMap, metaFile),
|
||||
utils: utilsMap ? resolveEntries(utilsMap, metaFile) : [],
|
||||
};
|
||||
}
|
||||
|
||||
return metaFiles.map(parseMetaFile);
|
||||
});
|
||||
}
|
||||
|
||||
export { parseMetaFiles };
|
||||
export type { ResolvedEntry, ParsedMeta };
|
||||
export { parseMetaFiles, readMetaFiles };
|
||||
export type { ResolvedEntry, ParsedMeta, MetaSummary };
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ParsedMeta } from "./parse-meta";
|
||||
import type { MetaSummary, ParsedMeta } from "./parse-meta";
|
||||
|
||||
/** Characters allowed in a Sanity document `_id`. */
|
||||
const SANITY_ID_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
||||
@@ -9,7 +9,7 @@ const SANITY_ID_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
||||
* valid). Kept pure so it can be exercised both by the extractor (which throws
|
||||
* on any error) and by the test-suite.
|
||||
*/
|
||||
function validateMetas(metas: ParsedMeta[]): string[] {
|
||||
function validateMetas(metas: (ParsedMeta | MetaSummary)[]): string[] {
|
||||
const errors: string[] = [];
|
||||
const nameToMeta = new Map<string, string>();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef } from "react";
|
||||
import { useModalContext } from "../../modal/Modal.context";
|
||||
import { useModalContext } from "../../modal/root/ModalRoot.context";
|
||||
import { useId } from "../../utils-external";
|
||||
import {
|
||||
Menu,
|
||||
@@ -102,7 +102,7 @@ const ActionMenuRoot = ({
|
||||
rootElement={rootElement}
|
||||
size={size}
|
||||
>
|
||||
<Menu open={open} onOpenChange={setOpen} modal>
|
||||
<Menu open={open} onOpenChange={setOpen} modal={false}>
|
||||
{children}
|
||||
</Menu>
|
||||
</ActionMenuProvider>
|
||||
|
||||
@@ -19,12 +19,12 @@ export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Badge>;
|
||||
|
||||
const statuses: { color: AkselColor; label: string }[] = [
|
||||
{ color: "danger", label: "Avslått" },
|
||||
{ color: "success", label: "Innvilget" },
|
||||
{ color: "info", label: "Under behandling" },
|
||||
{ color: "warning", label: "Mangler dokumentasjon" },
|
||||
{ color: "neutral", label: "Ikke startet" },
|
||||
const statuses: AkselColor[] = [
|
||||
"danger",
|
||||
"success",
|
||||
"info",
|
||||
"warning",
|
||||
"neutral",
|
||||
];
|
||||
|
||||
export const Default: Story = {
|
||||
@@ -37,8 +37,8 @@ export const Default: Story = {
|
||||
export const Dot: Story = {
|
||||
render: () => (
|
||||
<HStack gap="space-16" align="center">
|
||||
{statuses.map(({ color, label }) => (
|
||||
<Badge key={color} data-color={color} aria-label={label} />
|
||||
{statuses.map((color) => (
|
||||
<Badge key={color} data-color={color} />
|
||||
))}
|
||||
</HStack>
|
||||
),
|
||||
@@ -58,13 +58,13 @@ export const Count: Story = {
|
||||
export const Anchored: Story = {
|
||||
render: () => (
|
||||
<HStack gap="space-32" align="center">
|
||||
<Badge data-color="danger" count={42}>
|
||||
<Badge data-color="danger" count={42} aria-hidden>
|
||||
<Button
|
||||
icon={<InboxIcon aria-hidden />}
|
||||
aria-label="Innboks, 42 nye meldinger"
|
||||
/>
|
||||
</Badge>
|
||||
<Badge data-color="danger" aria-label="Nytt varsel">
|
||||
<Badge data-color="danger" aria-hidden>
|
||||
<Button icon={<InboxIcon aria-hidden />} aria-label="Innboks" />
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
@@ -35,9 +35,8 @@ interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
* Use standalone for inline indicators/counts, or pass the element you want to
|
||||
* anchor it to as `children` to pin the badge to one of its corners.
|
||||
*
|
||||
* Accessibility: A badge with no label is treated as decorative when it is a
|
||||
* dot or anchored to another element. When anchoring to an interactive element,
|
||||
* fold the state into that element's accessible name
|
||||
* When anchoring to an interactive element,
|
||||
* set `aria-hidden` on the badge and fold the count into that element's accessible name
|
||||
* (e.g. `aria-label="Inbox, 42 new messages"`).
|
||||
*
|
||||
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/badge)
|
||||
@@ -46,11 +45,11 @@ interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
* @example
|
||||
* ```jsx
|
||||
* // Standalone
|
||||
* <Badge data-color="success" aria-label="Active" />
|
||||
* <Badge data-color="success" />
|
||||
* <Badge data-color="danger" count={42} />
|
||||
*
|
||||
* // Anchored to an element
|
||||
* <Badge data-color="danger" count={42}>
|
||||
* <Badge data-color="danger" count={42} aria-hidden>
|
||||
* <Button icon={<InboxIcon />} aria-label="Inbox, 42 new messages" />
|
||||
* </Badge>
|
||||
* ```
|
||||
@@ -61,10 +60,6 @@ const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
|
||||
children,
|
||||
className,
|
||||
"data-color": color = "danger",
|
||||
"aria-label": ariaLabel,
|
||||
"aria-labelledby": ariaLabelledby,
|
||||
role,
|
||||
"aria-hidden": ariaHidden,
|
||||
count,
|
||||
maxCount = 99,
|
||||
...rest
|
||||
@@ -75,10 +70,6 @@ const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
|
||||
count == null ? undefined : normalizeCount(count, 0);
|
||||
const normalizedMaxCount = normalizeCount(maxCount, 99);
|
||||
const isDot = normalizedCount == null;
|
||||
const hasLabel =
|
||||
Boolean(ariaLabel?.trim()) || Boolean(ariaLabelledby?.trim());
|
||||
const isDecorative =
|
||||
role == null && !hasLabel && (isDot || children != null);
|
||||
|
||||
const badge = (
|
||||
<BodyShort
|
||||
@@ -89,10 +80,6 @@ const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
|
||||
weight="semibold"
|
||||
data-color={color}
|
||||
data-dot={isDot || undefined}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledby}
|
||||
role={role}
|
||||
aria-hidden={ariaHidden ?? (isDecorative || undefined)}
|
||||
className={cl("aksel-badge", className)}
|
||||
>
|
||||
{isDot
|
||||
@@ -120,10 +107,16 @@ function normalizeCount(value: number, fallback: number) {
|
||||
return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : fallback;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace, import/export
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
export namespace Badge {
|
||||
export type Props = BadgeProps;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/export
|
||||
export { Badge };
|
||||
/** @deprecated Badge is no longer in preview. Import it from `@navikt/ds-react` instead. */
|
||||
const BadgeDeprecated = Badge;
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace BadgeDeprecated {
|
||||
export type Props = BadgeProps;
|
||||
}
|
||||
|
||||
export { Badge, BadgeDeprecated };
|
||||
@@ -0,0 +1,2 @@
|
||||
"use client";
|
||||
export { BadgeDeprecated as Badge } from "./Badge";
|
||||
@@ -105,9 +105,9 @@ export const Button: OverridableComponent<ButtonProps, HTMLButtonElement> =
|
||||
className={cl(className, "aksel-button", `aksel-button--${size}`, {
|
||||
"aksel-button--loading": loading,
|
||||
"aksel-button--icon-only": !!icon && !children,
|
||||
"aksel-button--disabled": disabled ?? loading,
|
||||
"aksel-button--disabled": disabled || loading,
|
||||
})}
|
||||
disabled={(disabled ?? loading) ? true : undefined}
|
||||
disabled={disabled || loading ? true : undefined}
|
||||
>
|
||||
{icon && iconPosition === "left" && (
|
||||
<span className="aksel-button__icon">{icon}</span>
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from "react";
|
||||
import { PersonIcon } from "@navikt/aksel-icons";
|
||||
import { VStack } from "../primitives/stack";
|
||||
import { renderStoriesForChromatic } from "../utils/renderStoriesForChromatic";
|
||||
import Chat, { POSITIONS, SIZES, VARIANTS } from "./Chat";
|
||||
import { Chat, POSITIONS, SIZES, VARIANTS } from "./root/ChatRoot";
|
||||
|
||||
export default {
|
||||
title: "ds-react/Chat",
|
||||
|
||||
+6
-5
@@ -1,8 +1,8 @@
|
||||
import React, { type HTMLAttributes, forwardRef } from "react";
|
||||
import { Detail, Heading, type HeadingProps } from "../typography";
|
||||
import { cl } from "../utils/helpers";
|
||||
import { Detail, Heading, type HeadingProps } from "../../typography";
|
||||
import { cl } from "../../utils/helpers";
|
||||
|
||||
export interface ChatBubbleProps extends HTMLAttributes<HTMLDivElement> {
|
||||
interface ChatBubbleProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Bubble text.
|
||||
*/
|
||||
@@ -26,7 +26,7 @@ export interface ChatBubbleProps extends HTMLAttributes<HTMLDivElement> {
|
||||
toptextHeadingLevel?: Exclude<HeadingProps["level"], "1">;
|
||||
}
|
||||
|
||||
const Bubble = forwardRef<HTMLDivElement, ChatBubbleProps>(
|
||||
const ChatBubble = forwardRef<HTMLDivElement, ChatBubbleProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
@@ -65,4 +65,5 @@ const Bubble = forwardRef<HTMLDivElement, ChatBubbleProps>(
|
||||
},
|
||||
);
|
||||
|
||||
export default Bubble;
|
||||
export { ChatBubble };
|
||||
export type { ChatBubbleProps };
|
||||
@@ -1,3 +1,3 @@
|
||||
"use client";
|
||||
export { default as Chat, ChatBubble } from "./Chat";
|
||||
export type { ChatProps, ChatBubbleProps } from "./Chat";
|
||||
export { Chat, ChatBubble } from "./root/ChatRoot";
|
||||
export type { ChatProps, ChatBubbleProps } from "./root/ChatRoot";
|
||||
+33
-38
@@ -1,13 +1,13 @@
|
||||
import React, { type HTMLAttributes, forwardRef } from "react";
|
||||
import type { AkselStatusColorRole } from "@navikt/ds-tokens/types";
|
||||
import type { AkselColor } from "../types";
|
||||
import { BodyLong, type HeadingProps } from "../typography";
|
||||
import { cl } from "../utils/helpers";
|
||||
import Bubble, { type ChatBubbleProps } from "./Bubble";
|
||||
import type { AkselColor } from "../../types";
|
||||
import { BodyLong, type HeadingProps } from "../../typography";
|
||||
import { cl } from "../../utils/helpers";
|
||||
import { ChatBubble, type ChatBubbleProps } from "../bubble/ChatBubble";
|
||||
|
||||
export const VARIANTS = ["subtle", "info", "neutral"] as const;
|
||||
export const POSITIONS = ["left", "right"] as const;
|
||||
export const SIZES = ["medium", "small"] as const;
|
||||
const VARIANTS = ["subtle", "info", "neutral"] as const;
|
||||
const POSITIONS = ["left", "right"] as const;
|
||||
const SIZES = ["medium", "small"] as const;
|
||||
|
||||
interface ChatProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
@@ -62,33 +62,7 @@ interface ChatProps extends HTMLAttributes<HTMLDivElement> {
|
||||
"data-color"?: Exclude<AkselColor, AkselStatusColorRole>;
|
||||
}
|
||||
|
||||
interface ChatComponent extends React.ForwardRefExoticComponent<
|
||||
ChatProps & React.RefAttributes<HTMLDivElement>
|
||||
> {
|
||||
/**
|
||||
* @see 🏷️ {@link ChatBubbleProps}
|
||||
*/
|
||||
Bubble: typeof Bubble;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component for communicating a dialog between two or more parties.
|
||||
*
|
||||
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/chat)
|
||||
* @see 🏷️ {@link ChatProps}
|
||||
*
|
||||
* @example
|
||||
* ```jsx
|
||||
* <Chat avatar="A" name="Alice" timestamp="01.01.21 14:00">
|
||||
* <Chat.Bubble>Hello!</Chat.Bubble>
|
||||
* <Chat.Bubble>How can I help you?</Chat.Bubble>
|
||||
* </Chat>
|
||||
* <Chat avatar="B" name="Bob" timestamp="01.01.21 14:01" position="right">
|
||||
* <Chat.Bubble>Hi there!</Chat.Bubble>
|
||||
* </Chat>
|
||||
* ```
|
||||
*/
|
||||
export const Chat = forwardRef<HTMLDivElement, ChatProps>(
|
||||
const ChatRoot = forwardRef<HTMLDivElement, ChatProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
@@ -143,7 +117,7 @@ export const Chat = forwardRef<HTMLDivElement, ChatProps>(
|
||||
</div>
|
||||
);
|
||||
},
|
||||
) as ChatComponent;
|
||||
);
|
||||
|
||||
function variantToColor(variant: ChatProps["variant"]): AkselColor {
|
||||
switch (variant) {
|
||||
@@ -158,8 +132,29 @@ function variantToColor(variant: ChatProps["variant"]): AkselColor {
|
||||
}
|
||||
}
|
||||
|
||||
Chat.Bubble = Bubble;
|
||||
/**
|
||||
* A component for communicating a dialog between two or more parties.
|
||||
*
|
||||
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/chat)
|
||||
* @see 🏷️ {@link ChatProps}
|
||||
*
|
||||
* @example
|
||||
* ```jsx
|
||||
* <Chat avatar="A" name="Alice" timestamp="01.01.21 14:00">
|
||||
* <Chat.Bubble>Hello!</Chat.Bubble>
|
||||
* <Chat.Bubble>How can I help you?</Chat.Bubble>
|
||||
* </Chat>
|
||||
* <Chat avatar="B" name="Bob" timestamp="01.01.21 14:01" position="right">
|
||||
* <Chat.Bubble>Hi there!</Chat.Bubble>
|
||||
* </Chat>
|
||||
* ```
|
||||
*/
|
||||
const Chat = Object.assign(ChatRoot, {
|
||||
/**
|
||||
* @see 🏷️ {@link ChatBubbleProps}
|
||||
*/
|
||||
Bubble: ChatBubble,
|
||||
});
|
||||
|
||||
export default Chat;
|
||||
export { Bubble as ChatBubble };
|
||||
export { Chat, ChatBubble, VARIANTS, POSITIONS, SIZES };
|
||||
export type { ChatProps, ChatBubbleProps };
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
export { default as Chips, type ChipsProps } from "./root/ChipsRoot";
|
||||
export {
|
||||
ChipsRemovable,
|
||||
type ChipsRemovableProps,
|
||||
} from "./removable/ChipsRemovable";
|
||||
export { ChipsToggle, type ChipsToggleProps } from "./toggle/ChipsToggle";
|
||||
export { Chips, ChipsRemovable, ChipsToggle } from "./root/ChipsRoot";
|
||||
export type {
|
||||
ChipsProps,
|
||||
ChipsRemovableProps,
|
||||
ChipsToggleProps,
|
||||
} from "./root/ChipsRoot";
|
||||
@@ -4,7 +4,7 @@ import type { AkselColor } from "../../types";
|
||||
import { cl, composeEventHandlers } from "../../utils/helpers";
|
||||
import { useI18n } from "../../utils/i18n/i18n.hooks";
|
||||
|
||||
export interface ChipsRemovableProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
interface ChipsRemovableProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: string;
|
||||
/**
|
||||
* @deprecated Use `data-color` prop instead.
|
||||
@@ -22,10 +22,7 @@ export interface ChipsRemovableProps extends React.ButtonHTMLAttributes<HTMLButt
|
||||
"data-color"?: AkselColor;
|
||||
}
|
||||
|
||||
export const ChipsRemovable = forwardRef<
|
||||
HTMLButtonElement,
|
||||
ChipsRemovableProps
|
||||
>(
|
||||
const ChipsRemovable = forwardRef<HTMLButtonElement, ChipsRemovableProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
@@ -76,4 +73,5 @@ function variantToColor(
|
||||
}
|
||||
}
|
||||
|
||||
export default ChipsRemovable;
|
||||
export { ChipsRemovable };
|
||||
export type { ChipsRemovableProps };
|
||||
@@ -1,7 +1,10 @@
|
||||
import React, { type HTMLAttributes, forwardRef } from "react";
|
||||
import { cl } from "../../utils/helpers";
|
||||
import { ChipsRemovable } from "../removable/ChipsRemovable";
|
||||
import { ChipsToggle } from "../toggle/ChipsToggle";
|
||||
import {
|
||||
ChipsRemovable,
|
||||
type ChipsRemovableProps,
|
||||
} from "../removable/ChipsRemovable";
|
||||
import { ChipsToggle, type ChipsToggleProps } from "../toggle/ChipsToggle";
|
||||
|
||||
interface ChipsProps extends HTMLAttributes<HTMLUListElement> {
|
||||
children: React.ReactNode;
|
||||
@@ -12,22 +15,24 @@ interface ChipsProps extends HTMLAttributes<HTMLUListElement> {
|
||||
size?: "medium" | "small";
|
||||
}
|
||||
|
||||
interface ChipsComponent extends React.ForwardRefExoticComponent<
|
||||
ChipsProps & React.RefAttributes<HTMLUListElement>
|
||||
> {
|
||||
/**
|
||||
* Toggle between selected-states.
|
||||
* @see 🏷️ {@link ToggleChipsProps}
|
||||
* @see [🤖 OverridableComponent](https://aksel.nav.no/grunnleggende/kode/overridablecomponent) support
|
||||
*/
|
||||
Toggle: typeof ChipsToggle;
|
||||
/**
|
||||
* Remove filter or the likes on click.
|
||||
* @see 🏷️ {@link RemovableChipsProps}
|
||||
* @see [🤖 OverridableComponent](https://aksel.nav.no/grunnleggende/kode/overridablecomponent) support
|
||||
*/
|
||||
Removable: typeof ChipsRemovable;
|
||||
}
|
||||
const ChipsRoot = forwardRef<HTMLUListElement, ChipsProps>(
|
||||
({ className, size = "medium", children, ...rest }, ref) => {
|
||||
return (
|
||||
<ul
|
||||
{...rest}
|
||||
ref={ref}
|
||||
className={cl("aksel-chips", className, `aksel-chips--${size}`, {
|
||||
"aksel-body-short aksel-body-short--small": size === "medium",
|
||||
"aksel-detail aksel-detail--small": size === "small",
|
||||
})}
|
||||
>
|
||||
{React.Children.map(children, (chip, index) => {
|
||||
return <li key={chip?.toString() || index}>{chip}</li>;
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* A component that displays a list of items as chips.
|
||||
@@ -56,27 +61,20 @@ interface ChipsComponent extends React.ForwardRefExoticComponent<
|
||||
* </Chips>
|
||||
* ```
|
||||
*/
|
||||
export const Chips = forwardRef<HTMLUListElement, ChipsProps>(
|
||||
({ className, size = "medium", children, ...rest }, ref) => {
|
||||
return (
|
||||
<ul
|
||||
{...rest}
|
||||
ref={ref}
|
||||
className={cl("aksel-chips", className, `aksel-chips--${size}`, {
|
||||
"aksel-body-short aksel-body-short--small": size === "medium",
|
||||
"aksel-detail aksel-detail--small": size === "small",
|
||||
})}
|
||||
>
|
||||
{React.Children.map(children, (chip, index) => {
|
||||
return <li key={chip?.toString() || index}>{chip}</li>;
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
) as ChipsComponent;
|
||||
const Chips = Object.assign(ChipsRoot, {
|
||||
/**
|
||||
* Toggle between selected-states.
|
||||
* @see 🏷️ {@link ChipsToggleProps}
|
||||
* @see [🤖 OverridableComponent](https://aksel.nav.no/grunnleggende/kode/overridablecomponent) support
|
||||
*/
|
||||
Toggle: ChipsToggle,
|
||||
/**
|
||||
* Remove filter or the likes on click.
|
||||
* @see 🏷️ {@link ChipsRemovableProps}
|
||||
* @see [🤖 OverridableComponent](https://aksel.nav.no/grunnleggende/kode/overridablecomponent) support
|
||||
*/
|
||||
Removable: ChipsRemovable,
|
||||
});
|
||||
|
||||
Chips.Toggle = ChipsToggle;
|
||||
Chips.Removable = ChipsRemovable;
|
||||
|
||||
export default Chips;
|
||||
export type { ChipsProps };
|
||||
export { Chips, ChipsRemovable, ChipsToggle };
|
||||
export type { ChipsProps, ChipsRemovableProps, ChipsToggleProps };
|
||||
@@ -3,7 +3,7 @@ import type { AkselColor } from "../../types";
|
||||
import type { OverridableComponent } from "../../utils-external";
|
||||
import { cl } from "../../utils/helpers";
|
||||
|
||||
export interface ChipsToggleProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
interface ChipsToggleProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: string;
|
||||
/**
|
||||
* Toggles aria-pressed and visual changes
|
||||
@@ -26,68 +26,66 @@ export interface ChipsToggleProps extends React.ButtonHTMLAttributes<HTMLButtonE
|
||||
checkmark?: boolean;
|
||||
}
|
||||
|
||||
export const ChipsToggle: OverridableComponent<
|
||||
ChipsToggleProps,
|
||||
HTMLButtonElement
|
||||
> = forwardRef(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
selected,
|
||||
variant,
|
||||
checkmark = true,
|
||||
as: Component = "button",
|
||||
"data-color": color,
|
||||
...rest
|
||||
const ChipsToggle: OverridableComponent<ChipsToggleProps, HTMLButtonElement> =
|
||||
forwardRef(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
selected,
|
||||
variant,
|
||||
checkmark = true,
|
||||
as: Component = "button",
|
||||
"data-color": color,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<Component
|
||||
data-color={color ?? variantToColor(variant)}
|
||||
{...rest}
|
||||
ref={ref}
|
||||
className={cl("aksel-chips__chip aksel-chips__toggle", className, {
|
||||
"aksel-chips__toggle--with-checkmark": checkmark,
|
||||
})}
|
||||
aria-pressed={selected}
|
||||
data-pressed={selected}
|
||||
>
|
||||
{checkmark && (
|
||||
<svg
|
||||
aria-hidden
|
||||
className="aksel-chips__toggle-icon"
|
||||
width="1.25em"
|
||||
height="1.25em"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
focusable={false}
|
||||
>
|
||||
{selected ? (
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10 18.125C14.4873 18.125 18.125 14.4873 18.125 10C18.125 5.51269 14.4873 1.875 10 1.875C5.51269 1.875 1.875 5.51269 1.875 10C1.875 14.4873 5.51269 18.125 10 18.125ZM14.128 7.72904C14.3695 7.44357 14.3339 7.01635 14.0485 6.7748C13.763 6.53326 13.3358 6.56886 13.0942 6.85432L8.60428 12.1606L6.41627 9.97263C6.15185 9.70822 5.72315 9.70822 5.45873 9.97263C5.19431 10.2371 5.19431 10.6658 5.45873 10.9302L8.16706 13.6385C8.30095 13.7724 8.48479 13.8441 8.67397 13.8362C8.86316 13.8284 9.0404 13.7416 9.16271 13.5971L14.128 7.72904Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10 3.125C6.20304 3.125 3.125 6.20304 3.125 10C3.125 13.797 6.20304 16.875 10 16.875C13.797 16.875 16.875 13.797 16.875 10C16.875 6.20304 13.797 3.125 10 3.125ZM1.875 10C1.875 5.51269 5.51269 1.875 10 1.875C14.4873 1.875 18.125 5.51269 18.125 10C18.125 14.4873 14.4873 18.125 10 18.125C5.51269 18.125 1.875 14.4873 1.875 10Z"
|
||||
/* After removing old fallbacks, change to currentColor */
|
||||
fill="var(--ax-text-default)"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
)}
|
||||
<span className="aksel-chips__chip-text">{children}</span>
|
||||
</Component>
|
||||
);
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<Component
|
||||
data-color={color ?? variantToColor(variant)}
|
||||
{...rest}
|
||||
ref={ref}
|
||||
className={cl("aksel-chips__chip aksel-chips__toggle", className, {
|
||||
"aksel-chips__toggle--with-checkmark": checkmark,
|
||||
})}
|
||||
aria-pressed={selected}
|
||||
data-pressed={selected}
|
||||
>
|
||||
{checkmark && (
|
||||
<svg
|
||||
aria-hidden
|
||||
className="aksel-chips__toggle-icon"
|
||||
width="1.25em"
|
||||
height="1.25em"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
focusable={false}
|
||||
>
|
||||
{selected ? (
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10 18.125C14.4873 18.125 18.125 14.4873 18.125 10C18.125 5.51269 14.4873 1.875 10 1.875C5.51269 1.875 1.875 5.51269 1.875 10C1.875 14.4873 5.51269 18.125 10 18.125ZM14.128 7.72904C14.3695 7.44357 14.3339 7.01635 14.0485 6.7748C13.763 6.53326 13.3358 6.56886 13.0942 6.85432L8.60428 12.1606L6.41627 9.97263C6.15185 9.70822 5.72315 9.70822 5.45873 9.97263C5.19431 10.2371 5.19431 10.6658 5.45873 10.9302L8.16706 13.6385C8.30095 13.7724 8.48479 13.8441 8.67397 13.8362C8.86316 13.8284 9.0404 13.7416 9.16271 13.5971L14.128 7.72904Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10 3.125C6.20304 3.125 3.125 6.20304 3.125 10C3.125 13.797 6.20304 16.875 10 16.875C13.797 16.875 16.875 13.797 16.875 10C16.875 6.20304 13.797 3.125 10 3.125ZM1.875 10C1.875 5.51269 5.51269 1.875 10 1.875C14.4873 1.875 18.125 5.51269 18.125 10C18.125 14.4873 14.4873 18.125 10 18.125C5.51269 18.125 1.875 14.4873 1.875 10Z"
|
||||
/* After removing old fallbacks, change to currentColor */
|
||||
fill="var(--ax-text-default)"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
)}
|
||||
<span className="aksel-chips__chip-text">{children}</span>
|
||||
</Component>
|
||||
);
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
function variantToColor(
|
||||
variant?: ChipsToggleProps["variant"],
|
||||
@@ -102,4 +100,5 @@ function variantToColor(
|
||||
}
|
||||
}
|
||||
|
||||
export default ChipsToggle;
|
||||
export { ChipsToggle };
|
||||
export type { ChipsToggleProps };
|
||||
@@ -221,12 +221,11 @@ function isFieldVisible(
|
||||
return fields?.[key] !== false;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace, import/export
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
export namespace DataGridPreferencesRoot {
|
||||
export type Props = DataGridPreferencesProps;
|
||||
}
|
||||
|
||||
export type { DataGridPreferencesProps };
|
||||
// eslint-disable-next-line import/export
|
||||
export { DataGridPreferencesRoot };
|
||||
export default DataGridPreferencesRoot;
|
||||
@@ -7,6 +7,10 @@ const metadata: ComponentMetadata = {
|
||||
DataGrid,
|
||||
"DataGrid.Table": DataGridTable,
|
||||
"DataGrid.Preferences": DataGridPreferences,
|
||||
"DataGrid.Pagination": DataGrid.Pagination,
|
||||
"DataGrid.Pagination.Summary": DataGrid.Pagination.Summary,
|
||||
"DataGrid.Pagination.PageSize": DataGrid.Pagination.PageSize,
|
||||
"DataGrid.Pagination.Navigation": DataGrid.Pagination.Navigation,
|
||||
},
|
||||
keywords: ["data grid", "datagrid", "table", "tabell", "grid"],
|
||||
related: ["Table"],
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
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,
|
||||
defaultPageSize = 10,
|
||||
}: {
|
||||
paginationAbove?: boolean;
|
||||
defaultPageSize?: number;
|
||||
}) {
|
||||
return (
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={ROWS}
|
||||
getRowId={(row) => String(row.id)}
|
||||
pagination={{ defaultPageSize }}
|
||||
>
|
||||
{paginationAbove && <DataGrid.Pagination />}
|
||||
<DataGrid.Table />
|
||||
<DataGrid.Pagination />
|
||||
</DataGrid>
|
||||
);
|
||||
}
|
||||
|
||||
export const BelowTable: Story = {
|
||||
render: () => <PagedGrid />,
|
||||
};
|
||||
|
||||
export const AboveAndBelowTable: Story = {
|
||||
render: () => <PagedGrid paginationAbove />,
|
||||
};
|
||||
|
||||
export const PageScroll: Story = {
|
||||
render: () => <PagedGrid defaultPageSize={100} />,
|
||||
};
|
||||
|
||||
/** The bar stays visible because the table scrolls internally. */
|
||||
export const FixedHeight: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", height: "400px" }}>
|
||||
<PagedGrid defaultPageSize={50} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
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 />
|
||||
<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 />
|
||||
<DataGrid.Pagination />
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ResetPageOnSort: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"The consumer sorts the full dataset; DataGrid slices the current page. An accepted sort change resets to the first page and scrolls the table to the top. For filtering, call setPage(0) in the filter-change handler.",
|
||||
},
|
||||
},
|
||||
},
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
<DataGrid.Pagination />
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,588 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import React, { useState } from "react";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { DataGrid } from "../root/DataGridRoot";
|
||||
|
||||
const meta: Meta<typeof DataGrid> = {
|
||||
title: "ds-react/DataGrid/Pagination/tests",
|
||||
component: DataGrid,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
chromatic: { disable: true },
|
||||
a11y: { test: "error" },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DataGrid>;
|
||||
|
||||
type Row = { id: number; name: string };
|
||||
|
||||
const ROWS: Row[] = Array.from({ length: 25 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: `Person ${i + 1}`,
|
||||
}));
|
||||
|
||||
const columns: DataGrid.Columns<Row> = [
|
||||
{ id: "name", header: "Navn", bodyCell: (row) => row.name },
|
||||
];
|
||||
|
||||
const getRowId = (row: Row) => String(row.id);
|
||||
|
||||
const LIVE_REGION = '.aksel-data-grid > [aria-live="polite"]';
|
||||
|
||||
export const TestUncontrolledNavigation: Story = {
|
||||
render: () => (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.currentTarget.dataset.submitted = "true";
|
||||
}}
|
||||
>
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={ROWS}
|
||||
getRowId={getRowId}
|
||||
pagination={{ defaultPageSize: 10 }}
|
||||
>
|
||||
<DataGrid.Pagination />
|
||||
</DataGrid>
|
||||
</form>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const first = canvas.getByRole("button", { name: "Første side" });
|
||||
const next = canvas.getByRole("button", { name: "Neste side" });
|
||||
|
||||
expect(canvas.getByText("Side 1 av 3")).toBeInTheDocument();
|
||||
expect(first).toHaveAttribute("aria-disabled", "true");
|
||||
first.focus();
|
||||
await userEvent.keyboard("{Enter}");
|
||||
expect(first).toHaveFocus();
|
||||
expect(canvas.getByText("Side 1 av 3")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(next);
|
||||
expect(canvas.getByText("Side 2 av 3")).toBeInTheDocument();
|
||||
expect(canvas.getByText("Viser 11–20 av 25 rader")).toBeInTheDocument();
|
||||
expect(canvasElement.querySelector("form")).not.toHaveAttribute(
|
||||
"data-submitted",
|
||||
);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Forrige side" }));
|
||||
expect(canvas.getByText("Side 1 av 3")).toBeInTheDocument();
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Siste side" }));
|
||||
expect(canvas.getByText("Side 3 av 3")).toBeInTheDocument();
|
||||
expect(next).toHaveAttribute("aria-disabled", "true");
|
||||
next.focus();
|
||||
await userEvent.keyboard(" ");
|
||||
expect(next).toHaveFocus();
|
||||
expect(canvas.getByText("Side 3 av 3")).toBeInTheDocument();
|
||||
await userEvent.click(first);
|
||||
expect(canvas.getByText("Side 1 av 3")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
const testSummaryWidth: Story["play"] = async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await canvasElement.ownerDocument.fonts.ready;
|
||||
const getBounds = () =>
|
||||
[
|
||||
canvas.getByTestId("summary"),
|
||||
canvas.getByRole("combobox", { name: "Rader per side" }),
|
||||
...canvas.getAllByRole("button"),
|
||||
].map((element) => {
|
||||
const { x, y, width, height } = element.getBoundingClientRect();
|
||||
return { x, y, width, height };
|
||||
});
|
||||
const bounds = getBounds();
|
||||
|
||||
for (let page = 1; page < 14; page++) {
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Neste side" }));
|
||||
expect(getBounds()).toEqual(bounds);
|
||||
}
|
||||
};
|
||||
|
||||
function SummaryWidthBar() {
|
||||
return (
|
||||
<DataGrid.Pagination>
|
||||
<DataGrid.Pagination.Summary data-testid="summary" />
|
||||
<DataGrid.Pagination.PageSize />
|
||||
<DataGrid.Pagination.Navigation />
|
||||
</DataGrid.Pagination>
|
||||
);
|
||||
}
|
||||
|
||||
export const TestSummaryWidth: Story = {
|
||||
render: () => (
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={ROWS.slice(0, 10)}
|
||||
getRowId={getRowId}
|
||||
pagination={{ mode: "server", totalRows: 137 }}
|
||||
>
|
||||
<SummaryWidthBar />
|
||||
</DataGrid>
|
||||
),
|
||||
play: testSummaryWidth,
|
||||
};
|
||||
|
||||
export const TestUnknownTotalSummaryWidth: Story = {
|
||||
render: () => (
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={ROWS.slice(0, 10)}
|
||||
getRowId={getRowId}
|
||||
pagination={{ mode: "server", defaultPage: 98, hasNextPage: true }}
|
||||
>
|
||||
<SummaryWidthBar />
|
||||
</DataGrid>
|
||||
),
|
||||
play: testSummaryWidth,
|
||||
};
|
||||
|
||||
export const TestPageSizeChange: Story = {
|
||||
render: () => {
|
||||
const [page, setPage] = useState(2);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [lastPageChange, setLastPageChange] = useState<number | null>(null);
|
||||
const [lastPageSizeChange, setLastPageSizeChange] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<output data-testid="last-page-change">{lastPageChange}</output>
|
||||
<output data-testid="last-page-size-change">
|
||||
{lastPageSizeChange}
|
||||
</output>
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={ROWS}
|
||||
getRowId={getRowId}
|
||||
pagination={{
|
||||
page,
|
||||
onPageChange: (nextPage) => {
|
||||
setLastPageChange(nextPage);
|
||||
setPage(nextPage);
|
||||
},
|
||||
pageSize,
|
||||
onPageSizeChange: (nextPageSize) => {
|
||||
setLastPageSizeChange(nextPageSize);
|
||||
setPageSize(nextPageSize);
|
||||
},
|
||||
pageSizeOptions: [5, 10],
|
||||
}}
|
||||
>
|
||||
<DataGrid.Table />
|
||||
<DataGrid.Pagination />
|
||||
</DataGrid>
|
||||
</>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const pageSize = canvas.getByRole("combobox", { name: "Rader per side" });
|
||||
|
||||
expect(canvas.getByText("Viser 21–25 av 25 rader")).toBeInTheDocument();
|
||||
await userEvent.selectOptions(pageSize, "5");
|
||||
expect(
|
||||
await canvas.findByTestId("last-page-size-change"),
|
||||
).toHaveTextContent("5");
|
||||
expect(canvas.getByTestId("last-page-change")).toHaveTextContent("0");
|
||||
expect(canvas.getByText("Viser 1–5 av 25 rader")).toBeInTheDocument();
|
||||
expect(canvas.getByText("Side 1 av 5")).toBeInTheDocument();
|
||||
expect(pageSize).toHaveFocus();
|
||||
expect(canvasElement.querySelector(LIVE_REGION)).toHaveTextContent(
|
||||
"Viser 1–5 av 25 rader. Side 1 av 5. Rader per side: 5",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const TestPageSizeMissingFromOptions: Story = {
|
||||
render: () => (
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={ROWS}
|
||||
getRowId={getRowId}
|
||||
pagination={{ defaultPageSize: 7, pageSizeOptions: [10, 25] }}
|
||||
>
|
||||
<DataGrid.Pagination />
|
||||
</DataGrid>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const pageSize = canvas.getByRole("combobox", { name: "Rader per side" });
|
||||
|
||||
expect(pageSize).toHaveValue("7");
|
||||
expect(
|
||||
within(pageSize).getByRole("option", { name: "7" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const TestNavigationAccessibleNames: Story = {
|
||||
render: () => (
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={ROWS}
|
||||
getRowId={getRowId}
|
||||
pagination={{ defaultPageSize: 10 }}
|
||||
>
|
||||
<DataGrid.Pagination>
|
||||
<DataGrid.Pagination.Summary />
|
||||
<DataGrid.Pagination.PageSize />
|
||||
<DataGrid.Pagination.Navigation aria-label="Personer: sidenavigering over tabellen" />
|
||||
</DataGrid.Pagination>
|
||||
<DataGrid.Table />
|
||||
<DataGrid.Pagination>
|
||||
<DataGrid.Pagination.Summary />
|
||||
<DataGrid.Pagination.PageSize />
|
||||
<DataGrid.Pagination.Navigation aria-label="Personer: sidenavigering under tabellen" />
|
||||
</DataGrid.Pagination>
|
||||
</DataGrid>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const topNavigation = canvas.getByRole("navigation", {
|
||||
name: "Personer: sidenavigering over tabellen",
|
||||
});
|
||||
const bottomNavigation = canvas.getByRole("navigation", {
|
||||
name: "Personer: sidenavigering under tabellen",
|
||||
});
|
||||
const pageSizes = canvas.getAllByRole("combobox", {
|
||||
name: "Rader per side",
|
||||
});
|
||||
|
||||
expect(pageSizes).toHaveLength(2);
|
||||
expect(pageSizes[0].id).not.toBe(pageSizes[1].id);
|
||||
for (const pageSize of pageSizes) {
|
||||
expect(pageSize).toHaveAccessibleName("Rader per side");
|
||||
expect(within(pageSize).getAllByRole("option")).toHaveLength(4);
|
||||
}
|
||||
|
||||
pageSizes[0].focus();
|
||||
await userEvent.tab();
|
||||
expect(
|
||||
within(topNavigation).getByRole("button", { name: "Første side" }),
|
||||
).toHaveFocus();
|
||||
await userEvent.tab();
|
||||
expect(
|
||||
within(topNavigation).getByRole("button", { name: "Forrige side" }),
|
||||
).toHaveFocus();
|
||||
await userEvent.tab();
|
||||
const next = within(topNavigation).getByRole("button", {
|
||||
name: "Neste side",
|
||||
});
|
||||
expect(next).toHaveFocus();
|
||||
await userEvent.keyboard("{Enter}");
|
||||
expect(next).toHaveFocus();
|
||||
expect(within(topNavigation).getByText("Side 2 av 3")).toBeInTheDocument();
|
||||
expect(
|
||||
within(bottomNavigation).getByText("Side 2 av 3"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const TestLoadingNavigationAndAnnouncement: Story = {
|
||||
parameters: {
|
||||
a11y: {
|
||||
// Both landmarks intentionally provide identical controls for one grid.
|
||||
config: { rules: [{ id: "landmark-unique", enabled: false }] },
|
||||
},
|
||||
},
|
||||
render: () => {
|
||||
const [page, setPage] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [renderCount, setRenderCount] = useState(0);
|
||||
const dataPage = isLoading ? 0 : page;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setIsLoading(false)}>
|
||||
Complete request
|
||||
</button>
|
||||
<button type="button" onClick={() => setIsLoading(true)}>
|
||||
Reload page
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenderCount((count) => count + 1)}
|
||||
>
|
||||
Unrelated render {renderCount}
|
||||
</button>
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={ROWS.slice(dataPage * 10, (dataPage + 1) * 10)}
|
||||
isLoading={isLoading}
|
||||
getRowId={getRowId}
|
||||
pagination={{
|
||||
mode: "server",
|
||||
page,
|
||||
onPageChange: (nextPage) => {
|
||||
setPage(nextPage);
|
||||
setIsLoading(true);
|
||||
},
|
||||
defaultPageSize: 10,
|
||||
hasNextPage: true,
|
||||
}}
|
||||
>
|
||||
<DataGrid.Pagination />
|
||||
<DataGrid.Pagination />
|
||||
</DataGrid>
|
||||
</>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const navigation = canvas.getAllByRole("navigation", {
|
||||
name: "Sidenavigering",
|
||||
});
|
||||
const next = within(navigation[0]).getByRole("button", {
|
||||
name: "Neste side",
|
||||
});
|
||||
// Select also owns empty error-message live regions.
|
||||
const liveRegions = canvasElement.querySelectorAll(LIVE_REGION);
|
||||
|
||||
expect(liveRegions).toHaveLength(1);
|
||||
expect(liveRegions[0]).toBeEmptyDOMElement();
|
||||
await userEvent.click(next);
|
||||
expect(next).toHaveAttribute("aria-disabled", "true");
|
||||
expect(next).toHaveFocus();
|
||||
for (const nav of navigation) {
|
||||
for (const button of within(nav).getAllByRole("button")) {
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
}
|
||||
}
|
||||
expect(
|
||||
canvas.getAllByRole("combobox", { name: "Rader per side" })[0],
|
||||
).not.toBeDisabled();
|
||||
expect(canvas.getAllByText("Side 2")).toHaveLength(2);
|
||||
expect(canvas.getAllByText("Viser rad 1–10")).toHaveLength(2);
|
||||
expect(liveRegions[0]).toBeEmptyDOMElement();
|
||||
|
||||
await userEvent.click(next);
|
||||
await userEvent.keyboard("{Enter}");
|
||||
await userEvent.keyboard(" ");
|
||||
expect(next).toHaveFocus();
|
||||
expect(canvas.getAllByText("Side 2")).toHaveLength(2);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Complete request" }),
|
||||
);
|
||||
expect(await canvas.findAllByText("Viser rad 11–20")).toHaveLength(2);
|
||||
const announcement = "Viser rad 11–20. Side 2. Rader per side: 10";
|
||||
await expect(liveRegions[0]).toHaveTextContent(announcement);
|
||||
expect(liveRegions[0]).toHaveAttribute("aria-atomic", "true");
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Unrelated render 0" }),
|
||||
);
|
||||
expect(liveRegions[0]).toHaveTextContent(announcement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Reload page" }));
|
||||
expect(liveRegions[0]).toHaveTextContent(announcement);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Complete request" }),
|
||||
);
|
||||
expect(liveRegions[0]).toHaveTextContent(announcement);
|
||||
expect(canvasElement.querySelectorAll(LIVE_REGION)).toHaveLength(1);
|
||||
},
|
||||
};
|
||||
|
||||
export const TestUnknownTotalNavigation: Story = {
|
||||
render: () => {
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={ROWS.slice(0, 10)}
|
||||
getRowId={getRowId}
|
||||
pagination={{
|
||||
mode: "server",
|
||||
page,
|
||||
onPageChange: setPage,
|
||||
defaultPageSize: 10,
|
||||
hasNextPage: page === 0,
|
||||
}}
|
||||
>
|
||||
<DataGrid.Pagination />
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(canvas.getByText("Side 1")).toBeInTheDocument();
|
||||
expect(canvas.queryByRole("button", { name: "Siste side" })).toBeNull();
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Neste side" }));
|
||||
expect(canvas.getByText("Side 2")).toBeInTheDocument();
|
||||
expect(canvas.queryByText(/av/)).toBeNull();
|
||||
expect(canvas.getByRole("button", { name: "Neste side" })).toHaveAttribute(
|
||||
"aria-disabled",
|
||||
"true",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const TestRepeatedSummaryStillAnnouncesPage: Story = {
|
||||
render: () => (
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={[]}
|
||||
getRowId={getRowId}
|
||||
pagination={{ mode: "server", hasNextPage: true }}
|
||||
>
|
||||
<DataGrid.Pagination />
|
||||
</DataGrid>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const liveRegion = canvasElement.querySelector(LIVE_REGION);
|
||||
|
||||
expect(liveRegion).toBeEmptyDOMElement();
|
||||
expect(canvas.getByText("Ingen rader")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Neste side" }));
|
||||
await expect(liveRegion).toHaveTextContent("Side 2");
|
||||
expect(canvas.getByText("Ingen rader")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Neste side" }));
|
||||
await expect(liveRegion).toHaveTextContent("Side 3");
|
||||
expect(canvas.getByText("Ingen rader")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const TestUncontrolledPageIsClamped: Story = {
|
||||
render: () => (
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={ROWS}
|
||||
getRowId={getRowId}
|
||||
pagination={{ defaultPage: 4, defaultPageSize: 10 }}
|
||||
>
|
||||
<DataGrid.Pagination />
|
||||
</DataGrid>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(await canvas.findByText("Side 3 av 3")).toBeInTheDocument();
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Forrige side" }));
|
||||
expect(canvas.getByText("Side 2 av 3")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const TestControlledPageIsClampedOnce: Story = {
|
||||
render: () => {
|
||||
const [page, setPage] = useState(4);
|
||||
const [pageChangeCount, setPageChangeCount] = useState(0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<output data-testid="page-change-count">{pageChangeCount}</output>
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={ROWS}
|
||||
getRowId={getRowId}
|
||||
pagination={{
|
||||
page,
|
||||
onPageChange: (nextPage) => {
|
||||
setPageChangeCount((count) => count + 1);
|
||||
setPage(nextPage);
|
||||
},
|
||||
defaultPageSize: 10,
|
||||
}}
|
||||
>
|
||||
<DataGrid.Pagination />
|
||||
</DataGrid>
|
||||
</>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(await canvas.findByText("Side 3 av 3")).toBeInTheDocument();
|
||||
expect(await canvas.findByTestId("page-change-count")).toHaveTextContent(
|
||||
"1",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const TestCustomLayout: Story = {
|
||||
render: () => (
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={ROWS}
|
||||
getRowId={getRowId}
|
||||
pagination={{ defaultPageSize: 10 }}
|
||||
>
|
||||
<DataGrid.Pagination data-testid="custom-bar">
|
||||
<span>Egendefinert innhold</span>
|
||||
</DataGrid.Pagination>
|
||||
<DataGrid.Pagination data-testid="summary-bar">
|
||||
<DataGrid.Pagination.Summary />
|
||||
</DataGrid.Pagination>
|
||||
</DataGrid>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const customBar = within(canvas.getByTestId("custom-bar"));
|
||||
const summaryBar = within(canvas.getByTestId("summary-bar"));
|
||||
|
||||
expect(customBar.getByText("Egendefinert innhold")).toBeInTheDocument();
|
||||
expect(customBar.queryByText(/Viser/)).not.toBeInTheDocument();
|
||||
expect(summaryBar.getByText("Viser 1–10 av 25 rader")).toBeInTheDocument();
|
||||
expect(canvas.getAllByText(/Viser/)).toHaveLength(1);
|
||||
},
|
||||
};
|
||||
|
||||
export const TestIndependentGrids: Story = {
|
||||
render: () => (
|
||||
<>
|
||||
<section aria-label="First dataset">
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={ROWS}
|
||||
getRowId={getRowId}
|
||||
pagination={{ defaultPageSize: 10 }}
|
||||
>
|
||||
<DataGrid.Pagination>
|
||||
<DataGrid.Pagination.Summary />
|
||||
<DataGrid.Pagination.PageSize />
|
||||
<DataGrid.Pagination.Navigation aria-label="First dataset navigation" />
|
||||
</DataGrid.Pagination>
|
||||
</DataGrid>
|
||||
</section>
|
||||
<section aria-label="Second dataset">
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={ROWS.slice(0, 5)}
|
||||
getRowId={getRowId}
|
||||
pagination={{ defaultPageSize: 10 }}
|
||||
>
|
||||
<DataGrid.Pagination>
|
||||
<DataGrid.Pagination.Summary />
|
||||
<DataGrid.Pagination.PageSize />
|
||||
<DataGrid.Pagination.Navigation aria-label="Second dataset navigation" />
|
||||
</DataGrid.Pagination>
|
||||
</DataGrid>
|
||||
</section>
|
||||
</>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const firstSection = canvas.getByRole("region", { name: "First dataset" });
|
||||
const secondSection = canvas.getByRole("region", {
|
||||
name: "Second dataset",
|
||||
});
|
||||
const first = within(firstSection);
|
||||
const second = within(secondSection);
|
||||
|
||||
await userEvent.click(first.getByRole("button", { name: "Neste side" }));
|
||||
expect(first.getByText("Side 2 av 3")).toBeInTheDocument();
|
||||
expect(second.getByText("Side 1 av 1")).toBeInTheDocument();
|
||||
await expect(firstSection.querySelector(LIVE_REGION)).toHaveTextContent(
|
||||
/Side 2.*Rader per side/,
|
||||
);
|
||||
expect(secondSection.querySelector(LIVE_REGION)).toBeEmptyDOMElement();
|
||||
},
|
||||
};
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import React, { useState } from "react";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { DataGrid } from "../root/DataGridRoot";
|
||||
|
||||
const meta: Meta<typeof DataGrid> = {
|
||||
title: "ds-react/DataGrid/Pagination/Integration tests",
|
||||
component: DataGrid,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DataGrid>;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import React, { useState } from "react";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { DataGrid } from "../root/DataGridRoot";
|
||||
import { ResetPageOnSort } from "./DataGridPagination.stories";
|
||||
|
||||
const meta: Meta<typeof DataGrid> = {
|
||||
title: "ds-react/DataGrid/Pagination/patterns/tests",
|
||||
component: DataGrid,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DataGrid>;
|
||||
|
||||
type Row = { id: string; name: string; children?: Row[] };
|
||||
|
||||
const rows: Row[] = [
|
||||
{
|
||||
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 columns: DataGrid.Columns<Row> = [
|
||||
{ id: "name", header: "Name", bodyCell: (row) => row.name },
|
||||
];
|
||||
|
||||
const getSubRows = (row: Row) => row.children ?? [];
|
||||
|
||||
export const TestSelectionAcrossPages: Story = {
|
||||
render: () => {
|
||||
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<output aria-label="Selected row IDs">
|
||||
{JSON.stringify([...selectedRowIds].sort())}
|
||||
</output>
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={rows}
|
||||
getRowId={(row) => row.id}
|
||||
selection={{
|
||||
mode: "multiple",
|
||||
onSelectedRowIdsChange: setSelectedRowIds,
|
||||
}}
|
||||
pagination={{ defaultPageSize: 2, pageSizeOptions: [2] }}
|
||||
>
|
||||
<DataGrid.Table subRows={{ getRows: getSubRows }} />
|
||||
<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 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,557 @@
|
||||
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/Root 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 },
|
||||
];
|
||||
|
||||
function PaginationStateProbe() {
|
||||
const { mode, page, pageCount, pageSize, canNext, setPage } =
|
||||
useDataGridRootPaginationContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
{`${mode === "client" ? "Client" : "Server"} mode · Page ${page + 1} of ${pageCount ?? "unknown"} · ${pageSize} rows`}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canNext}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
Next page
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const ClientModeUsesRootPaginationState: Story = {
|
||||
render: () => (
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={rows}
|
||||
getRowId={getRowId}
|
||||
pagination={{ defaultPageSize: 2, pageSizeOptions: [2] }}
|
||||
>
|
||||
<PaginationStateProbe />
|
||||
<DataGrid.Table />
|
||||
</DataGrid>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByRole("cell", { name: "Person 1" })).toBeInTheDocument();
|
||||
expect(canvas.getByRole("cell", { name: "Person 2" })).toBeInTheDocument();
|
||||
expect(
|
||||
canvas.queryByRole("cell", { name: "Person 3" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
canvas.getByText("Client mode · Page 1 of 3 · 2 rows"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Next page" }));
|
||||
expect(
|
||||
canvas.queryByRole("cell", { name: "Person 1" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(canvas.getByRole("cell", { name: "Person 3" })).toBeInTheDocument();
|
||||
expect(canvas.getByRole("cell", { name: "Person 4" })).toBeInTheDocument();
|
||||
expect(
|
||||
canvas.getByText("Client mode · Page 2 of 3 · 2 rows"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
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 />
|
||||
<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,
|
||||
};
|
||||
|
||||
export const ServerModeUsesKnownTotalState: Story = {
|
||||
render: () => (
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={rows.slice(0, 2)}
|
||||
getRowId={getRowId}
|
||||
pagination={{
|
||||
mode: "server",
|
||||
defaultPageSize: 2,
|
||||
pageSizeOptions: [2],
|
||||
totalRows: rows.length,
|
||||
}}
|
||||
>
|
||||
<PaginationStateProbe />
|
||||
<DataGrid.Table />
|
||||
</DataGrid>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByRole("cell", { name: "Person 1" })).toBeInTheDocument();
|
||||
expect(canvas.getByRole("cell", { name: "Person 2" })).toBeInTheDocument();
|
||||
expect(
|
||||
canvas.getByText("Server mode · Page 1 of 3 · 2 rows"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Next page" }));
|
||||
expect(canvas.getByRole("cell", { name: "Person 1" })).toBeInTheDocument();
|
||||
expect(canvas.getByRole("cell", { name: "Person 2" })).toBeInTheDocument();
|
||||
expect(
|
||||
canvas.getByText("Server mode · Page 2 of 3 · 2 rows"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const ServerModeUsesUnknownTotalState: Story = {
|
||||
render: () => (
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={rows.slice(0, 2)}
|
||||
getRowId={getRowId}
|
||||
pagination={{
|
||||
mode: "server",
|
||||
defaultPageSize: 2,
|
||||
pageSizeOptions: [2],
|
||||
hasNextPage: true,
|
||||
}}
|
||||
>
|
||||
<PaginationStateProbe />
|
||||
</DataGrid>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
canvas.getByText("Server mode · Page 1 of unknown · 2 rows"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Next page" }));
|
||||
expect(
|
||||
canvas.getByText("Server mode · Page 2 of unknown · 2 rows"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const ContextBoundBarsShareStateAndAnnounceOnce: Story = {
|
||||
render: () => (
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={rows}
|
||||
getRowId={getRowId}
|
||||
pagination={{ defaultPageSize: 2, pageSizeOptions: [2] }}
|
||||
>
|
||||
<DataGrid.Pagination>
|
||||
<DataGrid.Pagination.Navigation aria-label="Top results navigation" />
|
||||
</DataGrid.Pagination>
|
||||
<DataGrid.Table />
|
||||
<DataGrid.Pagination>
|
||||
<DataGrid.Pagination.Navigation aria-label="Bottom results navigation" />
|
||||
</DataGrid.Pagination>
|
||||
</DataGrid>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const top = canvas.getByRole("navigation", {
|
||||
name: "Top results navigation",
|
||||
});
|
||||
const bottom = canvas.getByRole("navigation", {
|
||||
name: "Bottom results navigation",
|
||||
});
|
||||
const liveRegions = canvasElement.querySelectorAll(
|
||||
".aksel-data-grid > .aksel-sr-only[aria-live='polite']",
|
||||
);
|
||||
|
||||
expect(liveRegions).toHaveLength(1);
|
||||
expect(within(top).getByText("Side 1 av 3")).toBeInTheDocument();
|
||||
expect(within(bottom).getByText("Side 1 av 3")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(
|
||||
within(top).getByRole("button", { name: "Neste side" }),
|
||||
);
|
||||
expect(within(top).getByText("Side 2 av 3")).toBeInTheDocument();
|
||||
expect(within(bottom).getByText("Side 2 av 3")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(liveRegions[0].textContent).toContain("Side 2 av 3"),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
class RenderErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ error: Error | null }
|
||||
> {
|
||||
state: { error: Error | null } = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return <p role="alert">{this.state.error.message}</p>;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export const RequiresRootPagination: Story = {
|
||||
render: () => (
|
||||
<RenderErrorBoundary>
|
||||
<DataGrid columns={columns} data={rows}>
|
||||
<DataGrid.Pagination />
|
||||
</DataGrid>
|
||||
</RenderErrorBoundary>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
expect(within(canvasElement).getByRole("alert")).toHaveTextContent(
|
||||
"DataGrid.Pagination must be used within a <DataGrid pagination={...}>",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const NestedGridRequiresOwnPagination: Story = {
|
||||
render: () => (
|
||||
<DataGrid columns={columns} data={rows} getRowId={getRowId} pagination={{}}>
|
||||
<RenderErrorBoundary>
|
||||
<DataGrid columns={columns} data={rows}>
|
||||
<DataGrid.Pagination />
|
||||
</DataGrid>
|
||||
</RenderErrorBoundary>
|
||||
</DataGrid>
|
||||
),
|
||||
play: RequiresRootPagination.play,
|
||||
};
|
||||
|
||||
const manyRows: Row[] = Array.from({ length: 60 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
name: `Person ${String(index + 1).padStart(2, "0")}`,
|
||||
}));
|
||||
|
||||
const sortableColumns: DataGrid.Columns<Row> = [
|
||||
{ id: "name", header: "Name", bodyCell: (row) => row.name, isSortable: true },
|
||||
];
|
||||
|
||||
export const NestedGridDoesNotInheritPagination: Story = {
|
||||
render: () => (
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={rows}
|
||||
getRowId={getRowId}
|
||||
pagination={{ defaultPageSize: 2, pageSizeOptions: [2] }}
|
||||
>
|
||||
<DataGrid.Table aria-label="Outer grid" />
|
||||
<DataGrid columns={sortableColumns} data={rows} getRowId={getRowId}>
|
||||
<DataGrid.Table aria-label="Nested grid" />
|
||||
</DataGrid>
|
||||
<DataGrid.Pagination />
|
||||
</DataGrid>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const nested = within(canvas.getByRole("table", { name: "Nested grid" }));
|
||||
const outer = within(canvas.getByRole("table", { name: "Outer grid" }));
|
||||
|
||||
expect(nested.getAllByRole("row")).toHaveLength(6);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Neste side" }));
|
||||
expect(canvas.getByText("Side 2 av 3")).toBeInTheDocument();
|
||||
expect(outer.getByRole("cell", { name: "Person 3" })).toBeInTheDocument();
|
||||
expect(nested.getAllByRole("row")).toHaveLength(6);
|
||||
|
||||
await userEvent.click(nested.getByRole("button", { name: "Name" }));
|
||||
expect(nested.getByRole("columnheader", { name: "Name" })).toHaveAttribute(
|
||||
"aria-sort",
|
||||
"ascending",
|
||||
);
|
||||
expect(canvas.getByText("Side 2 av 3")).toBeInTheDocument();
|
||||
expect(outer.getByRole("cell", { name: "Person 3" })).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<ResetControls onSortProgrammatically={() => {}} />
|
||||
<DataGrid.Table
|
||||
aria-label="Personer"
|
||||
sorting={{ sortOrder, onSortOrderChange: setSortOrder }}
|
||||
/>
|
||||
</DataGrid>
|
||||
);
|
||||
}
|
||||
|
||||
export const ServerSortDoesNotResetPage: Story = {
|
||||
render: () => <ServerResetGrid />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Next page" }));
|
||||
expect(canvas.getByText("Page 2 · 20 rows")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Name" }));
|
||||
expect(canvas.getByRole("columnheader", { name: "Name" })).toHaveAttribute(
|
||||
"aria-sort",
|
||||
"ascending",
|
||||
);
|
||||
expect(canvas.getByText("Page 2 · 20 rows")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
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, hasNextPage } = options;
|
||||
const totalRows =
|
||||
options.mode === "client" ? options.rowCount : options.totalRows;
|
||||
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 pageRowCount =
|
||||
options.mode === "client"
|
||||
? Math.min(Math.max(options.rowCount - startIndex, 0), pageSize)
|
||||
: options.pageRowCount;
|
||||
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 };
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
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 DataGridPaginationNavigationProps}
|
||||
*
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataGrid columns={columns} data={rows} pagination={{}}>
|
||||
* <DataGrid.Pagination>
|
||||
* <DataGrid.Pagination.Navigation />
|
||||
* </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.Pagination.Navigation must be used within <DataGrid pagination={...}>",
|
||||
);
|
||||
const translate = useI18n("DataGrid");
|
||||
const pageLabel =
|
||||
pageCount === undefined
|
||||
? translate("Pagination.pageOfUnknownTotal", { page: page + 1 })
|
||||
: translate("Pagination.pageOf", { page: page + 1, pageCount });
|
||||
|
||||
return (
|
||||
<nav
|
||||
{...rest}
|
||||
ref={forwardedRef}
|
||||
aria-label={ariaLabel ?? translate("Pagination.navLabel")}
|
||||
className={cl("aksel-data-grid__pagination-navigation", className)}
|
||||
>
|
||||
<Button
|
||||
type="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"
|
||||
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>{pageLabel}</span>
|
||||
</span>
|
||||
<Button
|
||||
type="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"
|
||||
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 };
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, { forwardRef } 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.Pagination.PageSize />
|
||||
* </DataGrid.Pagination>
|
||||
* </DataGrid>
|
||||
* ```
|
||||
*/
|
||||
const DataGridPaginationPageSize = forwardRef<
|
||||
HTMLSelectElement,
|
||||
DataGridPaginationPageSizeProps
|
||||
>(({ className, style, ...rest }, forwardedRef) => {
|
||||
const { pageSize, pageSizeOptions, setPageSize } =
|
||||
useDataGridRootPaginationContext(
|
||||
true,
|
||||
"[Aksel] DataGrid.Pagination.PageSize must be used within <DataGrid pagination={...}>",
|
||||
);
|
||||
const translate = useI18n("DataGrid");
|
||||
const options = pageSizeOptions.includes(pageSize)
|
||||
? pageSizeOptions
|
||||
: [...pageSizeOptions, pageSize];
|
||||
|
||||
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 };
|
||||
@@ -0,0 +1,27 @@
|
||||
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 };
|
||||
@@ -0,0 +1,127 @@
|
||||
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,
|
||||
type DataGridPaginationNavigationProps,
|
||||
} from "../navigation/DataGridPaginationNavigation";
|
||||
import {
|
||||
DataGridPaginationPageSize,
|
||||
type DataGridPaginationPageSizeProps,
|
||||
} from "../page-size/DataGridPaginationPageSize";
|
||||
import {
|
||||
DataGridPaginationSummary,
|
||||
type DataGridPaginationSummaryProps,
|
||||
} 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;
|
||||
}
|
||||
|
||||
const DataGridPaginationRoot = forwardRef<
|
||||
HTMLDivElement,
|
||||
DataGridPaginationProps
|
||||
>(({ children, className, ...rest }, forwardedRef) => {
|
||||
useDataGridRootPaginationContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
ref={forwardedRef}
|
||||
className={cl("aksel-data-grid__pagination", className)}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = Object.assign(DataGridPaginationRoot, {
|
||||
/**
|
||||
* @see 🏷️ {@link DataGridPaginationSummaryProps}
|
||||
*/
|
||||
Summary: DataGridPaginationSummary,
|
||||
/**
|
||||
* @see 🏷️ {@link DataGridPaginationPageSizeProps}
|
||||
*/
|
||||
PageSize: DataGridPaginationPageSize,
|
||||
/**
|
||||
* @see 🏷️ {@link DataGridPaginationNavigationProps}
|
||||
*/
|
||||
Navigation: DataGridPaginationNavigation,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
export namespace DataGridPagination {
|
||||
export type Props = DataGridPaginationProps;
|
||||
export type SummaryProps = DataGridPaginationSummaryProps;
|
||||
export type PageSizeProps = DataGridPaginationPageSizeProps;
|
||||
export type NavigationProps = DataGridPaginationNavigationProps;
|
||||
}
|
||||
|
||||
export { DataGridPagination, DataGridPaginationLiveRegion };
|
||||
export type { DataGridPaginationProps };
|
||||
@@ -0,0 +1,169 @@
|
||||
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 };
|
||||
@@ -0,0 +1,78 @@
|
||||
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.Pagination.Summary />
|
||||
* </DataGrid.Pagination>
|
||||
* </DataGrid>
|
||||
* ```
|
||||
*/
|
||||
const DataGridPaginationSummary = forwardRef<
|
||||
HTMLParagraphElement,
|
||||
DataGridPaginationSummaryProps
|
||||
>(({ className, ...rest }, forwardedRef) => {
|
||||
const { from, to, totalRows, pageSize } = useDataGridRootPaginationContext(
|
||||
true,
|
||||
"[Aksel] DataGrid.Pagination.Summary 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 });
|
||||
}
|
||||
|
||||
// Match Navigation's three-digit page reservation for unknown totals.
|
||||
const maxRow =
|
||||
totalRows ??
|
||||
"9".repeat(Math.max(3, String(Math.max(999 * pageSize, from, to)).length));
|
||||
const maxLabel =
|
||||
totalRows === undefined
|
||||
? translate("Pagination.summaryUnknownTotal", {
|
||||
from: maxRow,
|
||||
to: maxRow,
|
||||
})
|
||||
: translate("Pagination.summary", {
|
||||
from: maxRow,
|
||||
to: maxRow,
|
||||
totalRows,
|
||||
});
|
||||
|
||||
return (
|
||||
<BodyShort
|
||||
{...rest}
|
||||
ref={forwardedRef}
|
||||
className={cl("aksel-data-grid__pagination-summary", className)}
|
||||
data-max-label={maxLabel}
|
||||
>
|
||||
<span>{text}</span>
|
||||
</BodyShort>
|
||||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
export namespace DataGridPaginationSummary {
|
||||
export type Props = DataGridPaginationSummaryProps;
|
||||
}
|
||||
|
||||
export { DataGridPaginationSummary };
|
||||
export type { DataGridPaginationSummaryProps };
|
||||
@@ -1,3 +1,35 @@
|
||||
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.
|
||||
@@ -48,4 +80,4 @@ type DataGridSettings = {
|
||||
columnDisplay?: { id: string; visible: boolean | "always" }[];
|
||||
};
|
||||
|
||||
export type { DataGridSettings };
|
||||
export type { DataGridPaginationConfig, DataGridSettings };
|
||||
@@ -2,6 +2,7 @@ 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> = {
|
||||
@@ -20,4 +21,53 @@ const { Provider: DataGridContextProvider, useContext: useDataGridContext } =
|
||||
errorMessage: "DataGrid hooks must be used within a <DataGrid />",
|
||||
});
|
||||
|
||||
export { DataGridContextProvider, useDataGridContext };
|
||||
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,
|
||||
};
|
||||
@@ -5,10 +5,53 @@ 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 type { DataGridSettings } from "./DataGrid.types";
|
||||
import { DataGridContextProvider } from "./DataGridRoot.context";
|
||||
import {
|
||||
DataGridPagination,
|
||||
DataGridPaginationLiveRegion,
|
||||
} from "../pagination/root/DataGridPaginationRoot";
|
||||
import { useDataGridPagination } from "../pagination/root/useDataGridPagination";
|
||||
import type {
|
||||
DataGridPaginationConfig,
|
||||
DataGridSettings,
|
||||
} from "./DataGrid.types";
|
||||
import {
|
||||
DataGridContextProvider,
|
||||
DataGridRootPaginationContextProvider,
|
||||
} from "./DataGridRoot.context";
|
||||
|
||||
interface DataGridProps<RowT> {
|
||||
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> {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
@@ -23,14 +66,6 @@ interface DataGridProps<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.
|
||||
*/
|
||||
@@ -77,6 +112,16 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,6 +147,7 @@ const DataGridInternal = forwardRef<HTMLDivElement, DataGridProps<any>>(
|
||||
className,
|
||||
columns,
|
||||
data,
|
||||
pagination,
|
||||
getRowId,
|
||||
selection,
|
||||
isLoading = false,
|
||||
@@ -129,18 +175,78 @@ const DataGridInternal = forwardRef<HTMLDivElement, DataGridProps<any>>(
|
||||
tableSettings={resolvedSettings}
|
||||
updateTableSettings={updateSettings}
|
||||
>
|
||||
{children}
|
||||
{pagination ? (
|
||||
<DataGridRootPagination
|
||||
pagination={pagination}
|
||||
rowCount={data.length}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{children}
|
||||
</DataGridRootPagination>
|
||||
) : (
|
||||
<DataGridRootPaginationContextProvider mode="none">
|
||||
{children}
|
||||
</DataGridRootPaginationContextProvider>
|
||||
)}
|
||||
</DataGridContextProvider>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 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 DataGridRootPagination({
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const DataGrid = DataGridInternal as unknown as DataGridComponent;
|
||||
DataGrid.Table = DataGridTable;
|
||||
DataGrid.Preferences = DataGridPreferences;
|
||||
DataGrid.Pagination = DataGridPagination;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace, import/export
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
export namespace DataGrid {
|
||||
export type Props<T = unknown> = DataGridProps<T>;
|
||||
export type Columns<T = unknown> = ColumnDefinitions<T>;
|
||||
@@ -164,8 +270,16 @@ 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;
|
||||
export type SummaryProps = DataGridPagination.SummaryProps;
|
||||
export type PageSizeProps = DataGridPagination.PageSizeProps;
|
||||
export type NavigationProps = DataGridPagination.NavigationProps;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/export
|
||||
export { DataGrid, DataGridInternal }; // DataGridRoot needs to be exported b.c. of docgen
|
||||
export default DataGrid;
|
||||
@@ -332,18 +332,11 @@ 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("Custom row");
|
||||
expectNodeFocus("Row 1");
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ const sortableData: SortableRow[] = [
|
||||
|
||||
export const AutoLayoutMinimal: StoryObj<{
|
||||
table: DataGrid.Table.Props<MinimalRow>;
|
||||
grid: Omit<DataGrid.Props<MinimalRow>, "children">;
|
||||
grid: Omit<DataGrid.Props<MinimalRow>, "children" | "pagination">;
|
||||
}> = {
|
||||
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">;
|
||||
grid: Omit<DataGrid.Props<PersonInfo>, "children" | "pagination">;
|
||||
}> = {
|
||||
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">;
|
||||
grid: Omit<DataGrid.Props<PersonInfo>, "children" | "pagination">;
|
||||
}> = {
|
||||
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">;
|
||||
grid: Omit<DataGrid.Props<SortableRow>, "children" | "pagination">;
|
||||
}> = {
|
||||
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">;
|
||||
grid: Omit<DataGrid.Props<MinimalRow>, "children" | "pagination">;
|
||||
}> = {
|
||||
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">;
|
||||
grid: Omit<DataGrid.Props<MinimalRow>, "children" | "pagination">;
|
||||
}> = {
|
||||
render: (args) => {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useValueAsRef } from "../../../utils/hooks";
|
||||
import { type GridCache, buildTableGridMap } from "../helpers/table-grid-nav";
|
||||
import {
|
||||
type GridCache,
|
||||
buildTableGridMap,
|
||||
findFirstCell,
|
||||
findFirstCellInRow,
|
||||
isCellFocusable,
|
||||
} from "../helpers/table-grid-nav";
|
||||
|
||||
/**
|
||||
* Manages the table grid cache and observes DOM changes that require grid rebuilding.
|
||||
@@ -13,9 +19,32 @@ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
|
||||
dirty: true,
|
||||
});
|
||||
|
||||
const [activeCell, setActiveCell] = useState<Element | null>(null);
|
||||
const [activeCell, setActiveCellState] = 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) {
|
||||
@@ -25,8 +54,30 @@ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
|
||||
observerRef.current = new MutationObserver(() => {
|
||||
gridCacheRef.current.dirty = true;
|
||||
|
||||
if (activeCellRef.current && !activeCellRef.current.isConnected) {
|
||||
setActiveCell(null);
|
||||
const isLoading = tableRef.getAttribute("aria-busy") === "true";
|
||||
const currentCell = activeCellRef.current;
|
||||
if (currentCell && !tableRef.contains(currentCell)) {
|
||||
const position =
|
||||
loadingCellPositionRef.current ??
|
||||
gridCacheRef.current.grid?.positions.get(currentCell);
|
||||
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));
|
||||
|
||||
// Keep pagination controls focused; restore this cell on table re-entry.
|
||||
setActiveCell(nextCell);
|
||||
}
|
||||
|
||||
if (!isLoading) {
|
||||
loadingCellPositionRef.current = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -34,7 +85,7 @@ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["colspan", "rowspan", "hidden", "style"],
|
||||
attributeFilter: ["colspan", "rowspan", "hidden", "style", "aria-busy"],
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -43,7 +94,7 @@ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
|
||||
observerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [tableRef, enabled, activeCellRef]);
|
||||
}, [tableRef, enabled, activeCellRef, getTableGrid, setActiveCell]);
|
||||
|
||||
/**
|
||||
* If keyboard-nav is re-enabled, mark grid as dirty since
|
||||
@@ -55,15 +106,6 @@ 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,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Root-owned pagination only: in client mode, resets to page 0 when the applied sort model changes
|
||||
* (server mode leaves this to the consumer to avoid fetching the old page with the new sort),
|
||||
* and scrolls the table's own scroll container to top on page, page size or sort changes.
|
||||
* Compares applied sort state, so 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 };
|
||||
|
||||
if (!setPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
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,6 +24,10 @@ 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";
|
||||
@@ -165,19 +169,20 @@ const DataGridTableInternal = forwardRef<
|
||||
}: DataGridTableProps<unknown>,
|
||||
forwardedRef,
|
||||
) => {
|
||||
const {
|
||||
columnDefinitions,
|
||||
data,
|
||||
getRowId,
|
||||
selection,
|
||||
isLoading,
|
||||
tableSettings,
|
||||
} = useDataGridContext();
|
||||
const { columnDefinitions, getRowId, selection, isLoading, tableSettings } =
|
||||
useDataGridContext();
|
||||
const items = useTablePagination();
|
||||
|
||||
const sortingState = useTableSort(sorting);
|
||||
|
||||
const scrollWrapperRef = useRef<HTMLDivElement>(null);
|
||||
useTablePaginationReset({
|
||||
sortState: sortingState.sortState,
|
||||
scrollWrapperRef,
|
||||
});
|
||||
|
||||
const tableItems = useTableItems({
|
||||
items: data,
|
||||
items,
|
||||
getRowId,
|
||||
subRows,
|
||||
});
|
||||
@@ -221,6 +226,7 @@ const DataGridTableInternal = forwardRef<
|
||||
>
|
||||
<TableElementWrapper
|
||||
enabled={true}
|
||||
scrollWrapperRef={scrollWrapperRef}
|
||||
hasStickyColumns={
|
||||
!!(
|
||||
tableSettings?.stickyColumns?.start ||
|
||||
@@ -288,14 +294,17 @@ 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({
|
||||
@@ -367,7 +376,7 @@ function TableElementWrapper({
|
||||
|
||||
return (
|
||||
<div className="aksel-data-table__border-wrapper">
|
||||
<div ref={tableWrapperRef} className="aksel-data-table__scroll-wrapper">
|
||||
<div ref={mergedWrapperRefs} className="aksel-data-table__scroll-wrapper">
|
||||
<Slot
|
||||
tabIndex={tabIndex}
|
||||
/* @ts-expect-error Ref is not typed correctly to handle this case */
|
||||
@@ -553,7 +562,7 @@ const DataGridTable = DataGridTableInternal as <RowT>(
|
||||
props: DataGridTableProps<RowT> & React.RefAttributes<HTMLTableElement>,
|
||||
) => React.ReactElement | null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace, import/export
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
export namespace DataGridTable {
|
||||
export type Props<T = unknown> = DataGridTableProps<T>;
|
||||
export type Sorting = TableSortOptions;
|
||||
@@ -566,7 +575,6 @@ export namespace DataGridTable {
|
||||
}
|
||||
|
||||
// docgen doesn't work well with type params, so we let it use DataGridTableInternal instead
|
||||
// eslint-disable-next-line import/export
|
||||
export { DataGridTable, DataGridTableInternal };
|
||||
export type { DataGridTableProps };
|
||||
export default DataGridTable;
|
||||
@@ -86,7 +86,9 @@ const AutoSuggest = forwardRef<HTMLInputElement, AutoSuggestProps>(
|
||||
<Floating>
|
||||
<Listbox setVirtuallyFocusedOptionId={setVirtuallyFocusedOptionId}>
|
||||
<Floating.Anchor>
|
||||
<Listbox.InputSlot>
|
||||
<Listbox.InputSlot
|
||||
virtuallyFocusedOptionId={virtuallyFocusedOptionId}
|
||||
>
|
||||
<Search
|
||||
label="Tabellsøk"
|
||||
variant="simple"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { isSameDay } from "date-fns";
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "../button";
|
||||
import { Dialog } from "../dialog";
|
||||
import Modal from "../modal/Modal";
|
||||
import { Modal } from "../modal";
|
||||
import { Box } from "../primitives/box";
|
||||
import { HGrid } from "../primitives/grid";
|
||||
import { HStack, Spacer, VStack } from "../primitives/stack";
|
||||
|
||||
Loaded 100 of 448 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user