mirror of
https://github.com/navikt/aksel.git
synced 2026-10-17 11:12:03 +00:00
Compare commits
149
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee976548c7 | ||
|
|
34b28ca9cd | ||
|
|
4fb29fa9a9 | ||
|
|
30f687ba6c | ||
|
|
6ce7ce7d4b | ||
|
|
154f12f84e | ||
|
|
aea6430c9f | ||
|
|
2b9f450028 | ||
|
|
b46949eb56 | ||
|
|
137ddc1841 | ||
|
|
19dc8cadb6 | ||
|
|
336503c5c1 | ||
|
|
4cdb7feb02 | ||
|
|
7330c74c7b | ||
|
|
604cd7b5f7 | ||
|
|
be7d524851 | ||
|
|
b42b259a4f | ||
|
|
def2ff5939 | ||
|
|
bb8a9f96df | ||
|
|
e9d723a1ba | ||
|
|
5fdca37439 | ||
|
|
d50aef6ced | ||
|
|
8b0d4a5517 | ||
|
|
79fd31c117 | ||
|
|
92d599fa97 | ||
|
|
c34d9fc966 | ||
|
|
f497787566 | ||
|
|
2a6ec64316 | ||
|
|
cdbda9c231 | ||
|
|
6b0b2f9376 | ||
|
|
ead1139a09 | ||
|
|
de438d487e | ||
|
|
8028ff69e8 | ||
|
|
459ee4a66f | ||
|
|
45c6635ec8 | ||
|
|
ab3bfc8074 | ||
|
|
4329775c9f | ||
|
|
6d3271d946 | ||
|
|
dd02a7d4a9 | ||
|
|
b8738227b2 | ||
|
|
c717b5a2d1 | ||
|
|
e9dd76d7e0 | ||
|
|
19ac81dbb4 | ||
|
|
20e6935a31 | ||
|
|
807b2feef2 | ||
|
|
a9a8bad7a4 | ||
|
|
412a00cdd2 | ||
|
|
7727c3249a | ||
|
|
c40ecab7dd | ||
|
|
ed2eb5885f | ||
|
|
d9ed2ab6eb | ||
|
|
1338d97853 | ||
|
|
fa6c935530 | ||
|
|
1f39de488f | ||
|
|
e29b94e4d2 | ||
|
|
cb2d048ed8 | ||
|
|
11d8874fba | ||
|
|
7d85a0c69e | ||
|
|
e537e040fe | ||
|
|
ed6c7e999d | ||
|
|
cf9992ca3e | ||
|
|
4db5cbfe54 | ||
|
|
54a077fbb7 | ||
|
|
a6e2a2f4d4 | ||
|
|
7e31ee6822 | ||
|
|
35295e7c01 | ||
|
|
0522dfae59 | ||
|
|
53d40657bb | ||
|
|
334770de25 | ||
|
|
faec264163 | ||
|
|
fbfdeba70b | ||
|
|
76c3143d68 | ||
|
|
bec61c3743 | ||
|
|
3fbba87f06 | ||
|
|
62197a107b | ||
|
|
4f55967cf8 | ||
|
|
335b7b7af2 | ||
|
|
1073f5d502 | ||
|
|
ffab045fcb | ||
|
|
8bb8083be3 | ||
|
|
ba7d2be630 | ||
|
|
4d3839e9b5 | ||
|
|
3ff4d17fa0 | ||
|
|
53497224fa | ||
|
|
1284fefa7b | ||
|
|
2f264b7cb7 | ||
|
|
4359fb3aba | ||
|
|
632fcff376 | ||
|
|
08b1ef6d72 | ||
|
|
f7c9bde6d2 | ||
|
|
66d7a204cd | ||
|
|
6d56ec8f51 | ||
|
|
2ecf0ee03c | ||
|
|
ee8a3be487 | ||
|
|
4a4100d0b2 | ||
|
|
1da8a06783 | ||
|
|
88f96906c7 | ||
|
|
a8ae8d2c54 | ||
|
|
045dbda89a | ||
|
|
193b23151e | ||
|
|
b54380304d | ||
|
|
5706c959b2 | ||
|
|
9442ac558b | ||
|
|
e8080c269b | ||
|
|
c35a9cd6a2 | ||
|
|
0d0beae02d | ||
|
|
2eacd71805 | ||
|
|
be22673419 | ||
|
|
b2f7dd4e52 | ||
|
|
3284cee8a9 | ||
|
|
34fbdc9cd3 | ||
|
|
2f1aa0988c | ||
|
|
7bfacc698d | ||
|
|
d02df8313c | ||
|
|
5c94d6ec7f | ||
|
|
3c9d24ba87 | ||
|
|
5e426e9d64 | ||
|
|
7a85f0d27f | ||
|
|
8f75f4426a | ||
|
|
71c16eb6d8 | ||
|
|
776aa84fa8 | ||
|
|
f985e1e83f | ||
|
|
48c7794cac | ||
|
|
10b1892f54 | ||
|
|
193200ec9c | ||
|
|
01fc444219 | ||
|
|
cb5b73c1d5 | ||
|
|
077eae7365 | ||
|
|
aa3f58c00d | ||
|
|
4368a14896 | ||
|
|
45b0939c52 | ||
|
|
d2fb1d8bc1 | ||
|
|
363ee8e483 | ||
|
|
ff454dbc06 | ||
|
|
92f6d9e93b | ||
|
|
516634b852 | ||
|
|
48939bcee0 | ||
|
|
5ea6069010 | ||
|
|
5eee3136a0 | ||
|
|
21521b892e | ||
|
|
f563c18a59 | ||
|
|
ca1bd3f5c0 | ||
|
|
a71f326426 | ||
|
|
fb79fd775c | ||
|
|
8a0afad10c | ||
|
|
92e6128cc9 | ||
|
|
ff0221736c | ||
|
|
abe4a768e1 | ||
|
|
c94d801b45 |
No files matched your search
@@ -1,43 +1,27 @@
|
||||
# Aksel - Copilot Instructions
|
||||
|
||||
## Use this file first
|
||||
## Approach
|
||||
|
||||
- Trust these instructions to narrow the search space, but still read the target code and nearest related files before editing.
|
||||
- Optimize for local work. Prefer known paths, nearby patterns, and existing scripts over broad repo searches.
|
||||
- Ignore release, deploy, changelog, CMS sync, and other remote/ops scripts unless the user asks.
|
||||
- Use these instructions to narrow search space, not replace reading code.
|
||||
- Before editing, read the target file and nearest related files (`index.ts`, story, test, exports).
|
||||
- Prefer known paths and existing scripts over broad repo searches.
|
||||
- Ignore release/deploy/changelog/ops scripts unless asked.
|
||||
|
||||
## How to use this file
|
||||
## Behavior
|
||||
|
||||
- Use this file to narrow the search space, not to replace reading code.
|
||||
- Before editing, read the target file and the nearest related files in the same folder such as `index.ts`, story, test, and package/root export files.
|
||||
- Search wider only when nearby files do not answer the question.
|
||||
|
||||
## Agent mode
|
||||
|
||||
- No apologies, no “I think”, no change summaries.
|
||||
- Don’t ask to confirm info that’s already in context.
|
||||
- Favor performance and security; add robust error handling and safe logging.
|
||||
- Default tone: short and answer-first. Aim for max 0–2 concise sentences where needed.
|
||||
- Thinking and explanation process: Keep it short, omit whats not strictly necessary, and focus on the next edit.
|
||||
- Avoid long explanations or multiple steps at once.
|
||||
- Don't explain thinking process for each step, just do the next edit. Only add explanations when the user explicitly asks for it or when it's necessary to clarify a non-obvious change.
|
||||
|
||||
## Inline chat and editor completions
|
||||
|
||||
- Verify before you claim. Don’t speculate.
|
||||
- Keep functions small and focused; remove duplication (rule of three).
|
||||
- Show only what changes. Provide minimal diffs/snippets; don’t paste whole files or unrelated lines.
|
||||
- Keep edits scoped to a single file unless explicitly asked to touch more.
|
||||
- Extract requirements into a tiny checklist and proceed. Ask a question only when truly blocked.
|
||||
- Avoid heavy formatting: bullets are OK; skip tables; use code fences only for code.
|
||||
- For performance/security: avoid unnecessary re-renders, side effects at module top-level, unsafe HTML, and leaking env vars.
|
||||
- No apologies, no "I think", no change summaries.
|
||||
- Short and answer-first. Max 0-2 sentences where needed.
|
||||
- Don't explain thinking; just do the next edit. Explain only when asked or non-obvious.
|
||||
- Show only what changes. Minimal diffs; don't paste whole files.
|
||||
- Keep edits scoped to one file unless asked. Ask only when truly blocked.
|
||||
- Favor performance and security; avoid re-renders, top-level side effects, unsafe HTML, leaking env vars.
|
||||
|
||||
## Repo summary
|
||||
|
||||
- Large Yarn 4 workspaces monorepo for NAV's design system: public React/CSS/tokens/icons/stylelint/CLI packages plus docs site and playroom.
|
||||
- Yarn 4 workspaces monorepo: public React/CSS/tokens/icons/stylelint/CLI packages + docs site + playroom.
|
||||
- Stack: TypeScript 6, React 19, Next.js 16, Storybook 10, Vitest 4, ESLint 9, Stylelint 17, Biome 2, Prettier 3.
|
||||
- Runtime: Node 22+ recommended, Yarn 4.12.0.
|
||||
- Important constraint: code under `@navikt/*` must stay React 17 API compatible. Do not use React 18/19-only APIs there.
|
||||
- Node 22+, Yarn 4.12.0.
|
||||
- Constraint: `@navikt/*` code must stay React 17 API compatible.
|
||||
|
||||
## Start and validate
|
||||
|
||||
@@ -48,98 +32,78 @@
|
||||
- Run `yarn boot` after first install and after `yarn clean`.
|
||||
- Full install may need `NPM_AUTH_TOKEN` for GitHub Packages.
|
||||
|
||||
Public-package-only install:
|
||||
|
||||
```bash
|
||||
yarn workspaces focus @navikt/aksel-icons @navikt/ds-tokens @navikt/ds-css @navikt/ds-react @navikt/ds-tailwind @navikt/aksel @navikt/aksel-stylelint
|
||||
```
|
||||
Public-package-only: `yarn workspaces focus @navikt/aksel-icons @navikt/ds-tokens @navikt/ds-css @navikt/ds-react @navikt/ds-tailwind @navikt/aksel @navikt/aksel-stylelint`
|
||||
|
||||
## Definition of done
|
||||
|
||||
- Behavior or visual change: update the implementation, closest story, and closest test.
|
||||
- New public export: update the component `index.ts`, package `src/index.ts`, and `package.json` exports.
|
||||
- User-facing package change: add a changeset unless the task says otherwise.
|
||||
- Validate with the narrowest workspace command first; use root commands only for cross-workspace changes.
|
||||
- Behavior/visual change: update implementation, closest story, and closest test.
|
||||
- New public export: sync component `index.ts`, package `src/index.ts`, and `package.json` exports.
|
||||
- User-facing package change: add a changeset unless told otherwise.
|
||||
- Validate with narrowest workspace command first.
|
||||
|
||||
### Common local commands
|
||||
|
||||
Always prefer using these commands that already exists in workspaces before running custom commands or doing manual steps. These are optimized for local development and validation. For example:
|
||||
Prefer existing workspace commands over custom commands:
|
||||
|
||||
- `yarn boot` - build all workspaces
|
||||
- `yarn test` - run all workspace tests
|
||||
- `yarn lint` - eslint + stylelint + biome + cycle + version checks
|
||||
- `yarn boot` - build all
|
||||
- `yarn test` / `yarn lint` - all tests / all linting
|
||||
- `yarn lint:eslint` / `yarn lint:css` / `yarn lint:biome`
|
||||
- `yarn storybook` - root Storybook on `localhost:6006`
|
||||
- `yarn storybook:aksel` - website/example Storybook on `localhost:6007`
|
||||
- `yarn storybook:test` - Storybook browser tests, slow, uses Playwright/Firefox
|
||||
- `yarn dev` - website dev server on `localhost:3000`
|
||||
- `yarn storybook` - root on `:6006`
|
||||
- `yarn storybook:aksel` - website examples on `:6007`
|
||||
- `yarn storybook:test` - browser tests (Playwright/Firefox)
|
||||
- `yarn dev` - website on `:3000`
|
||||
- `yarn clean` - remove build artifacts
|
||||
- `yarn workspace @navikt/ds-react build`
|
||||
- `yarn workspace @navikt/ds-react test`
|
||||
- `yarn workspace @navikt/ds-react build|test`
|
||||
- `yarn workspace website test`
|
||||
- `yarn workspace playroom sync-imports`
|
||||
|
||||
### Validated locally
|
||||
|
||||
- Works: `yarn boot`, `yarn test`, `yarn storybook --ci --smoke-test`, `yarn storybook:aksel --ci --smoke-test`
|
||||
- Caveat: `yarn lint` and `yarn lint:eslint` can fail if playroom imports are stale. Fix with `yarn workspace playroom sync-imports`.
|
||||
Caveat: `yarn lint` can fail if playroom imports are stale. Fix: `yarn workspace playroom sync-imports`.
|
||||
|
||||
## Where to edit
|
||||
|
||||
- `@navikt/core/react/` - `@navikt/ds-react`
|
||||
- `src/<component>/<Component>.tsx`
|
||||
- `src/<component>/index.ts`
|
||||
- `src/<component>/*.stories.tsx`
|
||||
- `src/index.ts`
|
||||
- `package.json` exports map for new public components
|
||||
- `@navikt/core/react/src/<component>/` - component source, index, stories
|
||||
- `@navikt/core/react/src/index.ts` + `package.json` exports - public API surface
|
||||
- `@navikt/core/css/src/` - component CSS
|
||||
- `@navikt/core/tokens/src/` - design token source
|
||||
- `@navikt/core/tokens/src/` - design tokens
|
||||
- `@navikt/core/tailwind/` - Tailwind preset
|
||||
- `@navikt/aksel-icons/icons/` - source SVGs; `src/` is generated
|
||||
- `@navikt/aksel-stylelint/src/` - Stylelint rules/config
|
||||
- `@navikt/aksel-icons/icons/` - source SVGs (`src/` is generated)
|
||||
- `@navikt/aksel-stylelint/src/` - Stylelint rules
|
||||
- `@navikt/aksel/src/` - CLI and codemods
|
||||
- `aksel.nav.no/website/` - Next.js docs site
|
||||
- `aksel.nav.no/playroom/` - playroom and generated imports
|
||||
- `examples/` - example apps
|
||||
- `scripts/` - shared repo scripts
|
||||
- Root references: `README.md`, `CONTRIBUTING.md`, `aksel.nav.no/README.md`, `package.json`, `eslint.config.js`, `stylelint.config.mjs`, `biome.json`, `tsconfig.json`, `tsconfig.build.json`, `.storybook/`, `.github/workflows/build-validation.yml`
|
||||
- `aksel.nav.no/playroom/` - playroom
|
||||
- Root: `eslint.config.js`, `stylelint.config.mjs`, `biome.json`, `tsconfig.json`, `.storybook/`
|
||||
|
||||
## Coding defaults
|
||||
|
||||
- Keep diffs small. No drive-by refactors, formatting churn, or new dependencies unless asked.
|
||||
- Read the full file and copy the nearest existing pattern before changing code.
|
||||
- Keep public APIs stable. Prefer additive props and exports.
|
||||
- In `@navikt/*`, import React in TSX files; root JSX runtime is classic.
|
||||
- Use named exports with explicit type exports.
|
||||
- Keep `forwardRef`, `className`, `...rest`, and `as`/`OverridableComponent` patterns when the surrounding component uses them.
|
||||
- Reuse internal helpers instead of re-implementing them: `cl`, `composeEventHandlers`, `useId`, `omit`, `useClientLayoutEffect`, `useEventListener`.
|
||||
- Use `node:` imports for Node builtins.
|
||||
- Never import from `esm/` or `cjs/` build output.
|
||||
- Keep import order consistent: `@navikt/*`, `@/*`, then relative. Let Prettier handle sorting.
|
||||
- Preserve or add JSDoc for public props/components when changing public APIs.
|
||||
- Small diffs. No drive-by refactors or new deps unless asked.
|
||||
- Copy nearest existing pattern before writing new code.
|
||||
- Stable public APIs. Prefer additive props/exports.
|
||||
- Named exports; separate type exports.
|
||||
- `node:` imports for Node builtins. Never import from `esm/`/`cjs/` output.
|
||||
- Import order: `@navikt/*`, `@/*`, relative. Prettier handles sorting.
|
||||
- Preserve JSDoc on public props/components when changing public APIs.
|
||||
- Reuse helpers: `cl`, `composeEventHandlers`, `useId`, `omit`, `useClientLayoutEffect`, `useEventListener`.
|
||||
|
||||
## React and ds-react specifics
|
||||
## React and ds-react
|
||||
|
||||
- Component `index.ts` files in `@navikt/core/react/src/**` start with `"use client"`.
|
||||
- When adding or exposing a public component, keep these in sync:
|
||||
- component `index.ts`
|
||||
- `@navikt/core/react/src/index.ts`
|
||||
- `@navikt/core/react/package.json` exports
|
||||
- Do not use React 18/19-only APIs under `@navikt/*`. Use internal helpers like `useId`.
|
||||
- `@navikt/*` TSX files import React explicitly (classic JSX runtime).
|
||||
- Component `index.ts` files start with `"use client"`.
|
||||
- Keep `forwardRef`, `className`, `...rest`, `as`/`OverridableComponent` patterns.
|
||||
- No React 18/19-only APIs in `@navikt/*`. Use internal `useId` etc.
|
||||
- New public component: sync component `index.ts` + `src/index.ts` + `package.json` exports.
|
||||
|
||||
## CSS and tokens
|
||||
|
||||
- Never hardcode colors, spacing, radius, or shadows. Use tokens only.
|
||||
- Public tokens use `--ax-*`. Internal component variables use `--__axc-*`.
|
||||
- Follow existing CSS nesting, `data-*` selectors, `focus-visible`, `forced-colors`, and reduced-motion patterns.
|
||||
- Prettier formats; Biome is lint-only.
|
||||
- Tokens only: `--ax-*` (public), `--__axc-*` (internal component vars).
|
||||
- Follow existing CSS nesting, `data-*` selectors, `focus-visible`, `forced-colors`, reduced-motion.
|
||||
- Prettier formats CSS; Biome is lint-only.
|
||||
|
||||
## Stories, tests, website
|
||||
|
||||
- Update the closest story and test whenever behavior or visuals change.
|
||||
- Use Testing Library queries by role and name.
|
||||
- Stories live next to components and use `@storybook/react-vite`.
|
||||
- For component work, prefer Storybook over `yarn dev`.
|
||||
- `aksel.nav.no/website/pages/eksempler/**` and `pages/templates/**` feed the website and Storybook. In these files, only import from `@navikt/*` and `react`.
|
||||
- The website uses both `app/` and `pages/`; stay inside the router already used nearby.
|
||||
- `yarn storybook:aksel` works for website examples/templates without Sanity secrets. `yarn dev` may need website env values such as `SANITY_READ_NO_DRAFTS`.
|
||||
- Update closest story + test when behavior/visuals change.
|
||||
- Testing Library: query by role and name.
|
||||
- Stories use `@storybook/react-vite`, live next to components.
|
||||
- Prefer `yarn storybook` for component work over `yarn dev`.
|
||||
- `pages/eksempler/**` and `pages/templates/**`: import only `@navikt/*` and `react`.
|
||||
- Website uses `app/` and `pages/`; stay in the router already used nearby.
|
||||
- `yarn storybook:aksel` needs no secrets. `yarn dev` may need `SANITY_READ_NO_DRAFTS`.
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
description: >
|
||||
Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
|
||||
while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra.
|
||||
Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
|
||||
"be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
|
||||
applyTo: "**"
|
||||
---
|
||||
|
||||
# caveman
|
||||
|
||||
ACTIVE EVERY RESPONSE.
|
||||
|
||||
default: full
|
||||
|
||||
switch mode:
|
||||
|
||||
- "/caveman lite" → lite
|
||||
- "/caveman full" → full
|
||||
- "/caveman ultra" → ultra
|
||||
- "stop caveman" | "normal mode" → off
|
||||
|
||||
persist: keep last state
|
||||
|
||||
## intensity
|
||||
|
||||
| Level | What change |
|
||||
| --------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| **lite** | short sentences, no filler, keep grammar |
|
||||
| **full** | fragments ok, drop articles, short words. Classic caveman |
|
||||
| **ultra** | Abbreviate (DB/auth/config/req/res/fn/impl), fragments, abbrev (db/api/req/res/fn), arrows (→), minimal words |
|
||||
|
||||
Example - "Why React component re-render?"
|
||||
|
||||
- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
|
||||
- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
||||
- ultra: "Inline obj prop → new ref → re-render. `useMemo`."
|
||||
|
||||
## global rules
|
||||
|
||||
- no filler (just/really/basically/etc)
|
||||
- no pleasantries
|
||||
- no hedging
|
||||
- keep tech exact
|
||||
- no long sentences
|
||||
- prefer symbols (→, =)
|
||||
|
||||
## patterns
|
||||
|
||||
Patterns: `[thing] [action] [reason]. [next step].`
|
||||
|
||||
## examples
|
||||
|
||||
lite:
|
||||
"Component re-renders because you create a new object each render. Use useMemo."
|
||||
|
||||
full:
|
||||
"New object each render → new ref → re-render. useMemo."
|
||||
|
||||
ultra:
|
||||
"inline obj → new ref → re-render. fix: useMemo."
|
||||
|
||||
## auto-Clarity
|
||||
|
||||
Drop caveman for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user asks to clarify or repeats question. Resume caveman after clear part done.
|
||||
|
||||
Example - destructive op:
|
||||
|
||||
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
|
||||
>
|
||||
> ```sql
|
||||
> DROP TABLE users;
|
||||
> ```
|
||||
>
|
||||
> Caveman resume. Verify backup exist first.
|
||||
|
||||
## boundaries
|
||||
|
||||
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end
|
||||
@@ -1,11 +1,11 @@
|
||||
description: "Use for JS/TS code in this repo."
|
||||
applyTo: "**/\*.tsx", "**/_.jsx", "\*\*/_.ts", "\*_/_.js"
|
||||
|
||||
---
|
||||
description: "JS/TS code conventions."
|
||||
applyTo: "**/*.tsx", "**/*.jsx", "**/*.ts", "**/*.js"
|
||||
---
|
||||
|
||||
# JS/TS instructions
|
||||
|
||||
- Prefer `for...of` over `Array.forEach`.
|
||||
- Prefer named exports; keep type exports separate from value exports.
|
||||
- Group exports at the bottom: values first, then types.
|
||||
- In `*.stories.*`, keep the default export at the top.
|
||||
- Named exports; keep type exports separate from value exports.
|
||||
- Group exports at bottom: values first, then types.
|
||||
- In `*.stories.*`, keep default export at top.
|
||||
@@ -1,13 +1,13 @@
|
||||
description: "Use for ds-react components and component CSS."
|
||||
---
|
||||
description: "ds-react components and component CSS."
|
||||
applyTo: "@navikt/core/react/src/**", "@navikt/core/css/src/**"
|
||||
|
||||
---
|
||||
|
||||
# ds-react and component instructions
|
||||
# ds-react component instructions
|
||||
|
||||
- Read the full component folder before editing.
|
||||
- Preserve the surrounding `forwardRef`, `className`, `...rest`, and `as`/`OverridableComponent` patterns.
|
||||
- Preserve `forwardRef`, `className`, `...rest`, `as`/`OverridableComponent` patterns.
|
||||
- Keep `@navikt/*` code React 17 API compatible.
|
||||
- Use tokens, not hardcoded component values.
|
||||
- If behavior or visuals change, update the nearest story and test.
|
||||
- If a public component changes, keep component exports, package exports, and root exports in sync.
|
||||
- Use tokens, not hardcoded values.
|
||||
- Behavior/visual change: update nearest story and test.
|
||||
- Public component change: keep component, package, and root exports in sync.
|
||||
@@ -1,11 +1,11 @@
|
||||
description: "Use for tests and stories."
|
||||
applyTo: "**/_.test._", "**/\*.stories.tsx"
|
||||
|
||||
---
|
||||
description: "Tests and stories."
|
||||
applyTo: "**/*.test.*", "**/*.stories.tsx"
|
||||
---
|
||||
|
||||
# Testing and Storybook instructions
|
||||
|
||||
- Prefer story `play` functions and `yarn storybook:test` for real user flows; use Vitest for logic and non-browser cases.
|
||||
- Prefer focused assertions over snapshots unless nearby tests already use snapshots.
|
||||
- Cover the main path and one or two meaningful edge cases.
|
||||
- If UI behavior changes, update the nearest story and test in the same change.
|
||||
- Prefer story `play` functions + `yarn storybook:test` for user flows; Vitest for logic/non-browser cases.
|
||||
- Prefer focused assertions over snapshots unless nearby tests use them.
|
||||
- Cover main path + one or two meaningful edge cases.
|
||||
- UI behavior change: update nearest story and test in the same change.
|
||||
@@ -1,14 +1,11 @@
|
||||
description: "Use for website and playroom work."
|
||||
applyTo:
|
||||
|
||||
- "aksel.nav.no/website/\*\*"
|
||||
- "aksel.nav.no/playroom/\*\*"
|
||||
|
||||
---
|
||||
description: "Website and playroom."
|
||||
applyTo: "aksel.nav.no/website/**, aksel.nav.no/playroom/**"
|
||||
---
|
||||
|
||||
# Website and playroom instructions
|
||||
|
||||
- Stay inside the router already used nearby; do not mix `app/` and `pages/` patterns in the same area.
|
||||
- In `aksel.nav.no/website/pages/eksempler/**` and `aksel.nav.no/website/pages/templates/**`, only import from `@navikt/*` and `react`.
|
||||
- Reuse nearby website utilities and patterns before adding abstractions.
|
||||
- Prefer `yarn storybook:aksel` for examples/templates; use `yarn dev` only for app-only behavior.
|
||||
- Stay in the router already used nearby; don't mix `app/` and `pages/` patterns.
|
||||
- `pages/eksempler/**` and `pages/templates/**`: only import from `@navikt/*` and `react`.
|
||||
- Reuse nearby utilities and patterns before adding abstractions.
|
||||
- Prefer `yarn storybook:aksel` for examples/templates; `yarn dev` only for app-specific behavior.
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
name: Playwright shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-noble
|
||||
services:
|
||||
testapp:
|
||||
image: ${{ needs.prebuild-app-for-playwright.outputs.image }}
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-noble
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @navikt/aksel-icons
|
||||
|
||||
## 8.10.6
|
||||
|
||||
## 8.10.5
|
||||
|
||||
## 8.10.4
|
||||
|
||||
## 8.10.3
|
||||
|
||||
## 8.10.2
|
||||
|
||||
## 8.10.1
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"dependencies": {
|
||||
"@navikt/ds-css": "*",
|
||||
"@navikt/ds-react": "*",
|
||||
"fuse.js": "^7.0.0",
|
||||
"fuse.js": "^7.3.0",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5"
|
||||
},
|
||||
@@ -23,8 +23,8 @@
|
||||
"@types/node": "^24.10.9",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"typescript": "6.0.2",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^7.3.2",
|
||||
"vite-plugin-singlefile": "^2.3.0",
|
||||
"vite-plugin-svgr": "^4.2.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel-icons",
|
||||
"version": "8.10.2",
|
||||
"version": "8.10.6",
|
||||
"author": "Aksel | Nav",
|
||||
"homepage": "https://aksel.nav.no/ikoner",
|
||||
"repository": {
|
||||
@@ -88,8 +88,8 @@
|
||||
"rehype-parse": "9.0.1",
|
||||
"tsc-alias": "1.8.16",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "6.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"unified": "11.0.5",
|
||||
"vitest": "4.1.4"
|
||||
"vitest": "4.1.5"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
# @navikt/aksel-stylelint
|
||||
|
||||
## 8.10.6
|
||||
|
||||
## 8.10.5
|
||||
|
||||
## 8.10.4
|
||||
|
||||
## 8.10.3
|
||||
|
||||
## 8.10.2
|
||||
|
||||
## 8.10.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel-stylelint",
|
||||
"version": "8.10.2",
|
||||
"version": "8.10.6",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
@@ -42,14 +42,14 @@
|
||||
"dev": "yarn watch:lint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@navikt/ds-css": "^8.10.2",
|
||||
"@navikt/ds-tokens": "^8.10.2",
|
||||
"@navikt/ds-css": "^8.10.6",
|
||||
"@navikt/ds-tokens": "^8.10.6",
|
||||
"concurrently": "9.2.1",
|
||||
"postcss-selector-parser": "^7.1.0",
|
||||
"postcss-value-parser": "^4.2.0",
|
||||
"stylelint": "^17.2.0",
|
||||
"stylelint-test-rule-node": "^1.0.0",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "6.0.2"
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,37 @@
|
||||
# @navikt/aksel
|
||||
|
||||
## 8.10.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`1f39de4`](https://github.com/navikt/aksel/commit/1f39de488fa3b1942c5e81cf22da3cf4c93bde26)]:
|
||||
- @navikt/ds-css@8.10.6
|
||||
- @navikt/ds-tokens@8.10.6
|
||||
|
||||
## 8.10.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`35295e7`](https://github.com/navikt/aksel/commit/35295e7c010bd4866c3cd4ae9ff72641a234b089), [`0522dfa`](https://github.com/navikt/aksel/commit/0522dfae59320477b10cab2941d0b004d280d79a)]:
|
||||
- @navikt/ds-css@8.10.5
|
||||
- @navikt/ds-tokens@8.10.5
|
||||
|
||||
## 8.10.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-css@8.10.4
|
||||
- @navikt/ds-tokens@8.10.4
|
||||
|
||||
## 8.10.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`92f6d9e`](https://github.com/navikt/aksel/commit/92f6d9e93b879962df8415f7fad22203174dd3a8)]:
|
||||
- @navikt/ds-css@8.10.3
|
||||
- @navikt/ds-tokens@8.10.3
|
||||
|
||||
## 8.10.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel",
|
||||
"version": "8.10.2",
|
||||
"version": "8.10.6",
|
||||
"description": "Aksel command line interface. Codemods and other utilities for Aksel users.",
|
||||
"author": "Aksel | Nav designsystem team",
|
||||
"license": "MIT",
|
||||
@@ -40,8 +40,8 @@
|
||||
"url": "https://github.com/navikt/aksel/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@navikt/ds-css": "^8.10.2",
|
||||
"@navikt/ds-tokens": "^8.10.2",
|
||||
"@navikt/ds-css": "^8.10.6",
|
||||
"@navikt/ds-tokens": "^8.10.6",
|
||||
"chalk": "5.6.2",
|
||||
"cli-progress": "3.12.0",
|
||||
"clipboardy": "5.0.1",
|
||||
@@ -59,8 +59,8 @@
|
||||
"@types/is-git-clean": "1.1.2",
|
||||
"@types/jscodeshift": "17.3.0",
|
||||
"rimraf": "6.1.3",
|
||||
"typescript": "6.0.2",
|
||||
"vitest": "4.1.4"
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.5"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"engines": {
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# @navikt/ds-css
|
||||
|
||||
## 8.10.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 🎨 InfoCard: Added bigger padding for medium sized InfoCard.Message ([#4861](https://github.com/navikt/aksel/pull/4861))
|
||||
|
||||
## 8.10.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Pagination: Fix selected-styling in forced-colors mode ([#4858](https://github.com/navikt/aksel/pull/4858))
|
||||
|
||||
- Button: Fix issue where secondary and tertiary variants did not have correct text color is certain cases ([#4857](https://github.com/navikt/aksel/pull/4857))
|
||||
|
||||
## 8.10.4
|
||||
|
||||
## 8.10.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add module declaration to avoid TypeScript error when importing ([`92f6d9e`](https://github.com/navikt/aksel/commit/92f6d9e93b879962df8415f7fad22203174dd3a8))
|
||||
|
||||
## 8.10.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-css",
|
||||
"version": "8.10.2",
|
||||
"version": "8.10.6",
|
||||
"description": "CSS for Nav Designsystem",
|
||||
"author": "Aksel | Nav designsystem team",
|
||||
"keywords": [
|
||||
@@ -14,7 +14,9 @@
|
||||
"files": [
|
||||
"*",
|
||||
"!dist/version",
|
||||
"!config/bundle.js"
|
||||
"!config",
|
||||
"!.gitignore",
|
||||
"!CHANGELOG.md"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -30,9 +32,9 @@
|
||||
"css:get-version": "node config/get-version.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@navikt/ds-tokens": "^8.10.2",
|
||||
"@navikt/ds-tokens": "^8.10.6",
|
||||
"browserslist": "^4.25.0",
|
||||
"esbuild": "^0.27.4",
|
||||
"esbuild": "^0.28.0",
|
||||
"fast-glob": "3.3.3",
|
||||
"lightningcss": "^1.30.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
@@ -46,9 +48,13 @@
|
||||
],
|
||||
"main": "./dist/index.css",
|
||||
"exports": {
|
||||
".": "./dist/index.css",
|
||||
"./dist/*": "./dist/*",
|
||||
"./tokens.json": "./tokens.json",
|
||||
"./config/_mappings": "./config/_mappings.js"
|
||||
".": {
|
||||
"types": "./types.d.ts",
|
||||
"default": "./dist/index.css"
|
||||
},
|
||||
"./dist/*": {
|
||||
"types": "./types.d.ts",
|
||||
"default": "./dist/*"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,13 +215,17 @@
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--ax-space-8);
|
||||
padding: var(--ax-space-12) var(--ax-space-16);
|
||||
padding: var(--ax-space-16) var(--ax-space-20);
|
||||
|
||||
& > .aksel-base-alert__icon {
|
||||
margin-top: var(--ax-space-2);
|
||||
}
|
||||
|
||||
.aksel-base-alert[data-size="small"] & > .aksel-base-alert__icon {
|
||||
margin-top: 0;
|
||||
.aksel-base-alert[data-size="small"] & {
|
||||
padding: var(--ax-space-12) var(--ax-space-16);
|
||||
|
||||
& > .aksel-base-alert__icon {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,14 +42,22 @@
|
||||
--__axc-button-border-color: var(--ax-border-default);
|
||||
|
||||
background-color: transparent;
|
||||
color: var(--ax-text-subtle);
|
||||
|
||||
/* secondary + neutral should use "default" text color.
|
||||
Check for data-color=neutral on both the button itself and the parents. */
|
||||
&[data-color="neutral"],
|
||||
[data-color="neutral"] > &:not([data-color]),
|
||||
[data-color="neutral"] :not([data-color]) &:not([data-color]) {
|
||||
[data-color="neutral"] &:not([data-color]) {
|
||||
color: var(--ax-text-default);
|
||||
}
|
||||
|
||||
/* secondary + NOT neutral should use "subtle" text color.
|
||||
We need to explicitly target cases where a parent has data-color=neutral but
|
||||
a closer parent has a different data-color (overriding the selector above). */
|
||||
&,
|
||||
[data-color="neutral"] [data-color]:not([data-color="neutral"]) & {
|
||||
color: var(--ax-text-subtle);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--__axc-button-border-color: var(--ax-border-strong);
|
||||
|
||||
@@ -73,14 +81,22 @@
|
||||
|
||||
&[data-variant="tertiary"] {
|
||||
background-color: transparent;
|
||||
color: var(--ax-text-subtle);
|
||||
|
||||
/* tertiary + neutral should use "default" text color.
|
||||
Check for data-color=neutral on both the button itself and the parents. */
|
||||
&[data-color="neutral"],
|
||||
[data-color="neutral"] > &:not([data-color], [data-pressed="true"]),
|
||||
[data-color="neutral"] :not([data-color]) &:not([data-color], [data-pressed="true"]) {
|
||||
[data-color="neutral"] &:not([data-color], [data-pressed="true"]) {
|
||||
color: var(--ax-text-default);
|
||||
}
|
||||
|
||||
/* tertiary + NOT neutral should use "subtle" text color.
|
||||
We need to explicitly target cases where a parent has data-color=neutral but
|
||||
a closer parent has a different data-color (overriding the selector above). */
|
||||
&,
|
||||
[data-color="neutral"] [data-color]:not([data-color="neutral"]) &:not([data-pressed="true"]) {
|
||||
color: var(--ax-text-subtle);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--ax-bg-moderate-hoverA);
|
||||
}
|
||||
@@ -231,6 +247,15 @@
|
||||
color: highlight;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&[data-variant="tertiary"][data-pressed="true"] {
|
||||
background-color: selecteditem;
|
||||
color: selecteditemtext;
|
||||
|
||||
& span {
|
||||
forced-color-adjust: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.aksel-button:not(:disabled):hover span {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
scroll-timeline: --horizontal-table-scroll inline;
|
||||
}
|
||||
|
||||
.aksel-data-table {
|
||||
@@ -34,14 +35,23 @@
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
--__axc-data-table-density: var(--ax-space-16);
|
||||
/* Default font size is prop "medium", which is variable --ax-font-size-large */
|
||||
--__axc-data-table-text-size: var(--ax-font-size-large);
|
||||
--__axc-data-table-density-base: var(--ax-space-16);
|
||||
--__axc-data-table-density-scale: 1;
|
||||
--__axc-data-table-density: calc(var(--__axc-data-table-density-base) * var(--__axc-data-table-density-scale));
|
||||
|
||||
&[data-text-size="small"] {
|
||||
--__axc-data-table-text-size: var(--ax-font-size-medium);
|
||||
--__axc-data-table-density-scale: 0.8889; /* 16/18 */
|
||||
}
|
||||
|
||||
&[data-density="condensed"] {
|
||||
--__axc-data-table-density: var(--ax-space-8);
|
||||
--__axc-data-table-density-base: var(--ax-space-8);
|
||||
}
|
||||
|
||||
&[data-density="spacious"] {
|
||||
--__axc-data-table-density: var(--ax-space-32);
|
||||
--__axc-data-table-density-base: var(--ax-space-32);
|
||||
}
|
||||
|
||||
&[data-zebra-stripes="true"] {
|
||||
@@ -104,15 +114,17 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
box-shadow: 1px 1px 0 0 var(--ax-border-neutral-subtle);
|
||||
|
||||
&::after {
|
||||
/* This breaks filler-cells ⬇️. Trying box-shadow instead ⬆️ */
|
||||
/* &::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
bottom: -1px;
|
||||
pointer-events: none;
|
||||
border-bottom: 1px solid var(--ax-border-neutral-subtle);
|
||||
}
|
||||
} */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +158,27 @@
|
||||
box-shadow: 0 1px 0 0 var(--ax-border-neutral-subtle);
|
||||
}
|
||||
|
||||
@keyframes scroll-shadow-fade-start {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
10%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scroll-shadow-fade-end {
|
||||
90% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.aksel-data-table__cell {
|
||||
/* border doesn't work on sticky thead when we have collapsed borders, so using box-shadow instead */
|
||||
box-shadow: inset 1px 1px 0 0 var(--ax-border-neutral-subtle);
|
||||
@@ -153,11 +186,12 @@
|
||||
vertical-align: middle;
|
||||
scroll-margin: var(--ax-space-0);
|
||||
padding: 0;
|
||||
font-size: var(--__axc-data-table-text-size);
|
||||
|
||||
&[data-nested="true"] {
|
||||
position: relative;
|
||||
|
||||
& > div {
|
||||
& > .aksel-data-table__cell-content {
|
||||
margin-inline-start: calc(32px + 8px + calc(var(--__axc-data-table-nested-depth) * var(--ax-space-16)));
|
||||
}
|
||||
}
|
||||
@@ -195,7 +229,25 @@
|
||||
&[data-sticky="start"] {
|
||||
left: 0;
|
||||
|
||||
&::after {
|
||||
&[data-sticky-last]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
box-shadow: 4px 0 8px 1px var(--ax-border-neutral-subtleA);
|
||||
clip-path: inset(0 -24px 0 0);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.aksel-data-table[data-scroll] &[data-sticky-last]::before {
|
||||
opacity: 1;
|
||||
animation: scroll-shadow-fade-start 1s linear;
|
||||
animation-timeline: --horizontal-table-scroll;
|
||||
}
|
||||
|
||||
&[data-sticky-last]::after {
|
||||
inset-inline: 0 -1px;
|
||||
border-right-width: 1px;
|
||||
}
|
||||
@@ -203,10 +255,32 @@
|
||||
|
||||
&[data-sticky="end"] {
|
||||
right: 0;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
box-shadow: -4px 0 8px 1px var(--ax-border-neutral-subtleA);
|
||||
clip-path: inset(0 0 0 -24px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.aksel-data-table[data-scroll] &::before {
|
||||
opacity: 1;
|
||||
animation: scroll-shadow-fade-end 1s linear;
|
||||
animation-timeline: --horizontal-table-scroll;
|
||||
}
|
||||
}
|
||||
|
||||
background-color: var(--ax-bg-raised);
|
||||
|
||||
.aksel-data-table__tr[data-selected="true"] & {
|
||||
background-color: var(--ax-bg-soft);
|
||||
}
|
||||
|
||||
.aksel-data-table__thead & {
|
||||
background-color: var(--ax-bg-neutral-soft);
|
||||
}
|
||||
@@ -220,10 +294,10 @@
|
||||
outline-offset: -4px;
|
||||
}
|
||||
|
||||
&[data-selectable="true"] {
|
||||
&[data-cell-type="action"] {
|
||||
padding: 0;
|
||||
|
||||
& > div {
|
||||
& > .aksel-data-table__cell-content {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
}
|
||||
@@ -231,14 +305,29 @@
|
||||
|
||||
--__axc-data-table-pi-cell: var(--ax-space-16);
|
||||
|
||||
&:not([data-selectable="true"]) > div {
|
||||
&:not([data-cell-type="action"]) > .aksel-data-table__cell-content {
|
||||
padding-block: var(--__axc-data-table-density);
|
||||
padding-inline: var(--__axc-data-table-pi-cell);
|
||||
overflow: hidden;
|
||||
|
||||
.aksel-data-table[data-truncate-content="true"] & {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
/* TODO: Too magical? Consider prop instead */
|
||||
> :is(.aksel-tag, .aksel-button) {
|
||||
vertical-align: top;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: var(--ax-space-8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.aksel-data-table[data-truncate-content="false"] &:has(.aksel-tag, .aksel-button) {
|
||||
display: flex;
|
||||
gap: var(--ax-space-8);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -377,11 +466,11 @@
|
||||
transform: translateY(-50%);
|
||||
height: 1.5rem;
|
||||
|
||||
&.aksel-data-table__th-resize-handle-indicator--start {
|
||||
&:first-child {
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
&.aksel-data-table__th-resize-handle-indicator--end {
|
||||
&:last-child {
|
||||
left: 12px;
|
||||
}
|
||||
}
|
||||
@@ -429,10 +518,10 @@
|
||||
outline-offset: -4px;
|
||||
}
|
||||
|
||||
&[data-selectable="true"] {
|
||||
&[data-cell-type="action"] {
|
||||
padding: 0;
|
||||
|
||||
& > div {
|
||||
& > .aksel-data-table__cell-content {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
}
|
||||
@@ -444,9 +533,14 @@
|
||||
}
|
||||
|
||||
.aksel-data-table__filler-cell {
|
||||
width: auto;
|
||||
display: table-cell;
|
||||
padding: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.aksel-data-table__drag-and-drop-root {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.aksel-data-drag-and-drop__drag-handler__button {
|
||||
@@ -469,21 +563,11 @@
|
||||
}
|
||||
|
||||
.aksel-data-table__drag-and-drop-item {
|
||||
background-color: var(--ax-bg-default);
|
||||
|
||||
&[data-overlay="true"] {
|
||||
width: 100%;
|
||||
opacity: 0.8;
|
||||
box-shadow: var(--ax-shadow-dialog);
|
||||
}
|
||||
|
||||
/* TODO - might be removable */
|
||||
&[data-keyboard-dragging="true"] {
|
||||
.aksel-data-drag-and-drop__drag-handler__button {
|
||||
outline: 2px solid var(--ax-border-focus);
|
||||
outline-offset: -1px;
|
||||
border-radius: var(--ax-radius-8);
|
||||
}
|
||||
background-color: var(--ax-bg-default);
|
||||
}
|
||||
|
||||
&[data-drop-target="true"]:not([data-overlay="true"]) {
|
||||
@@ -491,7 +575,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.aksel-data-drag-and-drop__drag-handler__alt,
|
||||
.aksel-data-drag-and-drop__drag-handler {
|
||||
position: relative;
|
||||
height: 1.5rem;
|
||||
@@ -593,3 +676,14 @@ html[data-dragging-cursor="true"] *::after {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.aksel-data-table__details-panel-row {
|
||||
box-shadow: inset 1px 1px 0 0 var(--ax-border-neutral-subtle);
|
||||
}
|
||||
|
||||
.aksel-data-table__details-panel-row-cell {
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--ax-border-focus);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module "@navikt/ds-css" {}
|
||||
@@ -1,5 +1,43 @@
|
||||
# @navikt/ds-react
|
||||
|
||||
## 8.10.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@8.10.6
|
||||
- @navikt/aksel-icons@8.10.6
|
||||
|
||||
## 8.10.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Dependencies: Bumped `@floating-ui/react` from v0.27.8 -> v0.27.19 ([#4851](https://github.com/navikt/aksel/pull/4851))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@8.10.5
|
||||
- @navikt/aksel-icons@8.10.5
|
||||
|
||||
## 8.10.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ActionMenu: Now locked to bottom/top side placement, left/right for sub-menus. ([#4843](https://github.com/navikt/aksel/pull/4843))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@8.10.4
|
||||
- @navikt/aksel-icons@8.10.4
|
||||
|
||||
## 8.10.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Radio, Checkbox: Fix regression where `className` was mistakenly passed to `input`-element ([#4837](https://github.com/navikt/aksel/pull/4837))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@8.10.3
|
||||
- @navikt/aksel-icons@8.10.3
|
||||
|
||||
## 8.10.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-react",
|
||||
"version": "8.10.2",
|
||||
"version": "8.10.6",
|
||||
"description": "React components from the Norwegian Labour and Welfare Administration.",
|
||||
"author": "Aksel, a team part of the Norwegian Labour and Welfare Administration.",
|
||||
"license": "MIT",
|
||||
@@ -698,16 +698,16 @@
|
||||
"clean": "rimraf cjs esm",
|
||||
"build": "yarn i18n-jsdoc && concurrently \"tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json\" \"tsc -p tsconfig.esm.json && tsc-alias -p tsconfig.esm.json && yarn write-packagejson\" && yarn i18n-jsdoc --cleanup && yarn copy-types",
|
||||
"watch": "tsc --watch -p tsconfig.esm.json",
|
||||
"test": "concurrently \"TZ=UTC vitest run -c tests/vitest.config.ts\" \"yarn test-sb-build\"",
|
||||
"test": "concurrently \"cross-env TZ=UTC vitest run -c tests/vitest.config.ts\" \"yarn test-sb-build\"",
|
||||
"test-sb-build": "tsc -p tsconfig.sb.json",
|
||||
"test:watch": "TZ=UTC vitest watch -c tests/vitest.config.ts",
|
||||
"copy-types": "copyfiles -f ./src/types/theme.d.ts ./esm/types"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "0.27.8",
|
||||
"@floating-ui/react": "0.27.19",
|
||||
"@floating-ui/react-dom": "^2.1.8",
|
||||
"@navikt/aksel-icons": "^8.10.2",
|
||||
"@navikt/ds-tokens": "^8.10.2",
|
||||
"@navikt/aksel-icons": "^8.10.6",
|
||||
"@navikt/ds-tokens": "^8.10.6",
|
||||
"date-fns": "^4.0.0",
|
||||
"react-day-picker": "9.14.0"
|
||||
},
|
||||
@@ -723,6 +723,7 @@
|
||||
"aksel": "workspace:^",
|
||||
"concurrently": "9.2.1",
|
||||
"copyfiles": "^2.4.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"fast-glob": "3.3.3",
|
||||
"jscodeshift": "17.3.0",
|
||||
"jsdom": "27.1.0",
|
||||
@@ -733,8 +734,8 @@
|
||||
"swr": "^2.4.1",
|
||||
"tsc-alias": "1.8.16",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "6.0.2",
|
||||
"vitest": "4.1.4"
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^17.0.30 || ^18 || ^19",
|
||||
|
||||
@@ -384,7 +384,7 @@ export const ActionMenuContent = forwardRef<
|
||||
{...rest}
|
||||
align={align}
|
||||
sideOffset={4}
|
||||
collisionPadding={10}
|
||||
collisionPadding={5}
|
||||
returnFocus={context.triggerRef}
|
||||
safeZone={{
|
||||
anchor: context.triggerRef.current,
|
||||
|
||||
@@ -184,126 +184,66 @@ export const ColorRole = () => (
|
||||
</HStack>
|
||||
<h3>data-color=neutral on wrapper and danger on button</h3>
|
||||
<HStack gap="space-8" data-color="neutral">
|
||||
<Button data-color="danger" variant="secondary" icon={<StarIcon />}>
|
||||
<Button data-color="danger" variant="secondary">
|
||||
Button
|
||||
</Button>
|
||||
<Button data-color="danger" variant="tertiary" icon={<StarIcon />}>
|
||||
<Button data-color="danger" variant="tertiary">
|
||||
Button
|
||||
</Button>
|
||||
<Button
|
||||
data-color="danger"
|
||||
variant="secondary"
|
||||
icon={<StarIcon />}
|
||||
disabled
|
||||
>
|
||||
<Button data-color="danger" variant="tertiary" data-pressed>
|
||||
Pressed
|
||||
</Button>
|
||||
<Button data-color="danger" variant="secondary" disabled>
|
||||
Button
|
||||
</Button>
|
||||
<Button
|
||||
data-color="danger"
|
||||
variant="tertiary"
|
||||
icon={<StarIcon />}
|
||||
disabled
|
||||
>
|
||||
<Button data-color="danger" variant="tertiary" disabled>
|
||||
Button
|
||||
</Button>
|
||||
</HStack>
|
||||
<h3>data-color=neutral on parent wrapper and danger on button</h3>
|
||||
<div data-color="neutral">
|
||||
<HStack gap="space-8">
|
||||
<Button data-color="danger" variant="secondary" icon={<StarIcon />}>
|
||||
Button
|
||||
</Button>
|
||||
<Button data-color="danger" variant="tertiary" icon={<StarIcon />}>
|
||||
Button
|
||||
</Button>
|
||||
<Button
|
||||
data-color="danger"
|
||||
variant="secondary"
|
||||
icon={<StarIcon />}
|
||||
disabled
|
||||
>
|
||||
Button
|
||||
</Button>
|
||||
<Button
|
||||
data-color="danger"
|
||||
variant="tertiary"
|
||||
icon={<StarIcon />}
|
||||
disabled
|
||||
>
|
||||
Button
|
||||
</Button>
|
||||
</HStack>
|
||||
</div>
|
||||
<h3>data-color=neutral on button</h3>
|
||||
<HStack gap="space-8">
|
||||
<Button data-color="neutral" variant="secondary" icon={<StarIcon />}>
|
||||
<Button data-color="neutral" variant="secondary">
|
||||
Button
|
||||
</Button>
|
||||
<Button data-color="neutral" variant="tertiary" icon={<StarIcon />}>
|
||||
<Button data-color="neutral" variant="tertiary">
|
||||
Button
|
||||
</Button>
|
||||
<Button
|
||||
data-color="neutral"
|
||||
variant="secondary"
|
||||
icon={<StarIcon />}
|
||||
disabled
|
||||
>
|
||||
<Button data-color="neutral" variant="tertiary" data-pressed>
|
||||
Pressed
|
||||
</Button>
|
||||
<Button data-color="neutral" variant="secondary" disabled>
|
||||
Button
|
||||
</Button>
|
||||
<Button
|
||||
data-color="neutral"
|
||||
variant="tertiary"
|
||||
icon={<StarIcon />}
|
||||
disabled
|
||||
>
|
||||
<Button data-color="neutral" variant="tertiary" disabled>
|
||||
Button
|
||||
</Button>
|
||||
</HStack>
|
||||
<h3>data-color=neutral on wrapper</h3>
|
||||
<HStack gap="space-8" data-color="neutral">
|
||||
<Button variant="secondary" icon={<StarIcon />}>
|
||||
<Button variant="secondary">Button</Button>
|
||||
<Button variant="tertiary">Button</Button>
|
||||
<Button variant="tertiary" data-pressed>
|
||||
Pressed
|
||||
</Button>
|
||||
<Button variant="secondary" disabled>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="tertiary" icon={<StarIcon />}>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="secondary" icon={<StarIcon />} disabled>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="tertiary" icon={<StarIcon />} disabled>
|
||||
<Button variant="tertiary" disabled>
|
||||
Button
|
||||
</Button>
|
||||
</HStack>
|
||||
<h3>data-color=neutral on parent wrapper</h3>
|
||||
<div data-color="neutral">
|
||||
<HStack gap="space-8">
|
||||
<Button variant="secondary" icon={<StarIcon />}>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="tertiary" icon={<StarIcon />}>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="secondary" icon={<StarIcon />} disabled>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="tertiary" icon={<StarIcon />} disabled>
|
||||
Button
|
||||
</Button>
|
||||
</HStack>
|
||||
</div>
|
||||
<h3>data-color=neutral on parent wrapper and danger on wrapper</h3>
|
||||
<div data-color="neutral">
|
||||
<HStack gap="space-8" data-color="danger">
|
||||
<Button variant="secondary" icon={<StarIcon />}>
|
||||
<Button variant="secondary">Button</Button>
|
||||
<Button variant="tertiary">Button</Button>
|
||||
<Button variant="tertiary" data-pressed>
|
||||
Pressed
|
||||
</Button>
|
||||
<Button variant="secondary" disabled>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="tertiary" icon={<StarIcon />}>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="secondary" icon={<StarIcon />} disabled>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="tertiary" icon={<StarIcon />} disabled>
|
||||
<Button variant="tertiary" disabled>
|
||||
Button
|
||||
</Button>
|
||||
</HStack>
|
||||
@@ -311,16 +251,15 @@ export const ColorRole = () => (
|
||||
<h3>data-color=neutral on both wrappers</h3>
|
||||
<div data-color="neutral">
|
||||
<HStack gap="space-8" data-color="neutral">
|
||||
<Button variant="secondary" icon={<StarIcon />}>
|
||||
<Button variant="secondary">Button</Button>
|
||||
<Button variant="tertiary">Button</Button>
|
||||
<Button variant="tertiary" data-pressed>
|
||||
Pressed
|
||||
</Button>
|
||||
<Button variant="secondary" disabled>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="tertiary" icon={<StarIcon />}>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="secondary" icon={<StarIcon />} disabled>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="tertiary" icon={<StarIcon />} disabled>
|
||||
<Button variant="tertiary" disabled>
|
||||
Button
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"use client";
|
||||
export { DataGridRoot as DataGrid } from "./root/DataGridRoot";
|
||||
export type { DataGridProps } from "./root/DataGridRoot";
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createStrictContext } from "../../../utils/helpers";
|
||||
import type { ColumnDefinitions } from "../../table/root/DataTable.types";
|
||||
|
||||
type DataGridContextValue<RowDef = unknown> = {
|
||||
data: RowDef[];
|
||||
columnDefinitions: ColumnDefinitions<RowDef>;
|
||||
getRowId?: (rowData: RowDef) => string;
|
||||
};
|
||||
|
||||
const { Provider: DataGridContextProvider, useContext: useDataGridContext } =
|
||||
createStrictContext<DataGridContextValue>({
|
||||
name: "DataGridContext",
|
||||
errorMessage: "DataGrid hooks must be used within a <DataGrid />",
|
||||
});
|
||||
|
||||
export { DataGridContextProvider, useDataGridContext };
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { HTMLAttributes, forwardRef } from "react";
|
||||
import { cl } from "../../../utils/helpers";
|
||||
import type { ColumnDefinitions } from "../../table/root/DataTable.types";
|
||||
import { DataGridContextProvider } from "./DataGridRoot.context";
|
||||
|
||||
type RowTId = string;
|
||||
|
||||
export interface DataGridProps<RowT> extends HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Definitions of the columns to display in the data grid.
|
||||
*
|
||||
* Each column definition should have a unique `id` and a `cell`-renderer function that takes the row data as argument and returns a React node.
|
||||
*/
|
||||
columnDefinitions: ColumnDefinitions<RowT>;
|
||||
/**
|
||||
* The data to display.
|
||||
*
|
||||
* Each object in the array represents a row, and the properties of the object are used to render the cells based on the `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) => RowTId;
|
||||
}
|
||||
|
||||
type DataGridRootComponent = <RowT>(
|
||||
props: DataGridProps<RowT> & React.RefAttributes<HTMLDivElement>,
|
||||
) => React.ReactElement | null;
|
||||
|
||||
/**
|
||||
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/data-grid)
|
||||
* @see 🏷️ {@link DataGridProps}
|
||||
*
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataGrid columnDefinitions={columnDefs} data={rowData} getRowId={(row) => row.id}>
|
||||
* <DataTable />
|
||||
* </DataGrid>
|
||||
* ```
|
||||
*/
|
||||
export const DataGridRoot = forwardRef<HTMLDivElement, DataGridProps<unknown>>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
columnDefinitions,
|
||||
data,
|
||||
getRowId,
|
||||
...rest
|
||||
}: DataGridProps<unknown>,
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<div {...rest} ref={ref} className={cl("aksel-data-grid", className)}>
|
||||
<DataGridContextProvider
|
||||
columnDefinitions={columnDefinitions}
|
||||
data={data}
|
||||
getRowId={getRowId}
|
||||
>
|
||||
{children}
|
||||
</DataGridContextProvider>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
) as DataGridRootComponent;
|
||||
|
||||
export default DataGridRoot;
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
CaretDownCircleFillIcon,
|
||||
CaretUpCircleFillIcon,
|
||||
DragVerticalIcon,
|
||||
} from "@navikt/aksel-icons";
|
||||
import { Floating } from "../../../utils/components/floating/Floating";
|
||||
|
||||
export interface DataDragAndDropDragHandlerProps {
|
||||
/**
|
||||
* Whether the drag handler is disabled
|
||||
*/
|
||||
// disabled?: boolean;
|
||||
/**
|
||||
* Wether dragging is done by keyboard. Used to conditionally render drag indicators.
|
||||
*/
|
||||
keyboardDragging?: boolean;
|
||||
/**
|
||||
* Handle ref is forwarded to the button element serving as drag handle.
|
||||
*/
|
||||
handleRef: React.Ref<HTMLDivElement>;
|
||||
// Just for testing purposes, to render an alternative drag handler
|
||||
alt?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DataDragAndDropDragHandler
|
||||
*
|
||||
* A button component that serves as a drag handle for drag and drop operations.
|
||||
* Can be used to initiate dragging of elements in a data table or list.
|
||||
*/
|
||||
export const DataDragAndDropDragHandler = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
DataDragAndDropDragHandlerProps
|
||||
>(({ keyboardDragging, handleRef, alt }) => {
|
||||
if (alt) {
|
||||
return (
|
||||
<div className="aksel-data-drag-and-drop__drag-handler__alt">
|
||||
{keyboardDragging && (
|
||||
<span
|
||||
className="aksel-data-drag-and-drop__drag-handler__arrow"
|
||||
data-direction="up"
|
||||
>
|
||||
<CaretUpCircleFillIcon aria-hidden fontSize="1.2rem" />
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
ref={handleRef}
|
||||
className="aksel-data-drag-and-drop__drag-handler__button"
|
||||
>
|
||||
<DragVerticalIcon
|
||||
aria-hidden
|
||||
title="Dra for å flytte"
|
||||
fontSize="1.5rem"
|
||||
/>
|
||||
</div>
|
||||
{keyboardDragging && (
|
||||
<span
|
||||
className="aksel-data-drag-and-drop__drag-handler__arrow"
|
||||
data-direction="down"
|
||||
>
|
||||
<CaretDownCircleFillIcon aria-hidden fontSize="1.2rem" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Floating>
|
||||
{keyboardDragging && (
|
||||
<Floating.Content
|
||||
side="top"
|
||||
avoidCollisions={false}
|
||||
updatePositionStrategy="always"
|
||||
className="aksel-data-drag-and-drop__drag-handler__arrow"
|
||||
>
|
||||
<CaretUpCircleFillIcon aria-hidden fontSize="1.2rem" />
|
||||
</Floating.Content>
|
||||
)}
|
||||
<Floating.Anchor asChild>
|
||||
<div
|
||||
ref={handleRef}
|
||||
className="aksel-data-drag-and-drop__drag-handler__button"
|
||||
>
|
||||
<DragVerticalIcon
|
||||
aria-hidden
|
||||
title="Dra for å flytte"
|
||||
fontSize="1.5rem"
|
||||
/>
|
||||
</div>
|
||||
</Floating.Anchor>
|
||||
{keyboardDragging && (
|
||||
<Floating.Content
|
||||
avoidCollisions={false}
|
||||
updatePositionStrategy="always"
|
||||
className="aksel-data-drag-and-drop__drag-handler__arrow"
|
||||
>
|
||||
<CaretDownCircleFillIcon aria-hidden fontSize="1.2rem" />
|
||||
</Floating.Content>
|
||||
)}
|
||||
</Floating>
|
||||
);
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useSortable } from "@dnd-kit/react/sortable";
|
||||
import React, { useRef } from "react";
|
||||
import { HStack } from "../../../primitives/stack";
|
||||
import { cl } from "../../../utils/helpers";
|
||||
import { useMergeRefs } from "../../../utils/hooks";
|
||||
import { DataDragAndDropDragHandler } from "../drag-handler/DataDragAndDropDragHandler";
|
||||
import { DataDragAndDropContext } from "../root/DataDragAndDrop.context";
|
||||
|
||||
interface DataDragAndDropItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Unique id
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Index of the item being dragged
|
||||
*/
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO
|
||||
*
|
||||
* @see 🏷️ {@link DataDragAndDropItemProps}
|
||||
* @example
|
||||
* ```tsx
|
||||
* <DragAndDrop.Item numOfSelectedRows={selectedRows.length} onClear={handleClear}>
|
||||
* TODO
|
||||
* </DragAndDrop.Item>
|
||||
* ```
|
||||
*/
|
||||
const DataDragAndDropItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
DataDragAndDropItemProps
|
||||
>(({ children, id, index, className, ...rest }, forwardedRef) => {
|
||||
const handleRef = useRef<HTMLDivElement>(null);
|
||||
const { ref, isDragging, isDropTarget } = useSortable({
|
||||
id,
|
||||
index,
|
||||
handle: handleRef,
|
||||
});
|
||||
const mergedRef = useMergeRefs(ref, forwardedRef);
|
||||
const context = React.useContext(DataDragAndDropContext);
|
||||
const mouseDragging = isDragging && context?.inputMethod === "mouse";
|
||||
const mouseDropTarget = isDropTarget && context?.inputMethod === "mouse";
|
||||
const keyboardDragging = isDragging && context?.inputMethod === "keyboard";
|
||||
|
||||
return (
|
||||
<HStack gap="space-8" align="center" wrap={false} asChild>
|
||||
{/* TODO Should this be a <li>? */}
|
||||
<div
|
||||
ref={mergedRef}
|
||||
{...rest}
|
||||
className={cl("aksel-data-table__drag-and-drop-item", className)}
|
||||
data-dragging={isDragging}
|
||||
data-mouse-dragging={mouseDragging}
|
||||
data-keyboard-dragging={keyboardDragging}
|
||||
data-drop-target={mouseDropTarget}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<DataDragAndDropDragHandler
|
||||
handleRef={handleRef}
|
||||
keyboardDragging={keyboardDragging}
|
||||
alt
|
||||
/>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</HStack>
|
||||
);
|
||||
});
|
||||
|
||||
export default DataDragAndDropItem;
|
||||
export { DataDragAndDropItem };
|
||||
export type { DataDragAndDropItemProps };
|
||||
@@ -1,11 +0,0 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
interface DataDragAndDropContextType {
|
||||
inputMethod: "mouse" | "keyboard" | null;
|
||||
// setInputMethod: (method: "mouse" | "keyboard" | null) => void;
|
||||
// setItems: React.Dispatch<React.SetStateAction<any[]>>;
|
||||
}
|
||||
|
||||
export const DataDragAndDropContext = createContext<
|
||||
DataDragAndDropContextType | undefined
|
||||
>(undefined);
|
||||
@@ -1,96 +0,0 @@
|
||||
import { DragDropProvider, DragOverlay } from "@dnd-kit/react";
|
||||
import { isSortable } from "@dnd-kit/react/sortable";
|
||||
import React, { forwardRef, isValidElement } from "react";
|
||||
import { VStack } from "../../../primitives/stack";
|
||||
import DataDragAndDropItem, {
|
||||
DataDragAndDropItemProps,
|
||||
} from "../item/DataDragAndDropItem";
|
||||
import { DataDragAndDropContext } from "./DataDragAndDrop.context";
|
||||
|
||||
interface DataDragAndDropProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: any[];
|
||||
setItems: React.Dispatch<React.SetStateAction<any[]>>;
|
||||
}
|
||||
|
||||
interface DataDragAndDropRootComponent extends React.ForwardRefExoticComponent<
|
||||
DataDragAndDropProps & React.RefAttributes<HTMLDivElement>
|
||||
> {
|
||||
/**
|
||||
* @see 🏷️ {@link DataDragAndDropItemProps}
|
||||
* * @example
|
||||
* ```jsx
|
||||
* <DragAndDrop>
|
||||
* <DragAndDrop.Item id="1" index={0}>
|
||||
* ...
|
||||
* </DragAndDrop.Item>
|
||||
* </DragAndDrop>
|
||||
* ```
|
||||
*/
|
||||
Item: typeof DataDragAndDropItem;
|
||||
}
|
||||
|
||||
const DataDragAndDrop = forwardRef<HTMLDivElement, DataDragAndDropProps>(
|
||||
({ setItems, children, ...rest }, forwardedRef) => {
|
||||
const [inputMethod, setInputMethod] = React.useState<
|
||||
"mouse" | "keyboard" | null
|
||||
>(null);
|
||||
|
||||
const setItemOrder = (initalIndex: number, targetIndex: number) => {
|
||||
setItems((items) => {
|
||||
const newItems = [...items];
|
||||
const [movedItem] = newItems.splice(initalIndex, 1);
|
||||
newItems.splice(targetIndex, 0, movedItem);
|
||||
return newItems;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DataDragAndDropContext.Provider value={{ inputMethod }}>
|
||||
<DragDropProvider
|
||||
// TODO Look into overriding default keybinds, might eliminate context need
|
||||
onBeforeDragStart={(event) =>
|
||||
setInputMethod(
|
||||
event.operation.activatorEvent?.type === "pointerdown"
|
||||
? "mouse"
|
||||
: "keyboard",
|
||||
)
|
||||
}
|
||||
onDragOver={(event) => {
|
||||
if (event.operation.activatorEvent?.type === "pointerdown") {
|
||||
// Prevents items to rearrange while dragging with mouse, but allows keyboard dragging to work as intended
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
const { source, target } = event.operation;
|
||||
if (!isSortable(source) || !isSortable(target)) return;
|
||||
setItemOrder(source.initialIndex, target.index);
|
||||
}}
|
||||
>
|
||||
<VStack asChild gap="space-12">
|
||||
<div {...rest} ref={forwardedRef}>
|
||||
{children}
|
||||
</div>
|
||||
</VStack>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{(source) => {
|
||||
// Overlay not needed and causes glitching when using keyboard
|
||||
if (inputMethod === "keyboard") return null;
|
||||
if (!isSortable(source)) return null;
|
||||
if (isValidElement(children[source.initialIndex])) {
|
||||
return children[source.initialIndex];
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</DataDragAndDropContext.Provider>
|
||||
);
|
||||
},
|
||||
) as DataDragAndDropRootComponent;
|
||||
|
||||
DataDragAndDrop.Item = DataDragAndDropItem;
|
||||
|
||||
export { DataDragAndDrop, DataDragAndDropItem };
|
||||
export default DataDragAndDrop;
|
||||
export type { DataDragAndDropItemProps, DataDragAndDropProps };
|
||||
@@ -49,7 +49,7 @@ export const DragAndDropDragHandler = React.forwardRef<
|
||||
<button
|
||||
className="aksel-data-drag-and-drop__drag-handler__arrow"
|
||||
data-direction="up"
|
||||
onClick={() => context?.onKeyboardDragEnd(-1)}
|
||||
onClick={() => context?.onKeyboardDragEnd(-1, itemLabel)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
disabled={context?.dragHandlerActive?.index === 0}
|
||||
type="button"
|
||||
@@ -58,13 +58,7 @@ export const DragAndDropDragHandler = React.forwardRef<
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
// TODO - Bedre formulering av aria-label?
|
||||
//aria-label={`Flytt element ${item.index + 1}. Trykk Enter eller Mellomrom for å aktivere, deretter piltastene for å flytte elementet.`}
|
||||
aria-label={
|
||||
active
|
||||
? `Flytt element ${itemLabel || item.index + 1}. Bruk piltastene for å flytte elementet.`
|
||||
: `Flytt element ${itemLabel || item.index + 1}. Trykk Enter eller Mellomrom for å aktivere flytting.`
|
||||
}
|
||||
aria-label={`${itemLabel}. Plass ${item.index + 1}.`}
|
||||
aria-pressed={Boolean(active)}
|
||||
aria-roledescription="draggable"
|
||||
type="button"
|
||||
@@ -77,18 +71,18 @@ export const DragAndDropDragHandler = React.forwardRef<
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (!active) {
|
||||
context?.setDragHandlerActive(item);
|
||||
context?.onKeyboardDragStart(item);
|
||||
event.currentTarget.focus();
|
||||
} else {
|
||||
context?.setDragHandlerActive(null);
|
||||
context?.onKeyboardDragStart(null);
|
||||
}
|
||||
}}
|
||||
onBlur={() => context?.setDragHandlerActive(null)}
|
||||
onBlur={() => context?.cancelDrag()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
// Enter or space, currently active item - end keyboard dragging
|
||||
event.preventDefault();
|
||||
context?.setDragHandlerActive(active ? null : item);
|
||||
context?.onKeyboardDragStart(active ? null : item);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -96,19 +90,19 @@ export const DragAndDropDragHandler = React.forwardRef<
|
||||
|
||||
if (event.key === "Escape") {
|
||||
// Cancel dragging
|
||||
// TODO Handle reset
|
||||
event.preventDefault();
|
||||
context?.setDragHandlerActive(null);
|
||||
context?.setAnnouncer(`Flytting av ${itemLabel} avbrutt.`);
|
||||
context?.cancelDrag(true);
|
||||
return;
|
||||
} else if (event.key === "ArrowUp") {
|
||||
// Move item up
|
||||
event.preventDefault();
|
||||
context?.onKeyboardDragEnd(-1);
|
||||
context?.onKeyboardDragEnd(-1, itemLabel);
|
||||
return;
|
||||
} else if (event.key === "ArrowDown") {
|
||||
// Move item down
|
||||
event.preventDefault();
|
||||
context?.onKeyboardDragEnd(1);
|
||||
context?.onKeyboardDragEnd(1, itemLabel);
|
||||
return;
|
||||
}
|
||||
}}
|
||||
@@ -120,7 +114,7 @@ export const DragAndDropDragHandler = React.forwardRef<
|
||||
className="aksel-data-drag-and-drop__drag-handler__arrow"
|
||||
data-direction="down"
|
||||
type="button"
|
||||
onClick={() => context?.onKeyboardDragEnd(1)}
|
||||
onClick={() => context?.onKeyboardDragEnd(1, itemLabel)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
disabled={
|
||||
context?.dragHandlerActive?.index === context?.itemAmount - 1
|
||||
|
||||
@@ -7,14 +7,16 @@ interface DragAndDropContextType {
|
||||
dropTarget: DragAndDropElement | null;
|
||||
setDropTarget: (id: DragAndDropElement | null) => void;
|
||||
dragHandlerActive: DragAndDropElement | null;
|
||||
setDragHandlerActive: (active: DragAndDropElement | null) => void;
|
||||
onKeyboardDragEnd: (diff: number) => void;
|
||||
onKeyboardDragStart: (active: DragAndDropElement | null) => void;
|
||||
onKeyboardDragEnd: (diff: number, label: string) => void;
|
||||
startPendingDrag: (
|
||||
event: React.PointerEvent,
|
||||
item: DragAndDropElement,
|
||||
element?: HTMLElement | null,
|
||||
) => void;
|
||||
itemAmount: number;
|
||||
cancelDrag: (resetOrder?: boolean) => void;
|
||||
setAnnouncer: (message: string) => void;
|
||||
}
|
||||
|
||||
export const {
|
||||
|
||||
@@ -22,34 +22,16 @@ interface DragAndDropProps<T> extends React.HTMLAttributes<HTMLUListElement> {
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO
|
||||
* [x] setItems on root
|
||||
* [x] state : active element
|
||||
* [x] pointer over listener / state, onPointerEnter, onPointerLeave
|
||||
* [x] Overlay - Use floating component
|
||||
* [x] Keyboard navigation
|
||||
* [ ] UU - announce on drag start, item moved, drag end
|
||||
* [x] Make overlay same width as the OG item, currently jumps to content width
|
||||
* [x] Look into adding a cancel listener event
|
||||
* [x] Make onClick work on drag handler button, currently blocked by pointer down/up listeners
|
||||
* [x] Talk to design about what should happen on ESC key press, currently just cancels dragging, should it also reset position?
|
||||
* [x] Make arrow icons into buttons that react to keyboard events, currently just decorative
|
||||
* [x] Keep handler focus after clicking arrows for dragging
|
||||
* [x] Look into data-based API vs component-based API
|
||||
* [ ] Should we have hidden instructions for screen readers on how to use the drag and drop, and should we announce the position of the item while dragging?
|
||||
* [x] Discuss if this component should be generic for drag and drop, or if it should be specifically for tables - just for table for now
|
||||
* [x] Discuss items type
|
||||
* [ ] Discuss how to implement label best
|
||||
* [ ] Quick nav (< > samtidig) - få piltastene til å fungere
|
||||
* [x] Implement new type for items - ColumnDefinitions<T>
|
||||
* [ ] Remove announcer div and use a live region component instead
|
||||
* [ ] Make ESC reset position, not just cancel dragging
|
||||
* [ ] Make instructions for keyboard users (visible?)
|
||||
* [ ] Ask design about visible keyboard instructions
|
||||
* TODO:
|
||||
*
|
||||
* Backlog:
|
||||
* [ ] Quick nav (< > samtidig) - få piltastene til å fungere - ignore?
|
||||
* [ ] Look at instructions text
|
||||
*
|
||||
*/
|
||||
|
||||
const DRAG_THRESHOLD = 4; // Minimum movement in pixels to start dragging
|
||||
const SR_INSTRUCTIONS_ID = "drag-and-drop-instructions-id";
|
||||
|
||||
function DragAndDropInner<T>(
|
||||
{ items, setItems, renderItem }: DragAndDropProps<T>,
|
||||
@@ -60,7 +42,8 @@ function DragAndDropInner<T>(
|
||||
const [dragHandlerActive, setDragHandlerActive] =
|
||||
useState<DragAndDropElement | null>(null);
|
||||
const [overlayWidth, setOverlayWidth] = useState<number | null>(null);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const [announcer, setAnnouncer] = useState("");
|
||||
const initialItemsRef = useRef<ColumnDefinitions<T> | null>(null);
|
||||
const activeData = items.find((item) => item.id === activeItem?.id);
|
||||
|
||||
const activeItemRef = useRef<DragAndDropElement | null>(null);
|
||||
@@ -79,6 +62,19 @@ function DragAndDropInner<T>(
|
||||
startY: number;
|
||||
} | null>(null);
|
||||
|
||||
const saveInitialItems = useCallback(() => {
|
||||
initialItemsRef.current = items;
|
||||
}, [items]);
|
||||
|
||||
const keyboardDragStart = (item: DragAndDropElement | null) => {
|
||||
if (item) {
|
||||
saveInitialItems();
|
||||
} else {
|
||||
initialItemsRef.current = null;
|
||||
}
|
||||
setDragHandlerActive(item);
|
||||
};
|
||||
|
||||
const startPendingDrag = (
|
||||
event: React.PointerEvent,
|
||||
item: DragAndDropElement,
|
||||
@@ -121,9 +117,24 @@ function DragAndDropInner<T>(
|
||||
[setItems],
|
||||
);
|
||||
|
||||
const cancelDrag = useCallback(
|
||||
(resetOrder = false) => {
|
||||
if (resetOrder && initialItemsRef.current) {
|
||||
setItems(initialItemsRef.current);
|
||||
}
|
||||
setOverlayWidth(null);
|
||||
setDragHandlerActive(null);
|
||||
setCombinedActiveItem(null);
|
||||
setCombinedDropTarget(null);
|
||||
pendingDragStartRef.current = null;
|
||||
initialItemsRef.current = null;
|
||||
},
|
||||
[setItems, setCombinedActiveItem, setCombinedDropTarget],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
/* This useEffect is used to toggle a class on the html element when dragging,
|
||||
to prevent cursor issues when dragging over interactive elements,
|
||||
/* This useEffect is used to toggle a class on the html element when dragging,
|
||||
to prevent cursor issues when dragging over interactive elements,
|
||||
and to prevent text selection during dragging. */
|
||||
|
||||
if (activeItem) {
|
||||
@@ -156,6 +167,7 @@ function DragAndDropInner<T>(
|
||||
}
|
||||
|
||||
setOverlayWidth(element?.getBoundingClientRect().width ?? null);
|
||||
saveInitialItems();
|
||||
setCombinedActiveItem(pendingStart.item);
|
||||
setCombinedDropTarget(pendingStart.item);
|
||||
pendingDragStartRef.current = null;
|
||||
@@ -221,25 +233,12 @@ function DragAndDropInner<T>(
|
||||
|
||||
if (active && target && active.id !== target.id) {
|
||||
reorderItems(active.index, target.index);
|
||||
setAnnouncement(
|
||||
`Element flyttet til posisjon ${target?.index + 1} av ${items.length}`,
|
||||
); // TODO - Bedre formulering?
|
||||
}
|
||||
|
||||
setOverlayWidth(null);
|
||||
setDragHandlerActive(null);
|
||||
setCombinedActiveItem(null);
|
||||
setCombinedDropTarget(null);
|
||||
pendingDragStartRef.current = null;
|
||||
cancelDrag();
|
||||
};
|
||||
|
||||
const handlePointerCancel = () => {
|
||||
setOverlayWidth(null);
|
||||
setDragHandlerActive(null);
|
||||
setCombinedActiveItem(null);
|
||||
setCombinedDropTarget(null);
|
||||
pendingDragStartRef.current = null;
|
||||
};
|
||||
const handlePointerCancel = () => cancelDrag(true);
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
@@ -254,10 +253,11 @@ function DragAndDropInner<T>(
|
||||
setCombinedDropTarget,
|
||||
setCombinedActiveItem,
|
||||
reorderItems,
|
||||
items.length,
|
||||
saveInitialItems,
|
||||
cancelDrag,
|
||||
]);
|
||||
|
||||
const onKeyboardDragEnd = (diff: number) => {
|
||||
const onKeyboardDragEnd = (diff: number, label: string) => {
|
||||
if (!dragHandlerActive) return;
|
||||
|
||||
const targetIndex = dragHandlerActive.index + diff;
|
||||
@@ -265,11 +265,9 @@ function DragAndDropInner<T>(
|
||||
return;
|
||||
}
|
||||
|
||||
setAnnouncer(`${label}. Plass ${targetIndex + 1} av ${items.length}.`);
|
||||
reorderItems(dragHandlerActive.index, targetIndex);
|
||||
setDragHandlerActive({ ...dragHandlerActive, index: targetIndex });
|
||||
setAnnouncement(
|
||||
`Element flyttet til posisjon ${targetIndex + 1} av ${items.length}`,
|
||||
); // TODO - Bedre formulering?
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -279,12 +277,27 @@ function DragAndDropInner<T>(
|
||||
dropTarget={dropTarget}
|
||||
setDropTarget={setCombinedDropTarget}
|
||||
dragHandlerActive={dragHandlerActive}
|
||||
setDragHandlerActive={setDragHandlerActive}
|
||||
onKeyboardDragStart={keyboardDragStart}
|
||||
onKeyboardDragEnd={onKeyboardDragEnd}
|
||||
startPendingDrag={startPendingDrag}
|
||||
cancelDrag={cancelDrag}
|
||||
setAnnouncer={setAnnouncer}
|
||||
itemAmount={items.length}
|
||||
>
|
||||
<ul ref={forwardedRef} aria-label="Dra og slipp elementer">
|
||||
<span id={SR_INSTRUCTIONS_ID} className="sr-only">
|
||||
Bruk Tab for å fokusere på en kolonne. Trykk mellomrom eller enter for å
|
||||
starte flytting, bruk piltastene for å flytte kolonnen, trykk mellomrom
|
||||
eller enter for å slippe, eller Escape for å avbryte.
|
||||
</span>
|
||||
<div aria-live="assertive" className="sr-only" aria-atomic>
|
||||
{announcer}
|
||||
</div>
|
||||
<ul
|
||||
ref={forwardedRef}
|
||||
aria-label="Kolonneinnstillinger"
|
||||
aria-describedby={SR_INSTRUCTIONS_ID}
|
||||
className="aksel-data-table__drag-and-drop-root"
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
return (
|
||||
<DragAndDropItem
|
||||
@@ -306,6 +319,7 @@ function DragAndDropInner<T>(
|
||||
<Floating.Content
|
||||
align="start"
|
||||
updatePositionStrategy="always"
|
||||
aria-hidden
|
||||
style={{
|
||||
pointerEvents: "none",
|
||||
boxSizing: "border-box",
|
||||
@@ -323,9 +337,6 @@ function DragAndDropInner<T>(
|
||||
</Floating.Content>
|
||||
</Floating>
|
||||
)}
|
||||
<div aria-live="assertive" className="sr-only" aria-atomic="true">
|
||||
{announcement}
|
||||
</div>
|
||||
</DragAndDropProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,3 @@ export interface DragAndDropElement {
|
||||
id: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface DragAndDropItemData {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import { Button } from "../../button";
|
||||
import { VStack } from "../../primitives/stack";
|
||||
import { Tag } from "../../tag";
|
||||
import { DataGrid } from "../data-grid";
|
||||
import { DataTable } from "../table";
|
||||
import type {
|
||||
ColumnDefinitions,
|
||||
SortEntry,
|
||||
} from "../table/root/DataTable.types";
|
||||
import DataTableAuto from "../table/root/DataTableAuto";
|
||||
|
||||
const meta: Meta<typeof DataTable> = {
|
||||
title: "ds-react/Data/Data Prop",
|
||||
@@ -29,7 +29,9 @@ type Story = StoryObj<typeof DataTable>;
|
||||
|
||||
const selectionControls = {
|
||||
args: {
|
||||
selectionMode: "multiple",
|
||||
selection: {
|
||||
selectionMode: "multiple",
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
controls: {
|
||||
@@ -37,9 +39,11 @@ const selectionControls = {
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
selectionMode: {
|
||||
control: { type: "select" },
|
||||
options: ["none", "single", "multiple"],
|
||||
selection: {
|
||||
selectionMode: {
|
||||
control: { type: "select" },
|
||||
options: ["none", "single", "multiple"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -58,8 +62,8 @@ const userColumnDef: ColumnDefinitions<UserDataTest> = [
|
||||
header: "Id",
|
||||
label: "Id",
|
||||
cell: ({ id }) => id,
|
||||
type: "number",
|
||||
defaultWidth: "100px",
|
||||
align: "right",
|
||||
width: { default: "100px" },
|
||||
},
|
||||
{
|
||||
id: "foo",
|
||||
@@ -110,20 +114,29 @@ const userData = generateUserData(4);
|
||||
|
||||
export const ItemsAsData: Story = {
|
||||
render: () => {
|
||||
return <DataTableAuto columnDefinitions={userColumnDef} data={userData} />;
|
||||
return (
|
||||
<DataGrid columnDefinitions={userColumnDef} data={userData}>
|
||||
<DataTable />
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ItemsAsDataWithCustomRowId: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
columnDefinitions={userColumnDef}
|
||||
data={userData}
|
||||
selectionMode="multiple"
|
||||
onSelectionChange={console.info}
|
||||
getRowId={(row) => row.foo + row.bar}
|
||||
/>
|
||||
>
|
||||
<DataTable
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
onSelectionChange: console.info,
|
||||
}}
|
||||
/>
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -131,14 +144,40 @@ export const ItemsAsDataWithCustomRowId: Story = {
|
||||
export const SelectionModeMultiple: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
columnDefinitions={userColumnDef}
|
||||
data={userData}
|
||||
selectionMode="multiple"
|
||||
onSelectionChange={console.info}
|
||||
getRowId={(row) => row.foo + row.bar}
|
||||
withKeyboardNav
|
||||
/>
|
||||
>
|
||||
<DataTable
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
onSelectionChange: console.info,
|
||||
}}
|
||||
withKeyboardNav
|
||||
/>
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectionModeOnControlsOnly: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataGrid
|
||||
columnDefinitions={userColumnDef}
|
||||
data={userData}
|
||||
getRowId={(row) => row.foo + row.bar}
|
||||
>
|
||||
<DataTable
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
onSelectionChange: console.info,
|
||||
selectionTrigger: "control",
|
||||
}}
|
||||
withKeyboardNav
|
||||
/>
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -146,14 +185,19 @@ export const SelectionModeMultiple: Story = {
|
||||
export const SelectionModeSingle: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
columnDefinitions={userColumnDef}
|
||||
data={userData}
|
||||
selectionMode="single"
|
||||
onSelectionChange={console.info}
|
||||
getRowId={(row) => row.foo + row.bar}
|
||||
withKeyboardNav
|
||||
/>
|
||||
>
|
||||
<DataTable
|
||||
selection={{
|
||||
selectionMode: "single",
|
||||
onSelectionChange: console.info,
|
||||
}}
|
||||
withKeyboardNav
|
||||
/>
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -161,13 +205,19 @@ export const SelectionModeSingle: Story = {
|
||||
export const SelectionModeSingleWithoutKeyboardNav: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
columnDefinitions={userColumnDef}
|
||||
data={userData}
|
||||
selectionMode="single"
|
||||
onSelectionChange={console.info}
|
||||
getRowId={(row) => row.foo + row.bar}
|
||||
/>
|
||||
>
|
||||
<DataTable
|
||||
selection={{
|
||||
selectionMode: "single",
|
||||
onSelectionChange: console.info,
|
||||
}}
|
||||
withKeyboardNav={false}
|
||||
/>
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -175,41 +225,50 @@ export const SelectionModeSingleWithoutKeyboardNav: Story = {
|
||||
export const SelectionWithDisabledRows: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
columnDefinitions={userColumnDef}
|
||||
data={userData}
|
||||
selectionMode="multiple"
|
||||
onSelectionChange={console.info}
|
||||
getRowId={(row) => row.id}
|
||||
withKeyboardNav
|
||||
disabledSelectionKeys={[1, 2]}
|
||||
/>
|
||||
getRowId={(row) => row.id.toString()}
|
||||
>
|
||||
<DataTable
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
onSelectionChange: console.info,
|
||||
enableRowSelection: ({ id }) => id !== "2" && id !== "1",
|
||||
}}
|
||||
/>
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ControlledSelection: Story = {
|
||||
render: () => {
|
||||
const [selectedKeys, setSelectedKeys] = useState<(string | number)[]>([1]);
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>(["1"]);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<Button size="small" onClick={() => setSelectedKeys([1, 2, 3])}>
|
||||
<Button size="small" onClick={() => setSelectedKeys(["1", "2", "3"])}>
|
||||
Select all
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setSelectedKeys([])}>
|
||||
Clear selection
|
||||
</Button>
|
||||
</div>
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
columnDefinitions={userColumnDef}
|
||||
data={userData}
|
||||
selectionMode="multiple"
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={setSelectedKeys}
|
||||
getRowId={(row) => row.id}
|
||||
/>
|
||||
getRowId={(row) => row.id.toString()}
|
||||
>
|
||||
<DataTable
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
onSelectionChange: setSelectedKeys,
|
||||
selectedKeys,
|
||||
}}
|
||||
/>
|
||||
</DataGrid>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -218,14 +277,19 @@ export const ControlledSelection: Story = {
|
||||
export const DefaultSelectedKeys: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
columnDefinitions={userColumnDef}
|
||||
data={userData}
|
||||
selectionMode="multiple"
|
||||
defaultSelectedKeys={[1, 3]}
|
||||
onSelectionChange={console.info}
|
||||
getRowId={(row) => row.id}
|
||||
/>
|
||||
getRowId={(row) => row.id.toString()}
|
||||
>
|
||||
<DataTable
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
onSelectionChange: console.info,
|
||||
defaultSelectedKeys: ["1", "3"],
|
||||
}}
|
||||
/>
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -233,14 +297,19 @@ export const DefaultSelectedKeys: Story = {
|
||||
export const SingleSelectionWithDisabledRows: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
columnDefinitions={userColumnDef}
|
||||
data={userData}
|
||||
selectionMode="single"
|
||||
onSelectionChange={console.info}
|
||||
getRowId={(row) => row.id}
|
||||
disabledSelectionKeys={[2]}
|
||||
/>
|
||||
getRowId={(row) => row.id.toString()}
|
||||
>
|
||||
<DataTable
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
onSelectionChange: console.info,
|
||||
enableRowSelection: ({ id }) => id !== "2",
|
||||
}}
|
||||
/>
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -248,11 +317,13 @@ export const SingleSelectionWithDisabledRows: Story = {
|
||||
export const EmptyData: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
columnDefinitions={userColumnDef}
|
||||
data={[]}
|
||||
selectionMode="multiple"
|
||||
/>
|
||||
<DataGrid columnDefinitions={userColumnDef} data={[]}>
|
||||
<DataTable
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
}}
|
||||
/>
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -260,11 +331,9 @@ export const EmptyData: Story = {
|
||||
export const EmptyDataWithEmptyState: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
columnDefinitions={userColumnDef}
|
||||
data={[]}
|
||||
emptyState="Ingen data å vise"
|
||||
/>
|
||||
<DataGrid columnDefinitions={userColumnDef} data={[]}>
|
||||
<DataTable emptyContent="Ingen data å vise" />
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -277,12 +346,9 @@ export const LoadingWithSkeletonRows: Story = {
|
||||
<Button onClick={() => setIsLoading((prev) => !prev)}>
|
||||
Toggle loading
|
||||
</Button>
|
||||
<DataTableAuto
|
||||
columnDefinitions={userColumnDef}
|
||||
data={[]}
|
||||
isLoading={isLoading}
|
||||
loadingRows={4}
|
||||
/>
|
||||
<DataGrid columnDefinitions={userColumnDef} data={[]}>
|
||||
<DataTable loading={{ isLoading, variant: "skeleton", rows: 4 }} />
|
||||
</DataGrid>
|
||||
</VStack>
|
||||
);
|
||||
},
|
||||
@@ -296,12 +362,15 @@ export const LoadingWithLoadingState: Story = {
|
||||
<Button onClick={() => setIsLoading((prev) => !prev)}>
|
||||
Toggle loading
|
||||
</Button>
|
||||
<DataTableAuto
|
||||
columnDefinitions={userColumnDef}
|
||||
data={[]}
|
||||
isLoading={isLoading}
|
||||
loadingState="Laster data..."
|
||||
/>
|
||||
<DataGrid columnDefinitions={userColumnDef} data={[]}>
|
||||
<DataTable
|
||||
loading={{
|
||||
isLoading,
|
||||
variant: "content",
|
||||
content: "Laster data...",
|
||||
}}
|
||||
/>
|
||||
</DataGrid>
|
||||
</VStack>
|
||||
);
|
||||
},
|
||||
@@ -315,12 +384,9 @@ export const LoadingWhileKeepingData: Story = {
|
||||
<Button onClick={() => setIsLoading((prev) => !prev)}>
|
||||
Toggle loading
|
||||
</Button>
|
||||
<DataTableAuto
|
||||
columnDefinitions={userColumnDef}
|
||||
data={userData}
|
||||
isLoading={isLoading}
|
||||
loadingRows={4}
|
||||
/>
|
||||
<DataGrid columnDefinitions={userColumnDef} data={userData}>
|
||||
<DataTable loading={{ isLoading, variant: "skeleton", rows: 4 }} />
|
||||
</DataGrid>
|
||||
</VStack>
|
||||
);
|
||||
},
|
||||
@@ -334,14 +400,24 @@ export const LoadingWhileKeepingDataNoPlaceholders: Story = {
|
||||
<Button onClick={() => setIsLoading((prev) => !prev)}>
|
||||
Toggle loading
|
||||
</Button>
|
||||
<DataTableAuto
|
||||
columnDefinitions={userColumnDef}
|
||||
data={userData}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<DataGrid columnDefinitions={userColumnDef} data={userData}>
|
||||
<DataTable
|
||||
loading={{
|
||||
isLoading,
|
||||
variant: "overlay",
|
||||
label: "Laster innhold for tabell",
|
||||
}}
|
||||
/>
|
||||
</DataGrid>
|
||||
</VStack>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(canvas.getAllByText("Laster innhold for tabell")).toHaveLength(1);
|
||||
expect(canvas.getByText("foo2")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
const selectionPaginationSpy = fn();
|
||||
@@ -357,16 +433,21 @@ export const SelectionPagination: Story = {
|
||||
<div>
|
||||
<button onClick={() => setPage("0")}>Page 1</button>
|
||||
<button onClick={() => setPage("1")}>Page 2</button>
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
columnDefinitions={userColumnDef}
|
||||
data={dataToShow}
|
||||
selectionMode="multiple"
|
||||
onSelectionChange={(keys) => {
|
||||
console.info({ keys });
|
||||
selectionPaginationSpy(keys);
|
||||
}}
|
||||
getRowId={(row) => row.foo + row.bar}
|
||||
/>
|
||||
>
|
||||
<DataTable
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
onSelectionChange: (keys) => {
|
||||
console.info({ keys });
|
||||
selectionPaginationSpy(keys);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</DataGrid>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -446,20 +527,25 @@ export const SelectionPagination: Story = {
|
||||
export const StickySelection: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
columnDefinitions={userColumnDef.map((col) => ({
|
||||
...col,
|
||||
defaultWidth: "350px",
|
||||
}))}
|
||||
data={userData}
|
||||
selectionMode="multiple"
|
||||
onSelectionChange={console.info}
|
||||
getRowId={(row) => row.foo + row.bar}
|
||||
withKeyboardNav
|
||||
stickyColumns={{
|
||||
first: "1",
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<DataTable
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
onSelectionChange: console.info,
|
||||
}}
|
||||
withKeyboardNav
|
||||
stickyColumns={{
|
||||
start: "1",
|
||||
}}
|
||||
/>
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -467,18 +553,21 @@ export const StickySelection: Story = {
|
||||
export const StickyLeftOne: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
columnDefinitions={userColumnDef.map((col) => ({
|
||||
...col,
|
||||
defaultWidth: "350px",
|
||||
}))}
|
||||
data={userData}
|
||||
getRowId={(row) => row.foo + row.bar}
|
||||
withKeyboardNav
|
||||
stickyColumns={{
|
||||
first: "1",
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<DataTable
|
||||
withKeyboardNav
|
||||
stickyColumns={{
|
||||
start: "1",
|
||||
}}
|
||||
/>
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -486,18 +575,21 @@ export const StickyLeftOne: Story = {
|
||||
export const StickyRightOne: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
columnDefinitions={userColumnDef.map((col) => ({
|
||||
...col,
|
||||
defaultWidth: "350px",
|
||||
}))}
|
||||
data={userData}
|
||||
getRowId={(row) => row.foo + row.bar}
|
||||
withKeyboardNav
|
||||
stickyColumns={{
|
||||
last: "1",
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<DataTable
|
||||
withKeyboardNav
|
||||
stickyColumns={{
|
||||
end: "1",
|
||||
}}
|
||||
/>
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -505,19 +597,22 @@ export const StickyRightOne: Story = {
|
||||
export const StickyBothOne: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
columnDefinitions={userColumnDef.map((col) => ({
|
||||
...col,
|
||||
defaultWidth: "250px",
|
||||
}))}
|
||||
data={userData}
|
||||
getRowId={(row) => row.foo + row.bar}
|
||||
withKeyboardNav
|
||||
stickyColumns={{
|
||||
first: "1",
|
||||
last: "1",
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<DataTable
|
||||
withKeyboardNav
|
||||
stickyColumns={{
|
||||
start: "1",
|
||||
end: "1",
|
||||
}}
|
||||
/>
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -526,13 +621,13 @@ export const StickyHeader: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ height: "300px" }}>
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
columnDefinitions={userColumnDef}
|
||||
data={generateUserData(20)}
|
||||
getRowId={(row) => row.foo + row.bar}
|
||||
withKeyboardNav
|
||||
stickyHeader
|
||||
/>
|
||||
>
|
||||
<DataTable withKeyboardNav stickyHeader />
|
||||
</DataGrid>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -542,20 +637,23 @@ export const StickyHeaderAndColumns: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ height: "300px" }}>
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
columnDefinitions={userColumnDef.map((col) => ({
|
||||
...col,
|
||||
defaultWidth: "250px",
|
||||
}))}
|
||||
data={generateUserData(20)}
|
||||
getRowId={(row) => row.foo + row.bar}
|
||||
withKeyboardNav
|
||||
stickyHeader
|
||||
stickyColumns={{
|
||||
first: "1",
|
||||
last: "1",
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<DataTable
|
||||
withKeyboardNav
|
||||
stickyHeader
|
||||
stickyColumns={{
|
||||
start: "1",
|
||||
end: "1",
|
||||
}}
|
||||
/>
|
||||
</DataGrid>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -603,7 +701,7 @@ const sortableColumnDef: ColumnDefinitions<SortableUserDataTest> = [
|
||||
id: "id",
|
||||
header: "Id",
|
||||
cell: ({ id }) => id,
|
||||
type: "number",
|
||||
align: "right",
|
||||
sortable: true,
|
||||
label: "Id",
|
||||
},
|
||||
@@ -631,8 +729,8 @@ function applySortEntries<T extends Record<string, unknown>>(
|
||||
if (sort.length === 0) return data;
|
||||
return [...data].sort((a, b) => {
|
||||
for (const { columnId, direction } of sort) {
|
||||
const aVal = a[columnId] as string | number;
|
||||
const bVal = b[columnId] as string | number;
|
||||
const aVal = a[columnId] as string;
|
||||
const bVal = b[columnId] as string;
|
||||
const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
|
||||
if (cmp !== 0) return direction === "asc" ? cmp : -cmp;
|
||||
}
|
||||
@@ -646,16 +744,21 @@ export const SortableColumns: Story = {
|
||||
const sortedData = applySortEntries(sortableUserData, sort);
|
||||
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
columnDefinitions={sortableColumnDef}
|
||||
data={sortedData}
|
||||
getRowId={(row) => row.id}
|
||||
sort={sort}
|
||||
onSortChange={(next, detail) => {
|
||||
console.info("changed column:", detail);
|
||||
setSort(next);
|
||||
}}
|
||||
/>
|
||||
getRowId={(row) => row.id.toString()}
|
||||
>
|
||||
<DataTable
|
||||
sorting={{
|
||||
sort,
|
||||
onSortChange: (next, detail) => {
|
||||
console.info("changed column:", detail);
|
||||
setSort(next);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -669,15 +772,20 @@ export const SortableColumnsUncontrolled: Story = {
|
||||
{loggedDetail && (
|
||||
<pre style={{ fontSize: "0.75rem" }}>{loggedDetail}</pre>
|
||||
)}
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
columnDefinitions={sortableColumnDef}
|
||||
data={sortableUserData}
|
||||
getRowId={(row) => row.id}
|
||||
defaultSort={[{ columnId: "name", direction: "asc" }]}
|
||||
onSortChange={(_sort, detail) =>
|
||||
setLoggedDetail(JSON.stringify(detail, null, 2))
|
||||
}
|
||||
/>
|
||||
getRowId={(row) => row.id.toString()}
|
||||
>
|
||||
<DataTable
|
||||
sorting={{
|
||||
defaultSort: [{ columnId: "name", direction: "asc" }],
|
||||
onSortChange: (_, detail) => {
|
||||
setLoggedDetail(JSON.stringify(detail, null, 2));
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</DataGrid>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -686,7 +794,7 @@ export const SortableColumnsUncontrolled: Story = {
|
||||
const rowClickSpy = fn();
|
||||
|
||||
const rowClickColumnDef: ColumnDefinitions<UserDataTest> = [
|
||||
{ id: "id", header: "Id", cell: ({ id }) => id, type: "number", label: "Id" },
|
||||
{ id: "id", header: "Id", cell: ({ id }) => id, align: "right", label: "Id" },
|
||||
{ id: "foo", header: "Foo", cell: ({ foo }) => foo, label: "Foo" },
|
||||
{
|
||||
id: "link",
|
||||
@@ -714,26 +822,36 @@ const rowClickColumnDef: ColumnDefinitions<UserDataTest> = [
|
||||
|
||||
export const RowClick: Story = {
|
||||
render: () => (
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
columnDefinitions={rowClickColumnDef}
|
||||
data={userData}
|
||||
getRowId={(row) => row.id}
|
||||
onRowClick={() => console.info("Row clicked!")}
|
||||
selectionMode="multiple"
|
||||
withKeyboardNav
|
||||
/>
|
||||
getRowId={(row) => row.id.toString()}
|
||||
>
|
||||
<DataTable
|
||||
onRowClick={(rowId) => console.info("Row clicked!: ", rowId)}
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
}}
|
||||
withKeyboardNav
|
||||
/>
|
||||
</DataGrid>
|
||||
),
|
||||
};
|
||||
|
||||
export const RowClickTest: Story = {
|
||||
render: () => (
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
columnDefinitions={rowClickColumnDef}
|
||||
data={userData}
|
||||
getRowId={(row) => row.id}
|
||||
onRowClick={rowClickSpy}
|
||||
selectionMode="multiple"
|
||||
/>
|
||||
getRowId={(row) => row.id.toString()}
|
||||
>
|
||||
<DataTable
|
||||
onRowClick={rowClickSpy}
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
}}
|
||||
/>
|
||||
</DataGrid>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
rowClickSpy.mockClear();
|
||||
@@ -764,40 +882,55 @@ export const RowClickTest: Story = {
|
||||
|
||||
export const RowExpansion: Story = {
|
||||
render: (args) => (
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
columnDefinitions={rowClickColumnDef}
|
||||
data={userData}
|
||||
getRowId={(row) => row.id}
|
||||
onRowClick={() => console.info("Row clicked!")}
|
||||
selectionMode={args.selectionMode}
|
||||
withKeyboardNav
|
||||
getDetailsPanelContent={(rowData) => {
|
||||
return <div>{`Details for ${rowData.foo} (id: ${rowData.id})`}</div>;
|
||||
}}
|
||||
/>
|
||||
getRowId={(row) => row.id.toString()}
|
||||
>
|
||||
<DataTable<UserDataTest>
|
||||
onRowClick={() => console.info("Row clicked!")}
|
||||
selection={{
|
||||
selectionMode: args.selection?.selectionMode ?? "none",
|
||||
}}
|
||||
withKeyboardNav
|
||||
/>
|
||||
</DataGrid>
|
||||
),
|
||||
...selectionControls,
|
||||
};
|
||||
|
||||
export const RowExpansionAll: Story = {
|
||||
render: (args) => (
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
columnDefinitions={rowClickColumnDef}
|
||||
data={userData}
|
||||
getRowId={(row) => row.id}
|
||||
onRowClick={() => console.info("Row clicked!")}
|
||||
selectionMode={args.selectionMode}
|
||||
withKeyboardNav
|
||||
getDetailsPanelContent={(rowData) => {
|
||||
return <div>{`Details for ${rowData.foo} (id: ${rowData.id})`}</div>;
|
||||
}}
|
||||
showExpandAll
|
||||
/>
|
||||
getRowId={(row) => row.id.toString()}
|
||||
>
|
||||
<DataTable<UserDataTest>
|
||||
onRowClick={() => console.info("Row clicked!")}
|
||||
selection={{
|
||||
selectionMode: args.selection?.selectionMode ?? "none",
|
||||
}}
|
||||
withKeyboardNav
|
||||
detailsPanel={{
|
||||
getContent: (rowData) => {
|
||||
return (
|
||||
<div>{`Details for ${rowData.foo} (id: ${rowData.id})`}</div>
|
||||
);
|
||||
},
|
||||
showExpandAll: true,
|
||||
}}
|
||||
/>
|
||||
</DataGrid>
|
||||
),
|
||||
...selectionControls,
|
||||
};
|
||||
|
||||
const nestedRowData = userData.map((user) => ({
|
||||
type NestedUserDataTest = UserDataTest & {
|
||||
children: NestedUserDataTest[];
|
||||
};
|
||||
|
||||
const nestedRowData: NestedUserDataTest[] = userData.map((user) => ({
|
||||
...user,
|
||||
children: [...generateUserData(3, user.id * 100)].map((child) => ({
|
||||
...child,
|
||||
@@ -810,15 +943,22 @@ const nestedRowData = userData.map((user) => ({
|
||||
|
||||
export const NestedRows: Story = {
|
||||
render: (args) => (
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
columnDefinitions={rowClickColumnDef}
|
||||
data={nestedRowData}
|
||||
getRowId={(row) => row.id}
|
||||
selectionMode={args.selectionMode}
|
||||
withKeyboardNav
|
||||
getSubRows={(row) => row.children}
|
||||
onSelectionChange={console.info}
|
||||
/>
|
||||
getRowId={(row) => row.id.toString()}
|
||||
>
|
||||
<DataTable<NestedUserDataTest>
|
||||
selection={{
|
||||
selectionMode: args.selection?.selectionMode ?? "none",
|
||||
onSelectionChange: console.info,
|
||||
}}
|
||||
withKeyboardNav
|
||||
subRows={{
|
||||
getRows: (row) => row.children,
|
||||
}}
|
||||
/>
|
||||
</DataGrid>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
@@ -832,7 +972,7 @@ export const NestedRows: Story = {
|
||||
await userEvent.click(getCheckboxes()[2]);
|
||||
|
||||
expect(getCheckboxes()[1].checked).toBe(false);
|
||||
expect(getCheckboxes()[1].indeterminate).toBe(true);
|
||||
expect(getCheckboxes()[1].indeterminate).toBe(false);
|
||||
|
||||
await userEvent.click(getCheckboxes()[1]);
|
||||
|
||||
@@ -861,22 +1001,29 @@ export const NestedRows: Story = {
|
||||
|
||||
export const NestedLeftAlignedContentRows: Story = {
|
||||
render: (args) => (
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
/* Removes right aligned id column */
|
||||
columnDefinitions={rowClickColumnDef.slice(1)}
|
||||
data={nestedRowData}
|
||||
getRowId={(row) => row.id}
|
||||
selectionMode={args.selectionMode}
|
||||
withKeyboardNav
|
||||
getSubRows={(row) => row.children}
|
||||
/>
|
||||
getRowId={(row) => row.id.toString()}
|
||||
>
|
||||
<DataTable<NestedUserDataTest>
|
||||
selection={{
|
||||
selectionMode: args.selection?.selectionMode ?? "none",
|
||||
}}
|
||||
withKeyboardNav
|
||||
subRows={{
|
||||
getRows: (row) => row.children,
|
||||
}}
|
||||
/>
|
||||
</DataGrid>
|
||||
),
|
||||
...selectionControls,
|
||||
};
|
||||
|
||||
export const NestedOneLevelLeftAlignedContentRows: Story = {
|
||||
render: (args) => (
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
/* Removes right aligned id column */
|
||||
columnDefinitions={rowClickColumnDef.slice(1)}
|
||||
data={userData.map((user) => ({
|
||||
@@ -886,18 +1033,25 @@ export const NestedOneLevelLeftAlignedContentRows: Story = {
|
||||
children: [],
|
||||
})),
|
||||
}))}
|
||||
getRowId={(row) => row.id}
|
||||
selectionMode={args.selectionMode}
|
||||
withKeyboardNav
|
||||
getSubRows={(row) => row.children}
|
||||
/>
|
||||
getRowId={(row) => row.id.toString()}
|
||||
>
|
||||
<DataTable<NestedUserDataTest>
|
||||
selection={{
|
||||
selectionMode: args.selection?.selectionMode ?? "none",
|
||||
}}
|
||||
withKeyboardNav
|
||||
subRows={{
|
||||
getRows: (row) => row.children,
|
||||
}}
|
||||
/>
|
||||
</DataGrid>
|
||||
),
|
||||
...selectionControls,
|
||||
};
|
||||
|
||||
export const NestedRowsWithMasterDetail: Story = {
|
||||
render: (args) => (
|
||||
<DataTableAuto
|
||||
<DataGrid
|
||||
/* Removes right aligned id column */
|
||||
columnDefinitions={rowClickColumnDef.slice(1)}
|
||||
data={userData.map((user) => ({
|
||||
@@ -907,13 +1061,29 @@ export const NestedRowsWithMasterDetail: Story = {
|
||||
children: [],
|
||||
})),
|
||||
}))}
|
||||
getRowId={(row) => row.id}
|
||||
selectionMode={args.selectionMode}
|
||||
withKeyboardNav
|
||||
getSubRows={(row) => row.children}
|
||||
getDetailsPanelContent={() => <div>Placeholder content</div>}
|
||||
getDetailsPanelHeight={() => 100}
|
||||
/>
|
||||
getRowId={(row) => row.id.toString()}
|
||||
>
|
||||
<DataTable<NestedUserDataTest>
|
||||
selection={{
|
||||
selectionMode: args.selection?.selectionMode ?? "none",
|
||||
}}
|
||||
withKeyboardNav
|
||||
subRows={{
|
||||
defaultExpandedRowIds: ["3"],
|
||||
getRows: (row) => row.children,
|
||||
}}
|
||||
detailsPanel={{
|
||||
getContent: () => (
|
||||
<div>
|
||||
This is the details panel content. It should be possible to
|
||||
interact with the content here without triggering row clicks or
|
||||
affecting row selection.
|
||||
</div>
|
||||
),
|
||||
getHeight: () => 100,
|
||||
}}
|
||||
/>
|
||||
</DataGrid>
|
||||
),
|
||||
/* play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
Table,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import React, { useState } from "react";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { DataTable } from "../table";
|
||||
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
|
||||
import { DataTableProfiler } from "./DataTableProfiler";
|
||||
import { PersonInfo, columns, sampleData } from "./dummy-data";
|
||||
import { DataGrid } from "../data-grid";
|
||||
import type { ColumnDefinitions } from "../table/root/DataTable.types";
|
||||
import { DataTable } from "../table/root/DataTableRoot";
|
||||
|
||||
const meta: Meta<typeof DataTable> = {
|
||||
title: "ds-react/Data/Keyboard Navigation",
|
||||
@@ -22,227 +12,81 @@ const meta: Meta<typeof DataTable> = {
|
||||
chromatic: { disable: true },
|
||||
layout: "padded",
|
||||
},
|
||||
decorators: [(Story) => <DataTableProfiler>{Story()}</DataTableProfiler>],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DataTable>;
|
||||
|
||||
export const TanstackDemo: Story = {
|
||||
render: () => {
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
data: sampleData,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
globalFilterFn: "includesString",
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageIndex: 1,
|
||||
pageSize: 5,
|
||||
},
|
||||
},
|
||||
state: {},
|
||||
columnResizeMode: "onChange",
|
||||
});
|
||||
type InputRow = { id: 1 | 2 | 3 };
|
||||
|
||||
return (
|
||||
<div style={{ padding: "4rem", display: "grid", gap: "2rem" }}>
|
||||
<button>Focuable before</button>
|
||||
<DataTable withKeyboardNav>
|
||||
<DataTable.Thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => {
|
||||
return (
|
||||
<DataTable.Tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<DataTableColumnHeader
|
||||
key={header.id}
|
||||
style={{ width: `var(--header-${header.id}-size)` }}
|
||||
defaultWidth={header.getSize()}
|
||||
sortable
|
||||
sortDirection={header.column.getIsSorted() || "none"}
|
||||
onSortClick={(event) => {
|
||||
const handler =
|
||||
header.column.getToggleSortingHandler();
|
||||
handler?.(event);
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</DataTableColumnHeader>
|
||||
);
|
||||
})}
|
||||
</DataTable.Tr>
|
||||
);
|
||||
})}
|
||||
</DataTable.Thead>
|
||||
const inputsData: InputRow[] = [{ id: 1 }, { id: 2 }, { id: 3 }];
|
||||
|
||||
<MemoizedTableBody table={table} />
|
||||
</DataTable>
|
||||
<button>Focuable after</button>
|
||||
</div>
|
||||
);
|
||||
const inputsColumnDef: ColumnDefinitions<InputRow> = [
|
||||
{
|
||||
id: "col1",
|
||||
header: "Col 1",
|
||||
label: "Col 1",
|
||||
cell: ({ id }) =>
|
||||
id === 1 ? (
|
||||
<input type="checkbox" data-testid="checkbox-1" />
|
||||
) : (
|
||||
<input type="checkbox" />
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const Spans: Story = {
|
||||
render: () => (
|
||||
<div style={{ padding: "4rem", display: "grid", gap: "2rem" }}>
|
||||
<DataTable style={{ width: "100%" }} withKeyboardNav layout="auto">
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader colSpan={2}>Group A</DataTableColumnHeader>
|
||||
<DataTableColumnHeader colSpan={2}>Group B</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Col 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 3</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 4</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td rowSpan={2}>Rowspan 2-1</DataTable.Td>
|
||||
<DataTable.Td>R1C2</DataTable.Td>
|
||||
<DataTable.Td colSpan={2}>Colspan 2-1</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>R2C2</DataTable.Td>
|
||||
<DataTable.Td>R2C3</DataTable.Td>
|
||||
<DataTable.Td>R2C4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>R3C1</DataTable.Td>
|
||||
<DataTable.Td colSpan={2}>Colspan 2-2</DataTable.Td>
|
||||
<DataTable.Td>R3C4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td rowSpan={2}>Rowspan 2-2</DataTable.Td>
|
||||
<DataTable.Td>R4C2</DataTable.Td>
|
||||
<DataTable.Td>R4C3</DataTable.Td>
|
||||
<DataTable.Td>R4C4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td colSpan={3}>Colspan 3</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
</div>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const { expectNodeFocus, right, left, down, up } = keyboardUtils();
|
||||
|
||||
const step = async (action: () => Promise<any>, text: string) => {
|
||||
await action();
|
||||
expectNodeFocus(text);
|
||||
};
|
||||
|
||||
const table = canvas.getByRole("table");
|
||||
expect(table).toBeInTheDocument();
|
||||
|
||||
table.focus();
|
||||
|
||||
expectNodeFocus("Group A");
|
||||
await step(right, "Group B");
|
||||
await step(down, "Col 3");
|
||||
await step(up, "Group B");
|
||||
await down();
|
||||
await step(right, "Col 4");
|
||||
await left();
|
||||
await step(left, "Col 2");
|
||||
await step(down, "R1C2");
|
||||
await step(down, "R2C2");
|
||||
await step(right, "R2C3");
|
||||
await step(down, "Colspan 2-2");
|
||||
await step(up, "R2C2");
|
||||
await step(down, "Colspan 2-2");
|
||||
await step(left, "R3C1");
|
||||
await step(up, "Rowspan 2-1");
|
||||
await step(right, "R1C2");
|
||||
await left();
|
||||
await down();
|
||||
await step(down, "Rowspan 2-2");
|
||||
await step(right, "R4C2");
|
||||
await step(right, "R4C3");
|
||||
await step(right, "R4C4");
|
||||
await step(down, "Colspan 3");
|
||||
await step(left, "Rowspan 2-2");
|
||||
await step(right, "R4C2");
|
||||
{
|
||||
id: "col2",
|
||||
header: "Col 2",
|
||||
label: "Col 2",
|
||||
cell: ({ id }) => {
|
||||
if (id === 1)
|
||||
return (
|
||||
<input type="text" placeholder="Col 2" data-testid="input-col-2" />
|
||||
);
|
||||
if (id === 2)
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Col 2"
|
||||
defaultValue="Test"
|
||||
data-testid="input-2"
|
||||
/>
|
||||
);
|
||||
return <textarea placeholder="Col 2" data-testid="textarea-col-2" />;
|
||||
},
|
||||
},
|
||||
};
|
||||
{
|
||||
id: "col3",
|
||||
header: "Col 3",
|
||||
label: "Col 3",
|
||||
cell: ({ id }) =>
|
||||
id === 2 ? (
|
||||
<select data-testid="select">
|
||||
<option value="">Select</option>
|
||||
<option value="option1">Option 1</option>
|
||||
<option value="option2">Option 2</option>
|
||||
</select>
|
||||
) : (
|
||||
"Col 3"
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "col4",
|
||||
header: "Col 4",
|
||||
label: "Col 4",
|
||||
cell: () => "Col 4",
|
||||
},
|
||||
];
|
||||
|
||||
export const Inputs: Story = {
|
||||
render: () => (
|
||||
<div style={{ padding: "4rem", display: "grid", gap: "2rem" }}>
|
||||
<DataTable style={{ width: "100%" }} withKeyboardNav layout="auto">
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Col 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 3</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 4</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>
|
||||
<input type="checkbox" data-testid="checkbox-1" />
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Col 2"
|
||||
data-testid="input-col-2"
|
||||
/>
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>Col 3</DataTable.Td>
|
||||
<DataTable.Td>Col 4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>
|
||||
<input type="checkbox" />
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Col 2"
|
||||
defaultValue="Test"
|
||||
data-testid="input-2"
|
||||
/>
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>
|
||||
<select data-testid="select">
|
||||
<option value="">Select</option>
|
||||
<option value="option1">Option 1</option>
|
||||
<option value="option2">Option 2</option>
|
||||
</select>
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>Col 4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>
|
||||
<input type="checkbox" />
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>
|
||||
<textarea placeholder="Col 2" data-testid="textarea-col-2" />
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>Col 3</DataTable.Td>
|
||||
<DataTable.Td>Col 4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
</div>
|
||||
<DataGrid
|
||||
columnDefinitions={inputsColumnDef}
|
||||
data={inputsData}
|
||||
getRowId={(row) => row.id.toString()}
|
||||
>
|
||||
<DataTable style={{ width: "100%" }} withKeyboardNav layout="auto" />
|
||||
</DataGrid>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
@@ -308,62 +152,83 @@ export const Inputs: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
type DisabledRow = { id: 1 | 2 | 3 };
|
||||
|
||||
const disabledData: DisabledRow[] = [{ id: 1 }, { id: 2 }, { id: 3 }];
|
||||
|
||||
const disabledColumnDef: ColumnDefinitions<DisabledRow> = [
|
||||
{
|
||||
id: "col1",
|
||||
header: "Col 1",
|
||||
label: "Col 1",
|
||||
cell: ({ id }) => {
|
||||
if (id === 2) return <button disabled>Disabled button</button>;
|
||||
if (id === 3)
|
||||
return <button aria-disabled="true">Aria-Disabled button</button>;
|
||||
return "Row 1 Col 1";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "col2",
|
||||
header: "Col 2",
|
||||
label: "Col 2",
|
||||
cell: ({ id }) => {
|
||||
if (id === 2)
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
defaultValue="Disabled input"
|
||||
data-testid="disabled-input"
|
||||
/>
|
||||
);
|
||||
if (id === 3) return "Aria Disabled 2";
|
||||
return "Row 1 Col 2";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "col3",
|
||||
header: "Col 3",
|
||||
label: "Col 3",
|
||||
cell: ({ id }) => {
|
||||
if (id === 2)
|
||||
return (
|
||||
<select disabled data-testid="disabled-select">
|
||||
<option>Disabled select</option>
|
||||
</select>
|
||||
);
|
||||
if (id === 3) return "Aria Disabled 3";
|
||||
return "Row 1 Col 3";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "col4",
|
||||
header: "Col 4",
|
||||
label: "Col 4",
|
||||
cell: ({ id }) => {
|
||||
if (id === 2)
|
||||
return (
|
||||
<textarea
|
||||
disabled
|
||||
defaultValue="Disabled textarea"
|
||||
data-testid="disabled-textarea"
|
||||
/>
|
||||
);
|
||||
if (id === 3) return "Aria Disabled 4";
|
||||
return "Row 1 Col 4";
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const DisabledCells: Story = {
|
||||
render: () => (
|
||||
<div style={{ padding: "4rem", display: "grid", gap: "2rem" }}>
|
||||
<DataTable style={{ width: "100%" }} withKeyboardNav layout="auto">
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Col 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 3</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 4</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Hidden 1</DataTable.Td>
|
||||
<DataTable.Td>Hidden 2</DataTable.Td>
|
||||
<DataTable.Td style={{ display: "none" }}>Hidden 3</DataTable.Td>
|
||||
<DataTable.Td style={{ visibility: "hidden" }}>
|
||||
Hidden 4
|
||||
</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>
|
||||
<button disabled>Disabled button</button>
|
||||
</DataTable.Td>
|
||||
<DataTable.Td data-testid="disabled-input">
|
||||
<input type="text" disabled defaultValue="Disabled input" />
|
||||
</DataTable.Td>
|
||||
<DataTable.Td data-testid="disabled-select">
|
||||
<select disabled>
|
||||
<option>Disabled select</option>
|
||||
</select>
|
||||
</DataTable.Td>
|
||||
<DataTable.Td data-testid="disabled-textarea">
|
||||
<textarea disabled defaultValue="Disabled textarea" />
|
||||
</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>
|
||||
<button aria-disabled="true">Aria-Disabled button</button>
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>Aria Disabled 2</DataTable.Td>
|
||||
<DataTable.Td>Aria Disabled 3</DataTable.Td>
|
||||
<DataTable.Td>Aria Disabled 4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Normal 1</DataTable.Td>
|
||||
<DataTable.Td style={{ visibility: "hidden" }}>
|
||||
<button disabled>Disabled button</button>
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>Normal 3</DataTable.Td>
|
||||
<DataTable.Td>Normal 4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
</div>
|
||||
<DataGrid
|
||||
columnDefinitions={disabledColumnDef}
|
||||
data={disabledData}
|
||||
getRowId={(row) => row.id.toString()}
|
||||
>
|
||||
<DataTable withKeyboardNav layout="auto" />
|
||||
</DataGrid>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
@@ -388,22 +253,21 @@ export const DisabledCells: Story = {
|
||||
expectNodeFocus("Col 1");
|
||||
await step(right, "Col 2");
|
||||
await step(left, "Col 1");
|
||||
await step(down, "Hidden 1");
|
||||
await step(right, "Hidden 2");
|
||||
await step(right, "Hidden 2");
|
||||
await step(down, "Row 1 Col 1");
|
||||
await step(right, "Row 1 Col 2");
|
||||
|
||||
await left();
|
||||
await stepCell(down, "Disabled button");
|
||||
await right();
|
||||
expect(canvas.getByTestId("disabled-input")).toHaveFocus();
|
||||
expect(canvas.getByTestId("disabled-input").closest("td")).toHaveFocus();
|
||||
await right();
|
||||
expect(canvas.getByTestId("disabled-select")).toHaveFocus();
|
||||
expect(canvas.getByTestId("disabled-select").closest("td")).toHaveFocus();
|
||||
await right();
|
||||
expect(canvas.getByTestId("disabled-textarea")).toHaveFocus();
|
||||
expect(canvas.getByTestId("disabled-textarea").closest("td")).toHaveFocus();
|
||||
await left();
|
||||
expect(canvas.getByTestId("disabled-select")).toHaveFocus();
|
||||
expect(canvas.getByTestId("disabled-select").closest("td")).toHaveFocus();
|
||||
await left();
|
||||
expect(canvas.getByTestId("disabled-input")).toHaveFocus();
|
||||
expect(canvas.getByTestId("disabled-input").closest("td")).toHaveFocus();
|
||||
await left();
|
||||
expectCellFocus("Disabled button");
|
||||
await stepCell(down, "Aria-Disabled button");
|
||||
@@ -413,89 +277,109 @@ export const DisabledCells: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
type CacheRow = { id: number; col1: string };
|
||||
|
||||
const cacheColumnDef: ColumnDefinitions<CacheRow> = [
|
||||
{ id: "col1", header: "Col 1", label: "Col 1", cell: ({ col1 }) => col1 },
|
||||
{ id: "col2", header: "Col 2", label: "Col 2", cell: () => "Col 2" },
|
||||
{
|
||||
id: "col3",
|
||||
header: "Col 3",
|
||||
label: "Col 3",
|
||||
cell: ({ id }) => (id === 1 ? <input type="checkbox" /> : "Col 3"),
|
||||
},
|
||||
{ id: "col4", header: "Col 4", label: "Col 4", cell: () => "Col 4" },
|
||||
];
|
||||
|
||||
export const Cache: Story = {
|
||||
render: () => {
|
||||
const [showThatSingleRow, setShowThatSingleRow] = useState(true);
|
||||
|
||||
const data: CacheRow[] = [
|
||||
{ id: 1, col1: "Row 1" },
|
||||
...(showThatSingleRow ? [{ id: 2, col1: "Custom row" }] : []),
|
||||
{ id: 3, col1: "Row 3" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: "4rem", display: "grid", gap: "2rem" }}>
|
||||
<button onClick={() => setShowThatSingleRow((s) => !s)}>
|
||||
Toggle single row: {showThatSingleRow ? "ON" : "OFF"}
|
||||
</button>
|
||||
<DataTable style={{ width: "100%" }} layout="auto">
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Col 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 3</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 4</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Col 1</DataTable.Td>
|
||||
<DataTable.Td>Col 2</DataTable.Td>
|
||||
<DataTable.Td>
|
||||
<input type="checkbox" />
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>Col 4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
{showThatSingleRow && (
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Custom row</DataTable.Td>
|
||||
<DataTable.Td>Col 2</DataTable.Td>
|
||||
<DataTable.Td>Col 3</DataTable.Td>
|
||||
<DataTable.Td>Col 4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
)}
|
||||
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Col 1</DataTable.Td>
|
||||
<DataTable.Td>Col 2</DataTable.Td>
|
||||
<DataTable.Td>Col 3</DataTable.Td>
|
||||
<DataTable.Td>Col 4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
<DataGrid
|
||||
columnDefinitions={cacheColumnDef}
|
||||
data={data}
|
||||
getRowId={(row) => row.id.toString()}
|
||||
>
|
||||
<DataTable withKeyboardNav layout="auto" />
|
||||
</DataGrid>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const { down, up, expectNodeFocus } = keyboardUtils();
|
||||
|
||||
const table = canvas.getByRole("table");
|
||||
const toggleButton = canvas.getByRole("button", {
|
||||
name: /toggle single row/i,
|
||||
});
|
||||
|
||||
table.focus();
|
||||
await down();
|
||||
expectNodeFocus("Row 1");
|
||||
await down();
|
||||
expectNodeFocus("Custom row");
|
||||
|
||||
await userEvent.click(toggleButton);
|
||||
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");
|
||||
},
|
||||
};
|
||||
|
||||
export const FocusElementInsideTable: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ padding: "4rem", display: "grid", gap: "2rem" }}>
|
||||
<DataTable style={{ width: "100%" }} withKeyboardNav layout="auto">
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Col 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 3</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 4</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Col 1</DataTable.Td>
|
||||
<DataTable.Td>
|
||||
<button>Focusable button</button>
|
||||
</DataTable.Td>
|
||||
<DataTable.Th>Col 3</DataTable.Th>
|
||||
<DataTable.Td>Col 4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
type FocusRow = { id: string };
|
||||
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Col 1</DataTable.Td>
|
||||
<DataTable.Td>Col 2</DataTable.Td>
|
||||
<DataTable.Td>Col 3</DataTable.Td>
|
||||
<DataTable.Td>Col 4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
</div>
|
||||
);
|
||||
const focusData: FocusRow[] = [{ id: "1" }, { id: "2" }];
|
||||
|
||||
const focusColumnDef: ColumnDefinitions<FocusRow> = [
|
||||
{ id: "col1", header: "Col 1", label: "Col 1", cell: () => "Col 1" },
|
||||
{
|
||||
id: "col2",
|
||||
header: "Col 2",
|
||||
label: "Col 2",
|
||||
cell: ({ id }) =>
|
||||
id === "1" ? <button>Focusable button</button> : "Col 2",
|
||||
},
|
||||
{
|
||||
id: "col3",
|
||||
header: "Col 3",
|
||||
label: "Col 3",
|
||||
cell: () => "Col 3",
|
||||
isRowHeader: true,
|
||||
},
|
||||
{ id: "col4", header: "Col 4", label: "Col 4", cell: () => "Col 4" },
|
||||
];
|
||||
|
||||
export const FocusElementInsideTable: Story = {
|
||||
render: () => (
|
||||
<DataGrid
|
||||
columnDefinitions={focusColumnDef}
|
||||
data={focusData}
|
||||
getRowId={(row) => row.id}
|
||||
>
|
||||
<DataTable withKeyboardNav layout="auto" />
|
||||
</DataGrid>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
@@ -515,29 +399,6 @@ export const FocusElementInsideTable: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
const TableBody = ({ table }: { table: Table<PersonInfo> }) => (
|
||||
<DataTable.Tbody>
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
return (
|
||||
<DataTable.Tr key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
return (
|
||||
<DataTable.Td key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataTable.Td>
|
||||
);
|
||||
})}
|
||||
</DataTable.Tr>
|
||||
);
|
||||
})}
|
||||
</DataTable.Tbody>
|
||||
);
|
||||
|
||||
const MemoizedTableBody = React.memo(
|
||||
TableBody,
|
||||
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn,
|
||||
) as typeof TableBody;
|
||||
|
||||
function keyboardUtils() {
|
||||
return {
|
||||
expectNodeFocus(text: string) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import { DataTable } from "../table";
|
||||
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
|
||||
import { sampleData } from "./dummy-data";
|
||||
import { DataGrid, type DataGridProps } from "../data-grid";
|
||||
import { DataTable, type DataTableProps } from "../table";
|
||||
import type { ColumnDefinitions } from "../table/root/DataTable.types";
|
||||
import { type PersonInfo, sampleData } from "./dummy-data";
|
||||
|
||||
const meta: Meta<typeof DataTable> = {
|
||||
title: "ds-react/Data/Layout",
|
||||
@@ -15,233 +16,215 @@ const meta: Meta<typeof DataTable> = {
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DataTable>;
|
||||
|
||||
const columns = [
|
||||
const columnDefinitions: ColumnDefinitions<PersonInfo> = [
|
||||
{ id: "id", label: "Id", cell: (row) => row.name },
|
||||
{ id: "name", label: "Name", cell: (row) => row.name },
|
||||
{ id: "nationalId", label: "National id", cell: (row) => row.nationalId },
|
||||
{ id: "dayJob", label: "Day job", cell: (row) => row.dayJob },
|
||||
{ id: "supervisor", label: "Supervisor", cell: (row) => row.supervisor },
|
||||
{
|
||||
header: "Id",
|
||||
accessorKey: "id",
|
||||
id: "dateReceived",
|
||||
label: "Date received",
|
||||
cell: (row) => row.dateReceived,
|
||||
},
|
||||
{
|
||||
header: "Name",
|
||||
accessorKey: "name",
|
||||
id: "message",
|
||||
label: "Message",
|
||||
cell: (row) => row.message,
|
||||
},
|
||||
{ id: "age", label: "Age", cell: (row) => row.age },
|
||||
{
|
||||
header: "National id",
|
||||
accessorKey: "nationalId",
|
||||
id: "forceSensitive",
|
||||
label: "Force sensitive",
|
||||
cell: (row) => (row.forceSensitive ? "Yes" : "No"),
|
||||
},
|
||||
{ id: "homeSystem", label: "Home system", cell: (row) => row.homeSystem },
|
||||
{
|
||||
header: "Day job",
|
||||
accessorKey: "dayJob",
|
||||
},
|
||||
{
|
||||
header: "Supervisor",
|
||||
accessorKey: "supervisor",
|
||||
},
|
||||
{
|
||||
header: "Date received",
|
||||
accessorKey: "dateReceived",
|
||||
},
|
||||
{
|
||||
header: "Message",
|
||||
accessorKey: "message",
|
||||
contentMaxWidth: 200,
|
||||
},
|
||||
{
|
||||
header: "Age",
|
||||
accessorKey: "age",
|
||||
},
|
||||
{
|
||||
header: "Force sensitive",
|
||||
accessorKey: "forceSensitive",
|
||||
},
|
||||
{
|
||||
header: "Home system",
|
||||
accessorKey: "homeSystem",
|
||||
},
|
||||
{
|
||||
header: "Skills",
|
||||
accessorKey: "skills",
|
||||
id: "skills",
|
||||
label: "Skills",
|
||||
cell: (row) => row.skills.join(", "),
|
||||
},
|
||||
];
|
||||
|
||||
export const AutoLayoutMinimal: Story = {
|
||||
render: () => {
|
||||
type MinimalRow = {
|
||||
col1: string;
|
||||
col2: string;
|
||||
col3: string;
|
||||
};
|
||||
|
||||
const minimalData: MinimalRow[] = [
|
||||
{ col1: "Test", col2: "Example", col3: "Demo of small table" },
|
||||
];
|
||||
|
||||
const minimalColumns: ColumnDefinitions<MinimalRow> = [
|
||||
{ id: "col1", label: "Column 1", cell: (row) => row.col1 },
|
||||
{ id: "col2", label: "Column 2", cell: (row) => row.col2 },
|
||||
{ id: "col3", label: "Column 3", cell: (row) => row.col3 },
|
||||
];
|
||||
|
||||
type SortableRow = {
|
||||
left: string;
|
||||
center: string;
|
||||
right: string;
|
||||
};
|
||||
|
||||
const sortableData: SortableRow[] = [
|
||||
{ left: "Data 1", center: "Yes", right: "100 500" },
|
||||
{ left: "Data 2", center: "No", right: "2 000 200" },
|
||||
{ left: "Data 3", center: "Maybe", right: "1 000 200" },
|
||||
];
|
||||
|
||||
export const AutoLayoutMinimal: StoryObj<{
|
||||
table: DataTableProps<MinimalRow>;
|
||||
grid: Omit<DataGridProps<MinimalRow>, "children">;
|
||||
}> = {
|
||||
render: (args) => {
|
||||
return (
|
||||
<DataTable layout="auto">
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Test</DataTable.Td>
|
||||
<DataTable.Td>Example</DataTable.Td>
|
||||
<DataTable.Td>Demo of small table</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
<DataGrid {...args.grid}>
|
||||
<DataTable {...args.table} />
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
a11y: { disable: true },
|
||||
controls: { disable: true },
|
||||
docs: { disable: true },
|
||||
args: {
|
||||
table: {
|
||||
layout: "auto",
|
||||
},
|
||||
grid: { data: minimalData, columnDefinitions: minimalColumns },
|
||||
},
|
||||
};
|
||||
|
||||
export const AutoLayoutOverflowX: Story = {
|
||||
render: () => {
|
||||
export const AutoLayoutOverflowX: StoryObj<{
|
||||
table: DataTableProps<PersonInfo>;
|
||||
grid: Omit<DataGridProps<PersonInfo>, "children">;
|
||||
}> = {
|
||||
render: (args) => {
|
||||
return (
|
||||
<DataTable layout="auto">
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
{columns.map((column) => {
|
||||
return (
|
||||
<DataTableColumnHeader key={column.header}>
|
||||
{column.header}
|
||||
</DataTableColumnHeader>
|
||||
);
|
||||
})}
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
{sampleData.slice(0, 4).map((row) => (
|
||||
<DataTable.Tr key={row.name}>
|
||||
{columns.map((column) => (
|
||||
<DataTable.Td
|
||||
key={column.accessorKey}
|
||||
contentMaxWidth={column.contentMaxWidth}
|
||||
>
|
||||
{row[column.accessorKey]}
|
||||
</DataTable.Td>
|
||||
))}
|
||||
</DataTable.Tr>
|
||||
))}
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
<DataGrid {...args.grid}>
|
||||
<DataTable {...args.table} />
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
args: {
|
||||
table: { layout: "auto", truncateContent: true },
|
||||
grid: {
|
||||
data: sampleData.slice(0, 4),
|
||||
columnDefinitions: columnDefinitions.map((col) =>
|
||||
col.id === "message"
|
||||
? {
|
||||
...col,
|
||||
cell: (row: PersonInfo) => (
|
||||
<div style={{ maxWidth: 200 }}>{row.message}</div>
|
||||
),
|
||||
}
|
||||
: col,
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const AutoLayoutNoCellTruncation: Story = {
|
||||
render: () => {
|
||||
export const AutoLayoutNoCellTruncation: StoryObj<{
|
||||
table: DataTableProps<PersonInfo>;
|
||||
grid: Omit<DataGridProps<PersonInfo>, "children">;
|
||||
}> = {
|
||||
render: (args) => {
|
||||
return (
|
||||
<DataTable layout="auto" truncateContent={false}>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
{columns.map((column) => (
|
||||
<DataTableColumnHeader key={column.header}>
|
||||
{column.header}
|
||||
</DataTableColumnHeader>
|
||||
))}
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
{sampleData.slice(0, 3).map((row) => (
|
||||
<DataTable.Tr key={row.name}>
|
||||
{columns.map((column) => (
|
||||
<DataTable.Td key={column.accessorKey}>
|
||||
{column.accessorKey === "message"
|
||||
? row[column.accessorKey].split(" ").slice(0, 4).join(" ") +
|
||||
"."
|
||||
: row[column.accessorKey]}
|
||||
</DataTable.Td>
|
||||
))}
|
||||
</DataTable.Tr>
|
||||
))}
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
<DataGrid {...args.grid}>
|
||||
<DataTable {...args.table} />
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
args: {
|
||||
table: { layout: "auto", truncateContent: false },
|
||||
grid: {
|
||||
data: sampleData.slice(0, 3),
|
||||
columnDefinitions: columnDefinitions.map((col) =>
|
||||
col.id === "message"
|
||||
? {
|
||||
...col,
|
||||
cell: (row: PersonInfo) =>
|
||||
row.message.split(" ").slice(0, 4).join(" ") + ".",
|
||||
}
|
||||
: col,
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const AutoLayoutSortable: Story = {
|
||||
render: () => (
|
||||
<DataTable layout="auto">
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader sortable>Left</DataTableColumnHeader>
|
||||
<DataTableColumnHeader sortable>Center</DataTableColumnHeader>
|
||||
<DataTableColumnHeader sortable>Right</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Data 1</DataTable.Td>
|
||||
<DataTable.Td>Yes</DataTable.Td>
|
||||
<DataTable.Td>100 500</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Data 2</DataTable.Td>
|
||||
<DataTable.Td>No</DataTable.Td>
|
||||
<DataTable.Td>2 000 200</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Data 3</DataTable.Td>
|
||||
<DataTable.Td>Maybe</DataTable.Td>
|
||||
<DataTable.Td>1 000 200</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
),
|
||||
};
|
||||
|
||||
export const FixedLayoutMinimal: Story = {
|
||||
render: () => {
|
||||
export const AutoLayoutSortable: StoryObj<{
|
||||
table: DataTableProps<SortableRow>;
|
||||
grid: Omit<DataGridProps<SortableRow>, "children">;
|
||||
}> = {
|
||||
render: (args) => {
|
||||
return (
|
||||
<DataTable layout="fixed">
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Test</DataTable.Td>
|
||||
<DataTable.Td>Example</DataTable.Td>
|
||||
<DataTable.Td>Demo of small table</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
<DataGrid {...args.grid}>
|
||||
<DataTable {...args.table} />
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
a11y: { disable: true },
|
||||
controls: { disable: true },
|
||||
docs: { disable: true },
|
||||
args: {
|
||||
table: { layout: "auto" },
|
||||
grid: {
|
||||
data: sortableData,
|
||||
columnDefinitions: [
|
||||
{
|
||||
id: "left",
|
||||
label: "Left",
|
||||
sortable: true,
|
||||
cell: (row) => row.left,
|
||||
},
|
||||
{
|
||||
id: "center",
|
||||
label: "Center",
|
||||
sortable: true,
|
||||
cell: (row) => row.center,
|
||||
},
|
||||
{
|
||||
id: "right",
|
||||
label: "Right",
|
||||
sortable: true,
|
||||
cell: (row) => row.right,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FixedLayoutDynamicWidth: Story = {
|
||||
render: () => {
|
||||
export const FixedLayoutMinimal: StoryObj<{
|
||||
table: DataTableProps<MinimalRow>;
|
||||
grid: Omit<DataGridProps<MinimalRow>, "children">;
|
||||
}> = {
|
||||
render: (args) => {
|
||||
return (
|
||||
<DataTable layout="fixed">
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader width="100%">Column 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader width="100%">Column 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader width="100%">Column 3</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Test</DataTable.Td>
|
||||
<DataTable.Td>Example</DataTable.Td>
|
||||
<DataTable.Td>Demo of small table</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
<DataGrid {...args.grid}>
|
||||
<DataTable {...args.table} />
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
a11y: { disable: true },
|
||||
controls: { disable: true },
|
||||
docs: { disable: true },
|
||||
args: {
|
||||
table: { layout: "fixed" },
|
||||
grid: { data: minimalData, columnDefinitions: minimalColumns },
|
||||
},
|
||||
};
|
||||
|
||||
export const FixedLayoutDynamicWidth: StoryObj<{
|
||||
table: DataTableProps<MinimalRow>;
|
||||
grid: Omit<DataGridProps<MinimalRow>, "children">;
|
||||
}> = {
|
||||
render: (args) => {
|
||||
return (
|
||||
<DataGrid {...args.grid}>
|
||||
<DataTable {...args.table} />
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
args: {
|
||||
table: { layout: "fixed" },
|
||||
grid: {
|
||||
data: minimalData,
|
||||
columnDefinitions: minimalColumns.map((col) => ({
|
||||
...col,
|
||||
defaultWidth: "100%",
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -2,8 +2,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import React, { useDeferredValue, useMemo, useState } from "react";
|
||||
import { Search } from "../../form/search";
|
||||
import { VStack } from "../../primitives/stack";
|
||||
import { DataTable } from "../table";
|
||||
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
|
||||
import { DataTable } from "../table/root/DataTableRoot.legacy";
|
||||
import { DataToolbar } from "../toolbar";
|
||||
import { PersonInfo, sampleData } from "./dummy-data";
|
||||
|
||||
@@ -110,7 +110,7 @@ export const WithoutTanstack: Story = {
|
||||
return (
|
||||
<DataTableColumnHeader
|
||||
key={column.header}
|
||||
defaultWidth={150}
|
||||
width={{ default: 150 }}
|
||||
//style={{ width: `var(--header-${header.id}-size)` }}
|
||||
data-key={column.accessorKey}
|
||||
/* pinningHandler={() => {
|
||||
@@ -128,6 +128,7 @@ export const WithoutTanstack: Story = {
|
||||
});
|
||||
}}
|
||||
isPinned={columnPinning.left.includes(column.accessorKey)} */
|
||||
label={column.header}
|
||||
>
|
||||
{column.header}
|
||||
</DataTableColumnHeader>
|
||||
|
||||
@@ -12,8 +12,8 @@ import React from "react";
|
||||
import { Search } from "../../form/search";
|
||||
import { Box } from "../../primitives/box";
|
||||
import { VStack } from "../../primitives/stack";
|
||||
import { DataTable } from "../table";
|
||||
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
|
||||
import { DataTable } from "../table/root/DataTableRoot.legacy";
|
||||
import { DataToolbar } from "../toolbar";
|
||||
import { PersonInfo, columns, sampleData } from "./dummy-data";
|
||||
|
||||
@@ -85,7 +85,10 @@ export const NoPageScroll: Story = {
|
||||
return (
|
||||
<DataTableColumnHeader
|
||||
key={header.id}
|
||||
defaultWidth={header.getSize()}
|
||||
label={(
|
||||
header.column.columnDef.header || ""
|
||||
).toString()}
|
||||
width={{ default: header.getSize() }}
|
||||
sortable
|
||||
sortDirection={header.column.getIsSorted() || "none"}
|
||||
onSortClick={(event) => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import { expect, within } from "storybook/test";
|
||||
import { DataGrid, type DataGridProps } from "../data-grid";
|
||||
import { DataTable } from "../table";
|
||||
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
|
||||
|
||||
const meta: Meta<typeof DataTable> = {
|
||||
title: "ds-react/Data/Resize",
|
||||
@@ -14,160 +15,240 @@ const meta: Meta<typeof DataTable> = {
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DataTable>;
|
||||
type Story = StoryObj<DataGridProps<Row>>;
|
||||
|
||||
type Row = {
|
||||
left: string;
|
||||
center: string;
|
||||
right: string;
|
||||
};
|
||||
|
||||
const testData = [
|
||||
{
|
||||
left: "Data 1",
|
||||
center: "Yes",
|
||||
right: "100 500",
|
||||
},
|
||||
{
|
||||
left: "Data 2",
|
||||
center: "No",
|
||||
right: "2 000 200",
|
||||
},
|
||||
{
|
||||
left: "Data 3",
|
||||
center: "Maybe",
|
||||
right: "1 000 200",
|
||||
},
|
||||
];
|
||||
|
||||
export const Resize: Story = {
|
||||
render: () => (
|
||||
<DataTable withKeyboardNav>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader textAlign="left">Left</DataTableColumnHeader>
|
||||
<DataTableColumnHeader textAlign="center">
|
||||
Center
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader textAlign="right">Right</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Data 1</DataTable.Td>
|
||||
<DataTable.Td textAlign="center">Yes</DataTable.Td>
|
||||
<DataTable.Td textAlign="right">100 500</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Data 2</DataTable.Td>
|
||||
<DataTable.Td textAlign="center">No</DataTable.Td>
|
||||
<DataTable.Td textAlign="right">2 000 200</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Data 3</DataTable.Td>
|
||||
<DataTable.Td textAlign="center">Maybe</DataTable.Td>
|
||||
<DataTable.Td textAlign="right">1 000 200</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
),
|
||||
render: (args) => {
|
||||
return (
|
||||
<DataGrid {...args}>
|
||||
<DataTable />
|
||||
</DataGrid>
|
||||
);
|
||||
},
|
||||
args: {
|
||||
data: testData,
|
||||
columnDefinitions: [
|
||||
{
|
||||
id: "left",
|
||||
label: "Left",
|
||||
align: "left",
|
||||
cell: (row) => row.left,
|
||||
},
|
||||
{
|
||||
id: "center",
|
||||
label: "Center",
|
||||
align: "center",
|
||||
cell: (row) => row.center,
|
||||
},
|
||||
{
|
||||
id: "right",
|
||||
label: "Right",
|
||||
align: "right",
|
||||
cell: (row) => row.right,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const ResizeMinMax: Story = {
|
||||
render: () => (
|
||||
<DataTable withKeyboardNav>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader
|
||||
maxWidth="400px"
|
||||
minWidth="100px"
|
||||
textAlign="left"
|
||||
>
|
||||
Left
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader
|
||||
maxWidth="400px"
|
||||
minWidth="50px"
|
||||
textAlign="center"
|
||||
>
|
||||
Center
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader
|
||||
maxWidth="400px"
|
||||
minWidth="200px"
|
||||
textAlign="right"
|
||||
>
|
||||
Right
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Data 1</DataTable.Td>
|
||||
<DataTable.Td textAlign="center">Yes</DataTable.Td>
|
||||
<DataTable.Td textAlign="right">100 500</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Data 2</DataTable.Td>
|
||||
<DataTable.Td textAlign="center">No</DataTable.Td>
|
||||
<DataTable.Td textAlign="right">2 000 200</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Data 3</DataTable.Td>
|
||||
<DataTable.Td textAlign="center">Maybe</DataTable.Td>
|
||||
<DataTable.Td textAlign="right">1 000 200</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
),
|
||||
args: {
|
||||
data: testData,
|
||||
columnDefinitions: [
|
||||
{
|
||||
id: "left",
|
||||
label: "Left",
|
||||
align: "left",
|
||||
width: {
|
||||
default: 250,
|
||||
resizeMin: 100,
|
||||
resizeMax: 200,
|
||||
},
|
||||
cell: (row) => row.left,
|
||||
},
|
||||
{
|
||||
id: "center",
|
||||
label: "Center",
|
||||
align: "center",
|
||||
width: {
|
||||
resizeMin: 50,
|
||||
resizeMax: 400,
|
||||
},
|
||||
cell: (row) => row.center,
|
||||
},
|
||||
{
|
||||
id: "right",
|
||||
label: "Right",
|
||||
align: "right",
|
||||
width: {
|
||||
default: 150,
|
||||
resizeMin: 200,
|
||||
resizeMax: 400,
|
||||
},
|
||||
cell: (row) => row.right,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const ResizeDefaultStaticWidth: Story = {
|
||||
render: () => (
|
||||
<DataTable withKeyboardNav>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader defaultWidth="300px" textAlign="left">
|
||||
Left
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader defaultWidth="300px" textAlign="center">
|
||||
Center
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader defaultWidth="300px" textAlign="right">
|
||||
Right
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Data 1</DataTable.Td>
|
||||
<DataTable.Td textAlign="center">Yes</DataTable.Td>
|
||||
<DataTable.Td textAlign="right">100 500</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Data 2</DataTable.Td>
|
||||
<DataTable.Td textAlign="center">No</DataTable.Td>
|
||||
<DataTable.Td textAlign="right">2 000 200</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Data 3</DataTable.Td>
|
||||
<DataTable.Td textAlign="center">Maybe</DataTable.Td>
|
||||
<DataTable.Td textAlign="right">1 000 200</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
),
|
||||
args: {
|
||||
data: testData,
|
||||
columnDefinitions: [
|
||||
{
|
||||
id: "left",
|
||||
label: "Left",
|
||||
align: "left",
|
||||
width: { default: "300px" },
|
||||
cell: (row) => row.left,
|
||||
},
|
||||
{
|
||||
id: "center",
|
||||
label: "Center",
|
||||
align: "center",
|
||||
width: { default: "300px" },
|
||||
cell: (row) => row.center,
|
||||
},
|
||||
{
|
||||
id: "right",
|
||||
label: "Right",
|
||||
align: "right",
|
||||
width: { default: "300px" },
|
||||
cell: (row) => row.right,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const ResizeDefaultDynamicWidth: Story = {
|
||||
render: () => (
|
||||
<DataTable withKeyboardNav>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader defaultWidth="100%" textAlign="left">
|
||||
Left
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader defaultWidth="100%" textAlign="center">
|
||||
Center
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader defaultWidth="100%" textAlign="right">
|
||||
Right
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Data 1</DataTable.Td>
|
||||
<DataTable.Td textAlign="center">Yes</DataTable.Td>
|
||||
<DataTable.Td textAlign="right">100 500</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Data 2</DataTable.Td>
|
||||
<DataTable.Td textAlign="center">No</DataTable.Td>
|
||||
<DataTable.Td textAlign="right">2 000 200</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Data 3</DataTable.Td>
|
||||
<DataTable.Td textAlign="center">Maybe</DataTable.Td>
|
||||
<DataTable.Td textAlign="right">1 000 200</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
),
|
||||
args: {
|
||||
data: testData,
|
||||
columnDefinitions: [
|
||||
{
|
||||
id: "left",
|
||||
label: "Left",
|
||||
align: "left",
|
||||
width: { default: "100%" },
|
||||
cell: (row) => row.left,
|
||||
},
|
||||
{
|
||||
id: "center",
|
||||
label: "Center",
|
||||
align: "center",
|
||||
width: { default: "100%" },
|
||||
cell: (row) => row.center,
|
||||
},
|
||||
{
|
||||
id: "right",
|
||||
label: "Right",
|
||||
align: "right",
|
||||
width: { default: "100%" },
|
||||
cell: (row) => row.right,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const ResizeAuto: Story = {
|
||||
args: {
|
||||
data: testData,
|
||||
columnDefinitions: [
|
||||
{
|
||||
id: "left",
|
||||
label: "L",
|
||||
align: "left",
|
||||
width: {
|
||||
default: 200,
|
||||
autoResizeOnce: true,
|
||||
},
|
||||
cell: (row) => row.left,
|
||||
},
|
||||
{
|
||||
id: "center",
|
||||
label: "C",
|
||||
align: "center",
|
||||
width: {
|
||||
default: 200,
|
||||
autoResizeOnce: true,
|
||||
},
|
||||
cell: (row) => row.center,
|
||||
},
|
||||
{
|
||||
id: "right",
|
||||
label: "R",
|
||||
align: "right",
|
||||
width: {
|
||||
default: 200,
|
||||
autoResizeOnce: true,
|
||||
},
|
||||
cell: (row) => row.right,
|
||||
},
|
||||
{
|
||||
id: "headingIsWidest",
|
||||
label: "Heading is widest",
|
||||
width: {
|
||||
default: 50,
|
||||
autoResizeOnce: true,
|
||||
},
|
||||
cell: () => "Test",
|
||||
},
|
||||
{
|
||||
id: "headingIsWidestSortable",
|
||||
label: "Heading is widest + sortable",
|
||||
width: {
|
||||
default: 50,
|
||||
autoResizeOnce: true,
|
||||
},
|
||||
sortable: true,
|
||||
cell: () => "Test",
|
||||
},
|
||||
],
|
||||
},
|
||||
render: (props) => {
|
||||
const [showTable, setShowTable] = React.useState(false);
|
||||
return showTable ? (
|
||||
<DataGrid {...props}>
|
||||
<DataTable />
|
||||
</DataGrid>
|
||||
) : (
|
||||
<button onClick={() => setShowTable(true)}>Show table</button>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await new Promise((r) => setTimeout(r, 200)); // Wait for font to load, so that correct widths are calculated.
|
||||
const button = canvas.getByRole("button", { name: "Show table" });
|
||||
button.click();
|
||||
await new Promise((r) => setTimeout(r, 100)); // Make sure auto resize has happened
|
||||
const headers = canvas.getAllByRole("columnheader");
|
||||
expect(headers.length).toBe(5);
|
||||
expect(headers[0]).toHaveStyle({ width: "80px" });
|
||||
expect(headers[1]).toHaveStyle({ width: "82px" });
|
||||
expect(headers[2]).toHaveStyle({ width: "102px" });
|
||||
expect(headers[3]).toHaveStyle({ width: "168px" });
|
||||
expect(headers[4]).toHaveStyle({ width: "248px" });
|
||||
},
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import { DataTable } from "../table";
|
||||
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
|
||||
import { DataTable } from "../table/root/DataTableRoot.legacy";
|
||||
|
||||
const meta: Meta<typeof DataTable> = {
|
||||
title: "ds-react/Data/Settings",
|
||||
@@ -21,11 +21,13 @@ export const TextAlign: Story = {
|
||||
<DataTable layout="auto" withKeyboardNav>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Left</DataTableColumnHeader>
|
||||
<DataTableColumnHeader textAlign="center">
|
||||
<DataTableColumnHeader label="Left">Left</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Center" textAlign="center">
|
||||
Center
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader textAlign="right">Right</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Right" textAlign="right">
|
||||
Right
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
@@ -54,11 +56,13 @@ export const TextAlignSortable: Story = {
|
||||
<DataTable layout="auto" withKeyboardNav>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader sortable>Left</DataTableColumnHeader>
|
||||
<DataTableColumnHeader sortable textAlign="center">
|
||||
<DataTableColumnHeader label="Left" sortable>
|
||||
Left
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Center" sortable textAlign="center">
|
||||
Center
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader sortable textAlign="right">
|
||||
<DataTableColumnHeader label="Right" sortable textAlign="right">
|
||||
Right
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import { VStack } from "../../primitives/stack";
|
||||
import { DataTable } from "../table";
|
||||
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
|
||||
import { DataTable } from "../table/root/DataTableRoot.legacy";
|
||||
|
||||
const meta: Meta<typeof DataTable> = {
|
||||
title: "ds-react/Data/Spans",
|
||||
@@ -24,18 +24,26 @@ export const Spans: Story = {
|
||||
<DataTable>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader width="100%" colSpan={2}>
|
||||
<DataTableColumnHeader
|
||||
label="Group A"
|
||||
width={{ default: "100%" }}
|
||||
colSpan={2}
|
||||
>
|
||||
Group A
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader width="100%" colSpan={2}>
|
||||
<DataTableColumnHeader
|
||||
label="Group B"
|
||||
width={{ default: "100%" }}
|
||||
colSpan={2}
|
||||
>
|
||||
Group B
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Col 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 3</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 4</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Col 1">Col 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Col 2">Col 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Col 3">Col 3</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Col 4">Col 4</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
@@ -105,18 +113,36 @@ export const SpansSortable: Story = {
|
||||
<DataTable>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader width="100%" colSpan={2} sortable>
|
||||
<DataTableColumnHeader
|
||||
label="Group A"
|
||||
width={{ default: "100%" }}
|
||||
colSpan={2}
|
||||
sortable
|
||||
>
|
||||
Group A
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader width="100%" colSpan={2} sortable>
|
||||
<DataTableColumnHeader
|
||||
label="Group B"
|
||||
width={{ default: "100%" }}
|
||||
colSpan={2}
|
||||
sortable
|
||||
>
|
||||
Group B
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader sortable>Col 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader sortable>Col 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader sortable>Col 3</DataTableColumnHeader>
|
||||
<DataTableColumnHeader sortable>Col 4</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Col 1" sortable>
|
||||
Col 1
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Col 2" sortable>
|
||||
Col 2
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Col 3" sortable>
|
||||
Col 3
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Col 4" sortable>
|
||||
Col 4
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import { Button } from "../../button";
|
||||
import { DataTable } from "../table";
|
||||
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
|
||||
|
||||
const meta: Meta<typeof DataTable> = {
|
||||
title: "ds-react/Data/States",
|
||||
component: DataTable,
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
layout: "fullscreen",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DataTable>;
|
||||
|
||||
export const EmptyStateAuto: Story = {
|
||||
render: () => (
|
||||
<DataTable layout="auto" withKeyboardNav>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.EmptyState>
|
||||
<div>No data available</div>
|
||||
<Button size="small">Create data</Button>
|
||||
</DataTable.EmptyState>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
),
|
||||
};
|
||||
|
||||
export const EmptyStateFixed: Story = {
|
||||
render: () => (
|
||||
<DataTable layout="fixed" withKeyboardNav>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.EmptyState>
|
||||
<div>No data available</div>
|
||||
<Button size="small">Create data</Button>
|
||||
</DataTable.EmptyState>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
),
|
||||
};
|
||||
|
||||
export const LoadingStateAuto: Story = {
|
||||
render: () => (
|
||||
<DataTable layout="auto" withKeyboardNav>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.LoadingState>Loading resources...</DataTable.LoadingState>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
),
|
||||
};
|
||||
|
||||
export const LoadingStateFixed: Story = {
|
||||
render: () => (
|
||||
<DataTable layout="fixed" withKeyboardNav>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.LoadingState>Loading resources...</DataTable.LoadingState>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
),
|
||||
};
|
||||
@@ -1,14 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
type RowSelectionState,
|
||||
Table,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
@@ -17,25 +7,26 @@ import {
|
||||
} from "@navikt/aksel-icons";
|
||||
import { Button } from "../../button";
|
||||
import { Dialog } from "../../dialog";
|
||||
import { CheckboxInput } from "../../form/checkbox/checkbox-input/CheckboxInput";
|
||||
import { Checkbox } from "../../form/checkbox";
|
||||
import { Radio, RadioGroup } from "../../form/radio";
|
||||
import { RadioInput } from "../../form/radio/radio-input/RadioInput";
|
||||
import { Search } from "../../form/search";
|
||||
import { Select } from "../../form/select";
|
||||
import { Switch } from "../../form/switch";
|
||||
import { HStack, VStack } from "../../primitives/stack";
|
||||
import { BodyShort } from "../../typography";
|
||||
import DataDragAndDrop from "../drag-and-drop-old/root/DataDragAndDropRoot";
|
||||
import { DataTable } from "../table";
|
||||
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
|
||||
import { BodyShort, Heading } from "../../typography";
|
||||
import { DataGrid } from "../data-grid";
|
||||
import DragAndDrop from "../drag-and-drop/root/DragAndDropRoot";
|
||||
import type { SelectionProps } from "../table/hooks/useTableSelection";
|
||||
import { DataTable } from "../table/root/DataTableRoot";
|
||||
import { TokenFilter } from "../token-filter/TokenFilter";
|
||||
import type { ExternalQuery } from "../token-filter/TokenFilter.types";
|
||||
import { DataToolbar } from "../toolbar";
|
||||
import {
|
||||
PersonInfo,
|
||||
columns,
|
||||
homeSystemOptions,
|
||||
sampleData,
|
||||
} from "./dummy-data";
|
||||
TEST_DATA,
|
||||
TEST_DATA_NESTED,
|
||||
columnDef_TEST_DATA,
|
||||
columnDef_TEST_DATA_NESTED,
|
||||
} from "./Data.test-data";
|
||||
import { PersonInfo, homeSystemOptions } from "./dummy-data";
|
||||
|
||||
const meta: Meta<typeof DataTable> = {
|
||||
title: "ds-react/Data",
|
||||
@@ -50,15 +41,21 @@ export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DataTable>;
|
||||
|
||||
export const KitchenSink: Story = {
|
||||
/* export const KitchenSink: Story = {
|
||||
render: () => {
|
||||
const [rowDensity, setRowDensity] = React.useState<
|
||||
"normal" | "condensed" | "spacious"
|
||||
>("normal");
|
||||
const [zebraStripes, setZebraStripes] = React.useState(false);
|
||||
const [truncateContent, setTruncateContent] = React.useState(true);
|
||||
const [columnOrder, setColumnOrder] = React.useState<string[]>(
|
||||
columns.map((col) => col.accessorKey!),
|
||||
const [columnOrder, setColumnOrder] = React.useState<
|
||||
ColumnDefinitions<any, any>
|
||||
>(
|
||||
columns.map((col) => ({
|
||||
id: col.accessorKey!,
|
||||
label: col.accessorKey!,
|
||||
cell: () => <></>,
|
||||
})),
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -76,10 +73,9 @@ export const KitchenSink: Story = {
|
||||
},
|
||||
},
|
||||
state: {
|
||||
columnOrder,
|
||||
columnOrder: columnOrder.map((col) => col.id),
|
||||
},
|
||||
enableRowSelection: false,
|
||||
onColumnOrderChange: setColumnOrder,
|
||||
columnResizeMode: "onChange",
|
||||
debugTable: false,
|
||||
debugHeaders: false,
|
||||
@@ -183,30 +179,29 @@ export const KitchenSink: Story = {
|
||||
>
|
||||
Velg alle
|
||||
</Switch>
|
||||
<DataDragAndDrop setItems={setColumnOrder}>
|
||||
{table.getAllLeafColumns().map((column, index) => {
|
||||
<DragAndDrop
|
||||
items={columnOrder}
|
||||
setItems={setColumnOrder}
|
||||
renderItem={(item) => {
|
||||
const column = table
|
||||
.getAllLeafColumns()
|
||||
.find((col) => col.id === item.id);
|
||||
return (
|
||||
<DataDragAndDrop.Item
|
||||
id={column.id}
|
||||
index={index}
|
||||
key={column.id}
|
||||
<Switch
|
||||
key={item.id}
|
||||
size="small"
|
||||
checked={column?.getIsVisible()}
|
||||
onChange={(event) => {
|
||||
const handler =
|
||||
column?.getToggleVisibilityHandler();
|
||||
handler?.(event);
|
||||
}}
|
||||
>
|
||||
<Switch
|
||||
key={column.id}
|
||||
size="small"
|
||||
checked={column.getIsVisible()}
|
||||
onChange={(event) => {
|
||||
const handler =
|
||||
column.getToggleVisibilityHandler();
|
||||
handler(event);
|
||||
}}
|
||||
>
|
||||
{column.id}
|
||||
</Switch>
|
||||
</DataDragAndDrop.Item>
|
||||
{item.id}
|
||||
</Switch>
|
||||
);
|
||||
})}
|
||||
</DataDragAndDrop>
|
||||
}}
|
||||
/>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
@@ -216,28 +211,22 @@ export const KitchenSink: Story = {
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
<DataTableLegacy
|
||||
rowDensity={rowDensity}
|
||||
zebraStripes={zebraStripes}
|
||||
truncateContent={truncateContent}
|
||||
withKeyboardNav
|
||||
>
|
||||
<DataTable.Thead>
|
||||
<DataTableLegacy.Thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => {
|
||||
return (
|
||||
<DataTable.Tr key={headerGroup.id}>
|
||||
<DataTableLegacy.Tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<DataTableColumnHeader
|
||||
key={header.id}
|
||||
style={{ width: `var(--header-${header.id}-size)` }}
|
||||
defaultWidth={header.getSize()}
|
||||
/* pinningHandler={
|
||||
header.column.getIsPinned() === "left"
|
||||
? () => header.column.pin(false)
|
||||
: () => header.column.pin("left")
|
||||
}
|
||||
isPinned={header.column.getIsPinned() === "left"} */
|
||||
sortable
|
||||
sortDirection={header.column.getIsSorted() || "none"}
|
||||
onSortClick={(event) => {
|
||||
@@ -245,6 +234,9 @@ export const KitchenSink: Story = {
|
||||
header.column.getToggleSortingHandler();
|
||||
handler?.(event);
|
||||
}}
|
||||
label={(
|
||||
header.column.columnDef.header || ""
|
||||
).toString()}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
@@ -255,32 +247,32 @@ export const KitchenSink: Story = {
|
||||
</DataTableColumnHeader>
|
||||
);
|
||||
})}
|
||||
</DataTable.Tr>
|
||||
</DataTableLegacy.Tr>
|
||||
);
|
||||
})}
|
||||
</DataTable.Thead>
|
||||
</DataTableLegacy.Thead>
|
||||
|
||||
<MemoizedTableBody table={table} />
|
||||
|
||||
{table.getRowModel().rows.length > 0 && (
|
||||
<DataTable.Tfoot>
|
||||
<DataTableLegacy.Tfoot>
|
||||
{table.getFooterGroups().map((footerGroup) => (
|
||||
<DataTable.Tr key={footerGroup.id}>
|
||||
<DataTableLegacy.Tr key={footerGroup.id}>
|
||||
{footerGroup.headers.map((header) => (
|
||||
<DataTable.Td key={header.id}>
|
||||
<DataTableLegacy.Td key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.footer,
|
||||
header.getContext(),
|
||||
)}
|
||||
</DataTable.Td>
|
||||
</DataTableLegacy.Td>
|
||||
))}
|
||||
</DataTable.Tr>
|
||||
</DataTableLegacy.Tr>
|
||||
))}
|
||||
</DataTable.Tfoot>
|
||||
</DataTableLegacy.Tfoot>
|
||||
)}
|
||||
</DataTable>
|
||||
</DataTableLegacy>
|
||||
</VStack>
|
||||
);
|
||||
},
|
||||
@@ -289,7 +281,7 @@ export const KitchenSink: Story = {
|
||||
controls: { disable: true },
|
||||
docs: { disable: true },
|
||||
},
|
||||
};
|
||||
}; */
|
||||
|
||||
const dayJobValues = [
|
||||
"Jedi Knight",
|
||||
@@ -344,53 +336,64 @@ const filterOptions = [
|
||||
propertyKey: "homeSystem",
|
||||
value: v,
|
||||
})),
|
||||
...Array.from(new Set(sampleData.map((row) => row.supervisor))).map(
|
||||
...Array.from(new Set(TEST_DATA.map((row) => row.supervisor))).map(
|
||||
(value) => ({
|
||||
propertyKey: "supervisor",
|
||||
value,
|
||||
}),
|
||||
),
|
||||
...Array.from(new Set(sampleData.map((row) => row.nationalId))).map(
|
||||
...Array.from(new Set(TEST_DATA.map((row) => row.nationalId))).map(
|
||||
(value) => ({
|
||||
propertyKey: "nationalId",
|
||||
value,
|
||||
}),
|
||||
),
|
||||
...Array.from(new Set(sampleData.map((row) => row.name))).map((value) => ({
|
||||
...Array.from(new Set(TEST_DATA.map((row) => row.name))).map((value) => ({
|
||||
propertyKey: "name",
|
||||
value,
|
||||
})),
|
||||
];
|
||||
|
||||
const allColumnIds = columnDef_TEST_DATA.map((col) => col.id);
|
||||
|
||||
export const KitchenSinkAdvancedFilter: Story = {
|
||||
render: () => {
|
||||
const [rowDensity, setRowDensity] = React.useState<
|
||||
"normal" | "condensed" | "spacious"
|
||||
>("normal");
|
||||
const [textSize, setTextSize] = React.useState<"small" | "medium">(
|
||||
"medium",
|
||||
);
|
||||
const [zebraStripes, setZebraStripes] = React.useState(false);
|
||||
const [truncateContent, setTruncateContent] = React.useState(true);
|
||||
const [columnOrder, setColumnOrder] = React.useState<string[]>(
|
||||
columns.map((col) => col.accessorKey!),
|
||||
);
|
||||
const [columnView, setColumnView] = React.useState(columnDef_TEST_DATA);
|
||||
const [visibleColumns, setVisibleColumns] =
|
||||
useState<string[]>(allColumnIds);
|
||||
|
||||
const [stickyColumns, setStickyColumns] = React.useState<{
|
||||
first: "none" | "first";
|
||||
last: "none" | "last";
|
||||
}>({
|
||||
first: "none",
|
||||
last: "none",
|
||||
});
|
||||
const [showDetailsPanel, setShowDetailsPanel] = useState(false);
|
||||
const [showNestedRows, setNestedRows] = useState(false);
|
||||
|
||||
const [query, setQuery] = useState<ExternalQuery>({
|
||||
tokens: [],
|
||||
operation: "and",
|
||||
});
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [selectionMode, setSelectionMode] = useState<"single" | "multiple">(
|
||||
"multiple",
|
||||
);
|
||||
|
||||
console.log(rowSelection);
|
||||
const [selectionMode, setSelectionMode] =
|
||||
useState<SelectionProps["selectionMode"]>("none");
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (query.tokens.length === 0) {
|
||||
return sampleData;
|
||||
return showNestedRows ? TEST_DATA_NESTED : TEST_DATA;
|
||||
}
|
||||
|
||||
return sampleData.filter((row) => {
|
||||
return (showNestedRows ? TEST_DATA_NESTED : TEST_DATA).filter((row) => {
|
||||
const results = query.tokens.map((token) => {
|
||||
const value = row[token.propertyKey as keyof PersonInfo];
|
||||
if (token.operator === "=") {
|
||||
@@ -426,10 +429,10 @@ export const KitchenSinkAdvancedFilter: Story = {
|
||||
? results.every(Boolean)
|
||||
: results.some(Boolean);
|
||||
});
|
||||
}, [query]);
|
||||
}, [query.operation, query.tokens, showNestedRows]);
|
||||
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
/* const table = useReactTable({
|
||||
columns: [{}],
|
||||
data: filteredData,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
@@ -454,10 +457,12 @@ export const KitchenSinkAdvancedFilter: Story = {
|
||||
debugTable: false,
|
||||
debugHeaders: false,
|
||||
debugColumns: false,
|
||||
});
|
||||
}); */
|
||||
|
||||
const pagedData = usePaginatedData(filteredData);
|
||||
|
||||
return (
|
||||
<VStack gap="space-16">
|
||||
<VStack gap="space-16" maxHeight="100vh" padding="space-24">
|
||||
<DataToolbar
|
||||
renderInput={
|
||||
<TokenFilter
|
||||
@@ -474,19 +479,19 @@ export const KitchenSinkAdvancedFilter: Story = {
|
||||
variant="tertiary"
|
||||
icon={<ChevronLeftIcon aria-hidden />}
|
||||
data-color="neutral"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
onClick={() => pagedData.setPageIndex(pagedData.pageIndex - 1)}
|
||||
disabled={!pagedData.canPreviousPage}
|
||||
/>
|
||||
<BodyShort as="div" style={{ whiteSpace: "pre" }} size="small">
|
||||
{`Side ${table.getState().pagination.pageIndex + 1} av ${table.getPageCount()}`}
|
||||
{`Side ${pagedData.pageIndex + 1} av ${pagedData.pageCount}`}
|
||||
</BodyShort>
|
||||
<Button
|
||||
size="small"
|
||||
variant="tertiary"
|
||||
icon={<ChevronRightIcon aria-hidden />}
|
||||
data-color="neutral"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
onClick={() => pagedData.setPageIndex(pagedData.pageIndex + 1)}
|
||||
disabled={!pagedData.canNextPage}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
@@ -511,7 +516,7 @@ export const KitchenSinkAdvancedFilter: Story = {
|
||||
</Dialog.Header>
|
||||
<Dialog.Body>
|
||||
<HStack gap="space-32" paddingBlock="space-0 space-32">
|
||||
<VStack gap="space-36" maxWidth="50%">
|
||||
<VStack gap="space-24" maxWidth="50%">
|
||||
<RadioGroup
|
||||
legend="Velg radtetthet"
|
||||
onChange={setRowDensity}
|
||||
@@ -522,6 +527,15 @@ export const KitchenSinkAdvancedFilter: Story = {
|
||||
<Radio value="normal">Normal</Radio>
|
||||
<Radio value="spacious">Løs</Radio>
|
||||
</RadioGroup>
|
||||
<RadioGroup
|
||||
legend="Velg tekststørrelse"
|
||||
onChange={setTextSize}
|
||||
size="small"
|
||||
value={textSize}
|
||||
>
|
||||
<Radio value="small">Liten</Radio>
|
||||
<Radio value="medium">Medium</Radio>
|
||||
</RadioGroup>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={truncateContent}
|
||||
@@ -538,57 +552,117 @@ export const KitchenSinkAdvancedFilter: Story = {
|
||||
>
|
||||
Stripede rader
|
||||
</Switch>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={selectionMode === "multiple"}
|
||||
onChangeCapture={(e) =>
|
||||
setSelectionMode(
|
||||
e.target.checked ? "multiple" : "single",
|
||||
)
|
||||
<RadioGroup
|
||||
legend="Sticky første kolonne"
|
||||
onChange={(value) =>
|
||||
setStickyColumns((prev) => ({
|
||||
...prev,
|
||||
first: value as "none" | "first",
|
||||
}))
|
||||
}
|
||||
description="Slår på flervalg"
|
||||
size="small"
|
||||
value={stickyColumns.first}
|
||||
>
|
||||
Flervalg
|
||||
</Switch>
|
||||
<Radio value="none">Ingen</Radio>
|
||||
<Radio value="first">Første kolonne</Radio>
|
||||
</RadioGroup>
|
||||
<RadioGroup
|
||||
legend="Sticky siste kolonne"
|
||||
onChange={(value) =>
|
||||
setStickyColumns((prev) => ({
|
||||
...prev,
|
||||
last: value as "none" | "last",
|
||||
}))
|
||||
}
|
||||
size="small"
|
||||
value={stickyColumns.last}
|
||||
>
|
||||
<Radio value="none">Ingen</Radio>
|
||||
<Radio value="last">Siste kolonne</Radio>
|
||||
</RadioGroup>
|
||||
<VStack gap="space-8">
|
||||
<Heading size="xsmall" spacing>
|
||||
Demo innstillinger
|
||||
</Heading>
|
||||
<Select
|
||||
label="Radvalg"
|
||||
size="small"
|
||||
value={selectionMode}
|
||||
onChange={(e) =>
|
||||
setSelectionMode(
|
||||
e.target.value as SelectionProps["selectionMode"],
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="none">Ingen</option>
|
||||
<option value="single">Enkeltvalg</option>
|
||||
<option value="multiple">Flervalg</option>
|
||||
</Select>
|
||||
<Checkbox
|
||||
onChange={(e) =>
|
||||
setShowDetailsPanel(e.target.checked)
|
||||
}
|
||||
size="small"
|
||||
checked={showDetailsPanel}
|
||||
>
|
||||
Vis detaljer
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
onChange={(e) => {
|
||||
setNestedRows(e.target.checked);
|
||||
setColumnView(
|
||||
e.target.checked
|
||||
? columnDef_TEST_DATA_NESTED
|
||||
: columnDef_TEST_DATA,
|
||||
);
|
||||
}}
|
||||
size="small"
|
||||
checked={showNestedRows}
|
||||
>
|
||||
Vis nestede rader
|
||||
</Checkbox>
|
||||
</VStack>
|
||||
</VStack>
|
||||
<VStack maxWidth="50%" gap="space-16">
|
||||
<BodyShort weight="semibold">Vis kolonner</BodyShort>
|
||||
<VStack gap="space-8">
|
||||
<Switch
|
||||
checked={table.getIsAllColumnsVisible()}
|
||||
checked={
|
||||
visibleColumns.length === allColumnIds.length
|
||||
}
|
||||
size="small"
|
||||
onChange={(event) => {
|
||||
const handler =
|
||||
table.getToggleAllColumnsVisibilityHandler();
|
||||
handler?.(event);
|
||||
onChange={() => {
|
||||
const allVisible =
|
||||
visibleColumns.length === allColumnIds.length;
|
||||
setVisibleColumns(allVisible ? [] : allColumnIds);
|
||||
}}
|
||||
>
|
||||
Velg alle
|
||||
</Switch>
|
||||
<DataDragAndDrop setItems={setColumnOrder}>
|
||||
{table.getAllLeafColumns().map((column, index) => {
|
||||
<DragAndDrop
|
||||
items={columnView}
|
||||
setItems={setColumnView}
|
||||
renderItem={(item) => {
|
||||
return (
|
||||
<DataDragAndDrop.Item
|
||||
id={column.id}
|
||||
index={index}
|
||||
key={column.id}
|
||||
<Switch
|
||||
key={item.id}
|
||||
size="small"
|
||||
checked={visibleColumns.includes(item.id)}
|
||||
onChange={(event) => {
|
||||
const isChecked = event.target.checked;
|
||||
setVisibleColumns((prev) => {
|
||||
if (isChecked) {
|
||||
return [...prev, item.id];
|
||||
}
|
||||
return prev.filter((id) => id !== item.id);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Switch
|
||||
key={column.id}
|
||||
size="small"
|
||||
checked={column.getIsVisible()}
|
||||
onChange={(event) => {
|
||||
const handler =
|
||||
column.getToggleVisibilityHandler();
|
||||
handler(event);
|
||||
}}
|
||||
>
|
||||
{column.id}
|
||||
</Switch>
|
||||
</DataDragAndDrop.Item>
|
||||
{item.label}
|
||||
</Switch>
|
||||
);
|
||||
})}
|
||||
</DataDragAndDrop>
|
||||
}}
|
||||
/>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
@@ -598,85 +672,40 @@ export const KitchenSinkAdvancedFilter: Story = {
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
rowDensity={rowDensity}
|
||||
zebraStripes={zebraStripes}
|
||||
truncateContent={truncateContent}
|
||||
withKeyboardNav
|
||||
>
|
||||
<DataTable.Thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => {
|
||||
return (
|
||||
<DataTable.Tr key={headerGroup.id}>
|
||||
{table.options.enableMultiRowSelection ? (
|
||||
<DataTableColumnHeader textAlign="center" width="64px">
|
||||
<CheckboxInput
|
||||
compact
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
indeterminate={table.getIsSomePageRowsSelected()}
|
||||
onChange={table.getToggleAllPageRowsSelectedHandler()}
|
||||
aria-label="Velg alle rader"
|
||||
/>
|
||||
</DataTableColumnHeader>
|
||||
) : (
|
||||
<DataTableColumnHeader textAlign="center" width="64px" />
|
||||
)}
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<DataTableColumnHeader
|
||||
key={header.id}
|
||||
style={{ width: `var(--header-${header.id}-size)` }}
|
||||
defaultWidth={header.getSize()}
|
||||
/* pinningHandler={
|
||||
header.column.getIsPinned() === "left"
|
||||
? () => header.column.pin(false)
|
||||
: () => header.column.pin("left")
|
||||
}
|
||||
isPinned={header.column.getIsPinned() === "left"} */
|
||||
sortable
|
||||
sortDirection={header.column.getIsSorted() || "none"}
|
||||
onSortClick={(event) => {
|
||||
const handler =
|
||||
header.column.getToggleSortingHandler();
|
||||
handler?.(event);
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</DataTableColumnHeader>
|
||||
);
|
||||
})}
|
||||
</DataTable.Tr>
|
||||
);
|
||||
})}
|
||||
</DataTable.Thead>
|
||||
|
||||
<MemoizedTableBody table={table} />
|
||||
|
||||
{table.getRowModel().rows.length > 0 && (
|
||||
<DataTable.Tfoot>
|
||||
{table.getFooterGroups().map((footerGroup) => (
|
||||
<DataTable.Tr key={footerGroup.id}>
|
||||
<DataTable.Td />
|
||||
{footerGroup.headers.map((header) => (
|
||||
<DataTable.Td key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.footer,
|
||||
header.getContext(),
|
||||
)}
|
||||
</DataTable.Td>
|
||||
))}
|
||||
</DataTable.Tr>
|
||||
))}
|
||||
</DataTable.Tfoot>
|
||||
<DataGrid
|
||||
getRowId={(row) => row.name}
|
||||
columnDefinitions={columnView.filter((col) =>
|
||||
visibleColumns.find((c) => c === col.id),
|
||||
)}
|
||||
</DataTable>
|
||||
data={pagedData.paginatedData}
|
||||
>
|
||||
<DataTable<(typeof TEST_DATA)[number]>
|
||||
rowDensity={rowDensity}
|
||||
zebraStripes={zebraStripes}
|
||||
textSize={textSize}
|
||||
truncateContent={truncateContent}
|
||||
withKeyboardNav
|
||||
selection={{
|
||||
selectionMode,
|
||||
}}
|
||||
stickyHeader
|
||||
stickyColumns={{
|
||||
start: stickyColumns.first === "first" ? "1" : undefined,
|
||||
end: stickyColumns.last === "last" ? "1" : undefined,
|
||||
}}
|
||||
detailsPanel={
|
||||
showDetailsPanel
|
||||
? { getContent: (rowData) => <DetailsPanel row={rowData} /> }
|
||||
: undefined
|
||||
}
|
||||
subRows={{
|
||||
/* @ts-expect-error Test-data just hacked together now */
|
||||
getRows: showNestedRows
|
||||
? (rowData) => rowData.nestedRows
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</DataGrid>
|
||||
</VStack>
|
||||
);
|
||||
},
|
||||
@@ -684,31 +713,69 @@ export const KitchenSinkAdvancedFilter: Story = {
|
||||
a11y: { disable: true },
|
||||
controls: { disable: true },
|
||||
docs: { disable: true },
|
||||
layout: "fullscreen",
|
||||
},
|
||||
};
|
||||
|
||||
const TableBody = ({ table }: { table: Table<PersonInfo> }) => {
|
||||
function DetailsPanel({ row }: { row: PersonInfo }) {
|
||||
return (
|
||||
<VStack gap="space-16" padding="space-16">
|
||||
<Heading size="small">{row.name}</Heading>
|
||||
<BodyShort>National ID: {row.nationalId}</BodyShort>
|
||||
<BodyShort>Day job: {row.dayJob}</BodyShort>
|
||||
<BodyShort>Supervisor: {row.supervisor}</BodyShort>
|
||||
<BodyShort>Home system: {row.homeSystem}</BodyShort>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
function usePaginatedData<T extends any[]>(data: T) {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = pageIndex * pageSize;
|
||||
return data.slice(start, start + pageSize);
|
||||
}, [data, pageIndex]);
|
||||
|
||||
const pageCount = Math.ceil(data.length / pageSize);
|
||||
|
||||
const canNextPage = pageIndex < pageCount - 1;
|
||||
const canPreviousPage = pageIndex > 0;
|
||||
|
||||
return {
|
||||
paginatedData,
|
||||
pageIndex,
|
||||
setPageIndex,
|
||||
canNextPage,
|
||||
canPreviousPage,
|
||||
pageCount,
|
||||
};
|
||||
}
|
||||
|
||||
/* const TableBody = ({ table }: { table: Table<PersonInfo> }) => {
|
||||
const hasRowSelection = table.options.enableRowSelection;
|
||||
const multiRowSelection = table.options.enableMultiRowSelection;
|
||||
|
||||
if (table.getRowModel().rows.length === 0) {
|
||||
return (
|
||||
<DataTable.Tbody>
|
||||
<DataTable.EmptyState>
|
||||
<DataTableLegacy.Tbody>
|
||||
<DataTableLegacy.EmptyState>
|
||||
<div>No data available</div>
|
||||
<Button size="small">Create data</Button>
|
||||
</DataTable.EmptyState>
|
||||
</DataTable.Tbody>
|
||||
</DataTableLegacy.EmptyState>
|
||||
</DataTableLegacy.Tbody>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable.Tbody>
|
||||
<DataTableLegacy.Tbody>
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
return (
|
||||
<DataTable.Tr key={row.id} selected={row.getIsSelected()}>
|
||||
<DataTableLegacy.Tr key={row.id} selected={row.getIsSelected()}>
|
||||
{hasRowSelection && (
|
||||
<DataTable.Td textAlign="center" UNSAFE_isSelection>
|
||||
<DataTableLegacy.Td textAlign="center" cellType="action">
|
||||
{multiRowSelection ? (
|
||||
<CheckboxInput
|
||||
compact
|
||||
@@ -726,23 +793,23 @@ const TableBody = ({ table }: { table: Table<PersonInfo> }) => {
|
||||
aria-label={`Velg rad ${row.id}`}
|
||||
/>
|
||||
)}
|
||||
</DataTable.Td>
|
||||
</DataTableLegacy.Td>
|
||||
)}
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
return (
|
||||
<DataTable.Td key={cell.id}>
|
||||
<DataTableLegacy.Td key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataTable.Td>
|
||||
</DataTableLegacy.Td>
|
||||
);
|
||||
})}
|
||||
</DataTable.Tr>
|
||||
</DataTableLegacy.Tr>
|
||||
);
|
||||
})}
|
||||
</DataTable.Tbody>
|
||||
</DataTableLegacy.Tbody>
|
||||
);
|
||||
};
|
||||
|
||||
}; */
|
||||
/*
|
||||
const MemoizedTableBody = React.memo(
|
||||
TableBody,
|
||||
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn,
|
||||
) as typeof TableBody;
|
||||
) as typeof TableBody; */
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,227 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import { DragVerticalIcon } from "@navikt/aksel-icons";
|
||||
import { Switch } from "../../form/switch";
|
||||
import { HStack } from "../../primitives/stack";
|
||||
import { Floating } from "../../utils/components/floating/Floating";
|
||||
import DragAndDrop from "../drag-and-drop-old/root/DataDragAndDropRoot";
|
||||
|
||||
const meta: Meta<typeof DragAndDrop> = {
|
||||
title: "ds-react/Data/OldDragAndDrop",
|
||||
component: DragAndDrop,
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
layout: "padded",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DragAndDrop>;
|
||||
|
||||
export const Dndkit: Story = {
|
||||
render: () => {
|
||||
const [items, setItems] = React.useState([
|
||||
{ id: "id", content: "Id" },
|
||||
{ id: "name", content: "Name" },
|
||||
{ id: "nationality", content: "Nationality" },
|
||||
{ id: "dayJob", content: "Day job" },
|
||||
{ id: "supervisor", content: "Supervisor" },
|
||||
{ id: "dateReceived", content: "Date received" },
|
||||
{ id: "message", content: "Message" },
|
||||
{ id: "age", content: "Age" },
|
||||
{ id: "forceSensitive", content: "Force sensitive" },
|
||||
{ id: "homeSystem", content: "Home system" },
|
||||
{ id: "skills", content: "Skills" },
|
||||
]);
|
||||
|
||||
return (
|
||||
<DragAndDrop setItems={setItems}>
|
||||
{items.map((item, index) => (
|
||||
<DragAndDrop.Item key={item.id} id={item.id} index={index}>
|
||||
<Switch size="small" defaultChecked>
|
||||
{item.content}
|
||||
</Switch>
|
||||
</DragAndDrop.Item>
|
||||
))}
|
||||
</DragAndDrop>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const NativeHTMLDragAndDrop: Story = {
|
||||
render: () => {
|
||||
const [items, setItems] = React.useState([
|
||||
{ id: "id", content: "Id" },
|
||||
{ id: "name", content: "Name" },
|
||||
{ id: "nationality", content: "Nationality" },
|
||||
{ id: "dayJob", content: "Day job" },
|
||||
{ id: "supervisor", content: "Supervisor" },
|
||||
{ id: "dateReceived", content: "Date received" },
|
||||
{ id: "message", content: "Message" },
|
||||
{ id: "age", content: "Age" },
|
||||
{ id: "forceSensitive", content: "Force sensitive" },
|
||||
{ id: "homeSystem", content: "Home system" },
|
||||
{ id: "skills", content: "Skills" },
|
||||
]);
|
||||
type Elem = { id: string; index: number };
|
||||
const [draggingElem, setDraggingElem] = React.useState<Elem | null>(null);
|
||||
const [dropTarget, setDropTarget] = React.useState<Elem | null>(null);
|
||||
|
||||
function handleDragStart(event, item, index) {
|
||||
setDraggingElem({ id: item.id, index });
|
||||
event.dataTransfer.setData("text/plain", item.id);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
|
||||
const preview = document.createElement("div");
|
||||
preview.innerHTML = `
|
||||
<div style="
|
||||
padding: 12px 16px;
|
||||
background: white;
|
||||
width: 100%;
|
||||
">
|
||||
⠿ ${item.content}
|
||||
</div>
|
||||
`;
|
||||
|
||||
preview.style.position = "absolute";
|
||||
preview.style.top = "-9999px";
|
||||
preview.style.left = "-9999px";
|
||||
|
||||
document.body.appendChild(preview);
|
||||
event.dataTransfer.setDragImage(preview, 12, 12);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
document.body.removeChild(preview);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map((item, index) => {
|
||||
return (
|
||||
<HStack key={item.id} gap="space-8" asChild>
|
||||
{/** biome-ignore lint/a11y/noStaticElementInteractions: TEMP */}
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
padding: "8px",
|
||||
marginBottom: "4px",
|
||||
width: "200px",
|
||||
backgroundColor:
|
||||
dropTarget?.id === item.id ? "lightblue" : "white",
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDropTarget({ id: item.id, index });
|
||||
}}
|
||||
onDrop={() => {
|
||||
if (draggingElem && dropTarget) {
|
||||
const newItems = [...items];
|
||||
const [removed] = newItems.splice(draggingElem.index, 1);
|
||||
newItems.splice(dropTarget.index, 0, removed);
|
||||
setItems(newItems);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/** biome-ignore lint/a11y/noStaticElementInteractions: TEMP */}
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, item, index)}
|
||||
onDragEnd={() => {
|
||||
setDraggingElem(null);
|
||||
setDropTarget(null);
|
||||
}}
|
||||
>
|
||||
<DragVerticalIcon
|
||||
aria-hidden
|
||||
title="Dra for å flytte"
|
||||
fontSize="1.5rem"
|
||||
/>
|
||||
</div>
|
||||
{item.content}
|
||||
</div>
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const _DNDAnchor = () => {
|
||||
const events = (src: string) => {
|
||||
return {
|
||||
onPointerMove: () => console.info(`${src} onPointerMove`),
|
||||
style: {
|
||||
paddibng: "1rem",
|
||||
border: "1px solid black",
|
||||
marginBottom: "0.5rem",
|
||||
cursor: "move",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const [virtualRef, setVirtualRef] = React.useState({
|
||||
getBoundingClientRect: () =>
|
||||
DOMRect.fromRect({ width: 0, height: 0, x: 0, y: 0 }),
|
||||
});
|
||||
|
||||
const handleOpen = (event: React.MouseEvent | React.PointerEvent) => {
|
||||
setVirtualRef({
|
||||
getBoundingClientRect: () =>
|
||||
DOMRect.fromRect({
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
}),
|
||||
});
|
||||
setOpen(true);
|
||||
|
||||
function moveListener(_event: PointerEvent) {
|
||||
setVirtualRef({
|
||||
getBoundingClientRect: () =>
|
||||
DOMRect.fromRect({
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: _event.clientX,
|
||||
y: _event.clientY,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("pointermove", moveListener);
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
window.removeEventListener("pointermove", moveListener);
|
||||
};
|
||||
|
||||
window.addEventListener("pointerup", handleClose, { once: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onPointerDown={handleOpen}>Open</button>
|
||||
<div {...events("One")}>Element 1</div>
|
||||
{open && (
|
||||
<div>
|
||||
<Floating>
|
||||
<Floating.Anchor virtualRef={virtualRef}>
|
||||
<span />
|
||||
</Floating.Anchor>
|
||||
<Floating.Content>
|
||||
<div style={{ ...events("one").style }}>Element 1 floating</div>
|
||||
</Floating.Content>
|
||||
</Floating>
|
||||
</div>
|
||||
)}
|
||||
<div {...events("Two")}>Element 2</div>
|
||||
<div {...events("Three")}>Element 3</div>
|
||||
<div {...events("Four")}>Element 4</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,248 +0,0 @@
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import React, { useState } from "react";
|
||||
import { DataTable } from ".";
|
||||
import { Checkbox } from "../../form/checkbox";
|
||||
import { DataTableColumnHeader } from "./column-header/DataTableColumnHeader";
|
||||
|
||||
type SortDirection = "asc" | "desc" | "none";
|
||||
|
||||
const meta: Meta<typeof DataTable> = {
|
||||
title: "ds-react/Data/DataTable",
|
||||
component: DataTable,
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
};
|
||||
|
||||
type Story = StoryObj<typeof DataTable>;
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (props) => (
|
||||
<DataTable
|
||||
rowDensity={props.rowDensity}
|
||||
style={{ width: "500px" }}
|
||||
zebraStripes={props.zebraStripes}
|
||||
truncateContent={props.truncateContent}
|
||||
>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Header 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Header 2</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Data 1</DataTable.Td>
|
||||
<DataTable.Td>Data 2</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Data 1</DataTable.Td>
|
||||
<DataTable.Td>Data 2</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Data 1</DataTable.Td>
|
||||
<DataTable.Td>
|
||||
Very long content that should be truncated
|
||||
</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
),
|
||||
args: {
|
||||
rowDensity: "normal",
|
||||
zebraStripes: false,
|
||||
truncateContent: true,
|
||||
},
|
||||
argTypes: {
|
||||
rowDensity: {
|
||||
control: { type: "radio" },
|
||||
options: ["condensed", "normal", "spacious"],
|
||||
defaultValue: "normal",
|
||||
},
|
||||
zebraStripes: {
|
||||
control: { type: "boolean" },
|
||||
},
|
||||
truncateContent: {
|
||||
control: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Selected: Story = {
|
||||
render: () => {
|
||||
const useToggleList = (initialState) => {
|
||||
const [list, setList] = useState(initialState);
|
||||
|
||||
return [
|
||||
list,
|
||||
(value) =>
|
||||
setList((oldList) =>
|
||||
oldList.includes(value)
|
||||
? oldList.filter((id) => id !== value)
|
||||
: [...oldList, value],
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
const [selectedRows, toggleSelectedRow] = useToggleList(["2"]);
|
||||
|
||||
return (
|
||||
<DataTable style={{ width: "500px" }}>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>
|
||||
<Checkbox
|
||||
size="small"
|
||||
indeterminate={
|
||||
selectedRows.length > 0 && selectedRows.length < 3
|
||||
}
|
||||
onChange={() => {
|
||||
if (selectedRows.length === 3) {
|
||||
selectedRows.forEach((id) => {
|
||||
toggleSelectedRow(id);
|
||||
});
|
||||
} else {
|
||||
["1", "2", "3"].forEach((id) => {
|
||||
if (!selectedRows.includes(id)) {
|
||||
toggleSelectedRow(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Velg alle
|
||||
</Checkbox>
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Header 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Header 2</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr selected={selectedRows.includes("1")}>
|
||||
<DataTable.Td>
|
||||
<Checkbox
|
||||
hideLabel
|
||||
size="small"
|
||||
checked={selectedRows.includes("1")}
|
||||
onChange={() => toggleSelectedRow("1")}
|
||||
>
|
||||
{" "}
|
||||
</Checkbox>
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>Data 1</DataTable.Td>
|
||||
<DataTable.Td>Data 2</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr selected={selectedRows.includes("2")}>
|
||||
<DataTable.Td>
|
||||
<Checkbox
|
||||
hideLabel
|
||||
size="small"
|
||||
checked={selectedRows.includes("2")}
|
||||
onChange={() => toggleSelectedRow("2")}
|
||||
aria-labelledby="test"
|
||||
>
|
||||
{" "}
|
||||
</Checkbox>
|
||||
</DataTable.Td>
|
||||
<DataTable.Td id="test">Data 1</DataTable.Td>
|
||||
<DataTable.Td>Data 2</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr selected={selectedRows.includes("3")}>
|
||||
<DataTable.Td>
|
||||
<Checkbox
|
||||
hideLabel
|
||||
size="small"
|
||||
checked={selectedRows.includes("3")}
|
||||
onChange={() => toggleSelectedRow("3")}
|
||||
>
|
||||
{" "}
|
||||
</Checkbox>
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>Data 1</DataTable.Td>
|
||||
<DataTable.Td>Data 2</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const SORT_CYCLE: Record<SortDirection, SortDirection> = {
|
||||
none: "asc",
|
||||
asc: "desc",
|
||||
desc: "none",
|
||||
};
|
||||
|
||||
const DATA = [
|
||||
{ name: "Arne", age: 42, city: "Oslo" },
|
||||
{ name: "Bjørg", age: 31, city: "Bergen" },
|
||||
{ name: "Carl", age: 55, city: "Trondheim" },
|
||||
{ name: "Diana", age: 28, city: "Stavanger" },
|
||||
];
|
||||
|
||||
export const Sortable: Story = {
|
||||
render: () => {
|
||||
const [sortKey, setSortKey] = useState<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<SortDirection>("none");
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
if (sortKey === key) {
|
||||
const next = SORT_CYCLE[sortDir];
|
||||
setSortDir(next);
|
||||
if (next === "none") setSortKey(null);
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const sortedData = [...DATA].sort((a, b) => {
|
||||
if (!sortKey || sortDir === "none") return 0;
|
||||
const aVal = a[sortKey];
|
||||
const bVal = b[sortKey];
|
||||
const cmp =
|
||||
typeof aVal === "number"
|
||||
? aVal - bVal
|
||||
: String(aVal).localeCompare(String(bVal));
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
|
||||
const dirFor = (key: string): SortDirection =>
|
||||
sortKey === key ? sortDir : "none";
|
||||
|
||||
return (
|
||||
<DataTable style={{ width: "500px" }}>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader
|
||||
sortable
|
||||
sortDirection={dirFor("name")}
|
||||
onSortClick={() => handleSort("name")}
|
||||
>
|
||||
Navn
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader
|
||||
sortable
|
||||
sortDirection={dirFor("age")}
|
||||
onSortClick={() => handleSort("age")}
|
||||
>
|
||||
Alder
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>By</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
{sortedData.map((row) => (
|
||||
<DataTable.Tr key={row.name}>
|
||||
<DataTable.Td>{row.name}</DataTable.Td>
|
||||
<DataTable.Td>{row.age}</DataTable.Td>
|
||||
<DataTable.Td>{row.city}</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
))}
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
# DataTable Beta Readiness Review
|
||||
|
||||
## Summary
|
||||
|
||||
The DataTable is a well-architected component with thoughtful separation of concerns (hooks, helpers, sub-components). The keyboard navigation, selection cascading, and sort state management are solid. However, there are several a11y gaps, API inconsistencies, and edge cases that should be addressed before a public beta.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
| # | Category | Finding | Severity | Recommendation |
|
||||
| --- | --------------- | ------------------------------------------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 2 | A11y | No `aria-selected` on selected rows | 🔴 | Add `aria-selected="true"` on `<tr>` for selected rows (required by grid/table selection pattern per ARIA APG). |
|
||||
| 3 | A11y | No `aria-label`/`aria-labelledby` on the table element | 🟡 | Expose a `label` or `aria-label` prop. Without it, screen readers announce the table generically. React Aria requires a label on every table. |
|
||||
| 4 | A11y | Hardcoded Norwegian strings without i18n support | 🟡 | All aria-labels ("Vis under-rader", "Skjul detaljer", "Velg alle synlige rader", "Laster innhold", resize labels) are Norwegian-only. Expose a `labels`/`translations` prop or accept them via column definitions for i18n. |
|
||||
| 5 | A11y | Sub-rows should use `role="treegrid"` | 🟡 | When `subRows` is provided, the table should use `role="treegrid"` with `aria-level`, `aria-setsize`, `aria-posinset` on rows. Already flagged as TODO in code. |
|
||||
| 7 | API | `disableRowSelection` signature inconsistency with stories | 🔴 | Type declares `({ row, id }) => boolean` but stories pass `({ id }) => id === 2`. The prop name is confusing: "disable" + returns `true` to disable = double negative. Consider `isRowSelectable` (positive predicate) like React Aria's `disabledKeys` pattern. |
|
||||
| 8 | API | `stickyColumns` type is overly restrictive | 🟡 | `first?: "1"` and `last?: "1"` prevent future extension to 2+ sticky columns. Use `number` instead. |
|
||||
| 9 | API | `sort` and `onSortChange` mixed into `DataTableProps` via `TableSortOptions` | 🟡 | Spreading sort props at root level makes the API flat but could collide with native HTML attributes or future features. Consider grouping into a `sort={{ state, onChange, defaultState }}` pattern. However, this is a style preference — the flat API is also valid. |
|
||||
| 10 | API | `children?: never` prevents slot-based composition | 🟢 | The data-driven API is good for beta, but consider a path to compositional usage (render props or slot API) for GA. MUI and TanStack offer both modes. |
|
||||
| 11 | API | Missing `aria-rowcount`/`aria-colcount` for virtualized/paginated tables | 🟡 | When data is paginated (as in `SelectionPagination` story), ATs cannot know total row count. Expose `totalRowCount` prop that maps to `aria-rowcount`. |
|
||||
| 12 | Code Quality | `selectableIdsSet` in `getMultipleSelectProps` iterates all rows including sub-rows | 🟡 | "Select all" selects ALL rows in `itemDetails` including hidden sub-rows. The `selectableIdsSet` should filter by `visibleRowIds` for the "select all" checkbox, while still cascading to children when selecting individual parent rows. Variable naming is misleading. |
|
||||
| 13 | Code Quality | `useTableSelection` passes `tableItems` (full object) to `getMultipleSelectProps` | 🟡 | This couples selection logic to the full table items shape. Consider passing only what's needed (`visibleRowIds`, `childRowIdsById`, `itemDetails`). |
|
||||
| 14 | Performance | `columns.map()` inside render creates new objects on each render | 🟢 | `useColumnOptions` is memoized — this is fine. The `columns.map()` inside `DataTableTBodyContent` render loop is acceptable for normal table sizes. |
|
||||
| 15 | Performance | No virtualization support | 🟢 | For beta, this is acceptable. Document the performance ceiling (recommended max ~500-1000 rows without virtualization). For GA, consider integrating with `@tanstack/react-virtual`. |
|
||||
| 16 | Potential Issue | `fullWidthColSpan` may be wrong when `subRows` adds the expansion column | 🔴 | `fullWidthColSpan` counts `columns.length + layout filler + selection + detailsPanel`. The `RowExpansionCell` renders a separate `<td>` for detailsPanel. If both `subRows` AND `detailsPanel` are used together, verify `fullWidthColSpan` is correct. Check with `NestedRowsWithMasterDetail` story. |
|
||||
| 17 | Potential Issue | Selection row-click fires AND toggles selection simultaneously | 🟡 | When `onRowClick` and `selection` are both enabled and `disableRowSelectionOnClick` is false (default), clicking a row both selects it and fires `onRowClick`. The default should probably be `disableRowSelectionOnClick: true` when `onRowClick` is also provided. |
|
||||
| 18 | Potential Issue | `getRowId` fallback uses index — breaks selection on reorder/filter | 🟡 | If users don't provide `getRowId` and data changes (filter, sort, paginate), selection state becomes stale because index-based IDs shift. Consider logging a dev warning when `selection` is used without `getRowId`. |
|
||||
| 19 | API | `loading.loadingLabel` only applies to skeleton/overlay mode | 🟢 | When `loadingState` is a custom ReactNode, there's no mechanism to announce loading to ATs. The `aria-busy` on the table partially covers this but `aria-live` region would be more robust. |
|
||||
| 20 | API | `detailsPanel.getHeight` returns `number \| "auto"` — inconsistent with CSS patterns | 🟢 | Consider accepting a CSS value string (`"200px"`, `"auto"`) or always `number` (pixels). The mixed type works but requires the consumer to handle units. |
|
||||
| 21 | A11y | Details panel row `aria-controls` relationship from toggle to content | 🟢 | The expansion button has `aria-controls={expansionId}` and the panel `<td>` has the matching `id`. Correctly implemented. ✓ No issue. |
|
||||
| 22 | Code Quality | `@ts-expect-error` for Slot ref in `TableElementWrapper` | 🟢 | Minor tech debt. Consider typing the Slot component to accept ref properly. |
|
||||
| 23 | API | No `onRowDoubleClick` or `onCellClick` callbacks | 🟢 | Common in MUI DataGrid. Not needed for beta but worth considering for GA. |
|
||||
| 24 | Potential Issue | Sort `onSortChange` may double-fire in controlled mode | 🟡 | In `useTableSort`, `setSort(next)` is called and then `onSortChange?.(next, detail)` separately. If `useControllableState` invokes `onChange` internally, `onSortChange` fires twice in controlled mode. Verify. |
|
||||
| 25 | API | `selection` prop type accepts partial object with no `selectionMode` | 🟡 | `SelectionProps` has `selectionMode?: "none" \| "single" \| "multiple"` defaulting to `"none"`. Passing `selection={{}}` without `selectionMode` silently does nothing. Consider requiring `selectionMode` when `selection` is provided (discriminated union). |
|
||||
| 26 | Potential Issue | Keyboard nav `focusInitialTableTarget` always focuses first cell (including header) | 🟢 | Initial focus can go to the first data cell. Currently goes to first `td`/`th` which may be the selection header cell. Minor UX concern. |
|
||||
| 27 | A11y | Resize handle uses `role="slider"` without `aria-valuemin`/`aria-valuemax` | 🟡 | ARIA slider requires `aria-valuemin` and `aria-valuemax`. Currently only `aria-valuenow` is set. Screen readers may not announce the range correctly. |
|
||||
|
||||
---
|
||||
|
||||
## Details
|
||||
|
||||
### Finding #2 — Missing `aria-selected`
|
||||
|
||||
```tsx
|
||||
// In DataTableTr, add:
|
||||
<tr
|
||||
{...rest}
|
||||
aria-selected={selected || undefined}
|
||||
// ...existing props
|
||||
>
|
||||
```
|
||||
|
||||
### Finding #7 — `disableRowSelection` API
|
||||
|
||||
Current signature:
|
||||
|
||||
```ts
|
||||
disableRowSelection?: (({ row, id }: { row: T; id: string | number }) => boolean) | boolean;
|
||||
```
|
||||
|
||||
Stories use:
|
||||
|
||||
```tsx
|
||||
disableRowSelection: ({ id }) => id === 2 || id === 1;
|
||||
```
|
||||
|
||||
Recommended rename for clarity (breaking but pre-beta):
|
||||
|
||||
```ts
|
||||
isRowSelectionDisabled?: (row: T, id: string | number) => boolean;
|
||||
// OR (React Aria style):
|
||||
disabledKeys?: (string | number)[];
|
||||
```
|
||||
|
||||
### Finding #24 — Possible double-fire of `onSortChange`
|
||||
|
||||
In `useTableSort`:
|
||||
|
||||
```ts
|
||||
const handleSortClick = useCallback((id, event) => {
|
||||
// ...
|
||||
setSort(next); // If controlled, useControllableState calls onChange
|
||||
onSortChange?.(next, detail); // Also called explicitly
|
||||
}, [...]);
|
||||
```
|
||||
|
||||
If `useControllableState` invokes `onChange` when the `value` prop changes, `onSortChange` is called twice. Verify by checking if `useControllableState` has its own `onChange` invocation path. If it does, remove the explicit `onSortChange` call and pass it as `onChange` to `useControllableState`.
|
||||
|
||||
---
|
||||
|
||||
## API & Developer Experience Analysis
|
||||
|
||||
### Overall API shape
|
||||
|
||||
The top-level API uses a **data-driven pattern** — you pass `data` + `columnDefinitions` and the component renders everything. This is the right choice for a "batteries included" table. The prop surface splits into:
|
||||
|
||||
- **Core:** `data`, `columnDefinitions`, `getRowId`
|
||||
- **Features (objects):** `selection`, `loading`, `detailsPanel`, `subRows`, `stickyColumns`
|
||||
- **Features (flat):** `sort`, `defaultSort`, `onSortChange`, `onRowClick`, `withKeyboardNav`
|
||||
- **Appearance:** `rowDensity`, `zebraStripes`, `truncateContent`, `textSize`, `layout`
|
||||
|
||||
This is generally good — feature-objects group related props, and appearance props are flat. However there are some inconsistencies.
|
||||
|
||||
### Findings: API & DX
|
||||
|
||||
| # | Category | Finding | Severity | Recommendation |
|
||||
| --- | -------- | -------------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 28 | DX | `header` vs `label` in ColumnDefinition is confusing | 🟡 | Both exist on every column. `label` is required and used for a11y/sort button labels. `header` is optional and overrides what's rendered. Developers will ask "which one is my header text?" Consider: `header` (required, replaces current `label`) + `headerContent` or just always use `header` for rendering and `aria-label` when the header is non-textual. |
|
||||
| 29 | DX | `selection` vs sort props use different grouping patterns | 🟡 | Selection is grouped: `selection={{ selectionMode, onSelectionChange, ... }}`. Sort is flat: `sort`, `defaultSort`, `onSortChange`. This inconsistency will confuse devs. Either group sort (`sort={{ state, defaultState, onChange }}`) or flatten selection. Since sort also uses `detail` parameter, the flat pattern is defensible, but be consistent — pick one and apply it everywhere. |
|
||||
| 30 | DX | `ColumnDefinition` mixes rendering, behavior, and sizing concerns | 🟢 | A single column def contains `cell`, `header`, `label`, `sortable`, `align`, `resizable`, `width`, `defaultWidth`, `autoWidth`, `minWidth`, `maxWidth`, `onWidthChange`, `isRowHeader`, `details`. That's 14+ properties. For beta this is fine (flat = discoverable), but for GA consider splitting into `column.sizing` or using separate configuration layers for power users. |
|
||||
| 31 | DX | `detailsPanel` and `subRows` both have `expandedRowIds`/`defaultExpandedRowIds`/`onExpandedRowIdsChange` | 🟡 | Same prop names exist in both objects. When both features are used together (`NestedRowsWithMasterDetail`), developers must understand these are independent expansion states. The naming collision makes this unclear. Consider `detailsPanel.expandedRowIds` staying as-is but subRows using `expandedSubRowIds` or documenting clearly that they're independent. |
|
||||
| 32 | DX | Resize props pollute column definition for non-resizable tables | 🟢 | When `layout="auto"`, resize props (`resizable`, `width`, `defaultWidth`, `autoWidth`, `minWidth`, `maxWidth`, `onWidthChange`) are all ignored. Developers may set them without realizing they have no effect. The JSDoc on `resizable` mentions this, but it's easy to miss. |
|
||||
| 33 | DX | `getRowId` return type `string \| number` makes controlled selection awkward | 🟡 | `selectedKeys` is `(string \| number)[]`. If `getRowId` returns a number (like `row.id`), the consumer can pass `selectedKeys: [1, 2]` fine. But if they later compare with `===`, `string` vs `number` mismatches bite. TanStack Table uses `string` only for row IDs. Consider normalizing to `string` internally (with automatic `String()` coercion) or staying `number` only — pick one. |
|
||||
| 34 | DX | No way to hide/show columns dynamically | 🟡 | Consumers must filter `columnDefinitions` array externally to toggle column visibility. This is fine API-wise (keep it data-driven), but should be documented as the expected pattern. The `Data.settings.stories.tsx` likely shows this but it's not obvious from the types alone. |
|
||||
| 35 | DX | `loading` object has confusing interaction between its props | 🟡 | Three modes: (1) `isLoading + loadingState` → custom content, (2) `isLoading + loadingRows` → skeletons, (3) `isLoading` alone → overlay on existing data. These modes are implicit from which subset of props you pass. A discriminated union would make intent explicit, but adds verbosity. At minimum, JSDoc should clearly document "if you pass X, Y is ignored." |
|
||||
| 36 | DX | `withKeyboardNav` defaults to `true` — unexpected for simple tables | 🟢 | Most table components default keyboard nav to off. When devs use DataTable for a simple read-only table, the `tabIndex=0` and arrow-key behavior is unexpected. Consider defaulting to `false` or auto-enabling only when interactive features (selection, sorting) are present. |
|
||||
| 37 | DX | `onRowClick` doesn't convey which row data was clicked | 🟡 | Signature is `(rowId, event) => void`. Consumers must look up `rowId` in their own data array to get the row object. Consider `(rowData: T, rowId, event) => void` — this is what MUI and TanStack expose. Avoids forcing an O(n) lookup. |
|
||||
| 38 | DX | `isRowHeader` on ColumnDefinition has no enforcement or guidance | 🟢 | JSDoc says "each row should have one rowheader" but nothing prevents 0 or many. This is a documentation issue, not a code issue. Consider a dev-mode warning if no column has `isRowHeader: true`. |
|
||||
|
||||
### What's working well (keep as-is)
|
||||
|
||||
- **Controlled/uncontrolled pattern** is consistent across selection, sort, sub-rows expansion, and details panel expansion. All use `value`/`defaultValue`/`onChange` semantics.
|
||||
- **`getRowId`** — good that it's optional with a sensible fallback, and the JSDoc warns about the tradeoff.
|
||||
- **`columnDefinitions` as a typed array** — gives excellent autocomplete and catches typos at compile time.
|
||||
- **`selection.selectionMode`** driving the UI (checkbox vs radio) automatically — reduces decision surface.
|
||||
- **Sort cycling** (none → asc → desc → none) with shift-click for multi-sort is well thought out.
|
||||
- **Feature-as-object pattern** (`loading`, `selection`, `detailsPanel`, `subRows`) — good discoverability, scales well for adding future options.
|
||||
|
||||
---
|
||||
|
||||
## Priority Summary
|
||||
|
||||
### 🔴 Beta Blockers (must fix)
|
||||
|
||||
1. **#2** — Add `aria-selected` on selected rows
|
||||
2. **#7** — Resolve `disableRowSelection` API inconsistency (breaking change is OK pre-beta)
|
||||
3. **#16** — Verify `fullWidthColSpan` correctness with combined subRows + detailsPanel
|
||||
|
||||
### 🟡 Should Fix Before Beta
|
||||
|
||||
5. **#3** — Require/encourage table label
|
||||
6. **#4** — Externalize hardcoded Norwegian strings
|
||||
7. **#5** — `role="treegrid"` for sub-rows
|
||||
8. **#8** — `stickyColumns` type to `number`
|
||||
9. **#11** — `aria-rowcount` for paginated tables
|
||||
10. **#12** — Verify "select all" behavior with pagination
|
||||
11. **#17** — Default `disableRowSelectionOnClick: true` when `onRowClick` is set
|
||||
12. **#18** — Dev warning for selection without `getRowId`
|
||||
13. **#24** — Verify sort `onSortChange` double-fire
|
||||
14. **#25** — Make `selectionMode` required when `selection` is provided
|
||||
15. **#27** — Add `aria-valuemin`/`aria-valuemax` to resize slider
|
||||
16. **#28** — Clarify `header` vs `label` in ColumnDefinition
|
||||
17. **#29** — Decide on consistent grouping pattern (flat vs object) for features
|
||||
18. **#31** — Disambiguate `expandedRowIds` between subRows and detailsPanel
|
||||
19. **#33** — Normalize row ID type (string-only or document clearly)
|
||||
20. **#35** — Document loading prop interactions or use discriminated union
|
||||
21. **#37** — Add `rowData` to `onRowClick` callback signature
|
||||
|
||||
### 🟢 Post-Beta / GA
|
||||
|
||||
22. **#9** — Consider grouping sort props
|
||||
23. **#10** — Compositional/slot API
|
||||
24. **#15** — Virtualization
|
||||
25. **#19** — `aria-live` for loading announcements
|
||||
26. **#20** — `getHeight` type cleanup
|
||||
27. **#22** — Fix `@ts-expect-error` tech debt
|
||||
28. **#23** — `onRowDoubleClick`/`onCellClick`
|
||||
29. **#26** — Initial focus target
|
||||
30. **#14** — Performance for large tables (acceptable for beta)
|
||||
31. **#30** — Split ColumnDefinition into layers for power users
|
||||
32. **#32** — Warn when resize props used with `layout="auto"`
|
||||
33. **#34** — Document column visibility pattern
|
||||
34. **#36** — Consider `withKeyboardNav` default
|
||||
35. **#38** — Dev warning for missing `isRowHeader`
|
||||
@@ -2,21 +2,23 @@ import React, { forwardRef } from "react";
|
||||
import { cl } from "../../../utils/helpers";
|
||||
import { useDataTableContext } from "../root/DataTableRoot.context";
|
||||
|
||||
interface DataTableBaseCellProps extends React.HTMLAttributes<HTMLTableCellElement> {
|
||||
interface DataTableBaseCellProps extends Omit<
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>,
|
||||
"width"
|
||||
> {
|
||||
/**
|
||||
* Content alignment inside cell
|
||||
* Content alignment inside cell.
|
||||
*
|
||||
* Quantitative figures like amounts and percentages should be right‑aligned (but not phone numbers, postal codes etc.)
|
||||
* @default "left"
|
||||
*/
|
||||
textAlign?: "left" | "center" | "right";
|
||||
/**
|
||||
* TODO: Shouldnt be needed to declare these here... But getting type-errors if not
|
||||
* Internal cell type that controls padding, alignment, row-click prevention, and resize behavior.
|
||||
* - `"action"`: Centers content, removes cell padding, prevents row click, and disables column resize.
|
||||
* Used for selection (checkbox/radio) and expansion (expand/collapse) cells.
|
||||
*/
|
||||
colSpan?: number;
|
||||
rowSpan?: number;
|
||||
/**
|
||||
* Temp hack to solve overflow and alignment
|
||||
*/
|
||||
UNSAFE_isSelection?: boolean;
|
||||
cellType?: "action";
|
||||
/**
|
||||
* When true, clicking this cell will not trigger `onRowClick` on the row.
|
||||
* Useful for cells that contain their own interactive content or actions
|
||||
@@ -28,18 +30,13 @@ interface DataTableBaseCellProps extends React.HTMLAttributes<HTMLTableCellEleme
|
||||
* This is only needed when using `layout="auto"` together with
|
||||
* `truncateContent` on `<DataTable>` and you want the cell to be truncated.
|
||||
*/
|
||||
contentMaxWidth?: number | `${number}${string}`;
|
||||
contentMaxWidth?: number | string;
|
||||
/**
|
||||
* Makes the cell sticky.
|
||||
*/
|
||||
isSticky?: "start" | "end" | false;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* - Need a "type" or something that centers content instead of relying on isSelection prop.
|
||||
* - Need a separate prop do disabled resizing instead of relying on isSelection prop.
|
||||
*/
|
||||
const DataTableBaseCell = forwardRef<
|
||||
HTMLTableCellElement,
|
||||
DataTableBaseCellProps & {
|
||||
@@ -47,6 +44,12 @@ const DataTableBaseCell = forwardRef<
|
||||
* Cell type
|
||||
*/
|
||||
as: "th" | "td";
|
||||
/**
|
||||
* Content to render before the main cell content.
|
||||
*
|
||||
* **WARNING: Adding content here that takes up space will probably break auto-resizing!**
|
||||
*/
|
||||
beforeContent?: React.ReactNode;
|
||||
}
|
||||
>(
|
||||
(
|
||||
@@ -54,13 +57,14 @@ const DataTableBaseCell = forwardRef<
|
||||
className,
|
||||
children,
|
||||
as: Component,
|
||||
beforeContent,
|
||||
textAlign = "left",
|
||||
colSpan,
|
||||
UNSAFE_isSelection,
|
||||
cellType,
|
||||
preventRowClick,
|
||||
contentMaxWidth,
|
||||
rowSpan,
|
||||
isSticky,
|
||||
colSpan,
|
||||
rowSpan,
|
||||
...rest
|
||||
},
|
||||
forwardedRef,
|
||||
@@ -74,15 +78,21 @@ const DataTableBaseCell = forwardRef<
|
||||
className={cl("aksel-data-table__cell", className)}
|
||||
tabIndex={withKeyboardNav ? -1 : undefined}
|
||||
data-align={textAlign}
|
||||
data-selectable={UNSAFE_isSelection}
|
||||
data-cell-type={cellType || undefined}
|
||||
data-prevent-row-click={
|
||||
preventRowClick || UNSAFE_isSelection || undefined
|
||||
preventRowClick || cellType === "action" || undefined
|
||||
}
|
||||
data-sticky={isSticky || undefined}
|
||||
colSpan={colSpan}
|
||||
rowSpan={rowSpan}
|
||||
>
|
||||
<div style={{ maxWidth: contentMaxWidth }}>{children}</div>
|
||||
{beforeContent}
|
||||
<div
|
||||
className="aksel-data-table__cell-content"
|
||||
style={{ maxWidth: contentMaxWidth }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Component>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { forwardRef, useRef } from "react";
|
||||
import React, { forwardRef, useMemo, useRef } from "react";
|
||||
import {
|
||||
ArrowsUpDownIcon,
|
||||
CaretLeftCircleFillIcon,
|
||||
@@ -13,34 +13,51 @@ import {
|
||||
type DataTableBaseCellProps,
|
||||
} from "../base-cell/DataTableBaseCell";
|
||||
import type { SortDirection } from "../root/DataTable.types";
|
||||
import { useDataTableContext } from "../root/DataTableRoot.context";
|
||||
import { type ResizeProps, useTableColumnResize } from "./useTableColumnResize";
|
||||
|
||||
interface DataTableColumnHeaderProps
|
||||
extends ResizeProps, DataTableBaseCellProps {
|
||||
resizeHandler?: (
|
||||
event:
|
||||
| React.MouseEvent<HTMLButtonElement>
|
||||
| React.TouchEvent<HTMLButtonElement>,
|
||||
) => void;
|
||||
interface DataTableColumnHeaderProps extends DataTableBaseCellProps {
|
||||
/**
|
||||
* Makes the column header sortable. The entire header cell content becomes
|
||||
* a clickable button when true.
|
||||
* Unique identifier for the column. Required for sortable columns to identify which column is being sorted.
|
||||
*/
|
||||
sortable?: boolean;
|
||||
id?: string;
|
||||
/**
|
||||
* Accessible name of the column.
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* Makes the column sortable by clicking on the header.
|
||||
* The entire header cell content becomes a clickable button when true.
|
||||
*/
|
||||
sortable?: boolean; // TODO: Consider merging sortable, sortDirection and onSortClick into a single "sort" object prop
|
||||
/**
|
||||
* Current sort direction. Only relevant when `sortable` is true.
|
||||
* Uses values matching the `aria-sort` attribute directly.
|
||||
* Uses values matching the `aria-sort` attribute directly. // TODO: What does this mean? (Can we just remove it?)
|
||||
* @default "none"
|
||||
*/
|
||||
sortDirection?: SortDirection;
|
||||
sortDirection?: SortDirection; // TODO Not in use???
|
||||
/**
|
||||
* Called when the user clicks the sortable header.
|
||||
* The consumer is responsible for determining and setting the next sort state.
|
||||
* Called when the user clicks the header. Only relevant when `sortable` is true.
|
||||
* The consumer is responsible for determining and setting the next sort state. // TODO: We don't use the term "consumer" in JSDoc anywhere else
|
||||
*/
|
||||
onSortClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
onSortClick?: (event: React.MouseEvent<HTMLElement>) => void; // TODO Not in use???
|
||||
/**
|
||||
* Object with props related to column width and resizing. Summary:
|
||||
*
|
||||
* - `resizable?: boolean` - Whether the column should be resizable by the user.
|
||||
* - `autoResizeOnce?: boolean` - Whether the column should automatically resize to fit its content.
|
||||
* - `resizeMin?: number` - Minimum width of the column when resizing.
|
||||
* - `resizeMax?: number` - Maximum width of the column when resizing.
|
||||
* - `value?: number | string` - Controlled width of the column.
|
||||
* - `default?: number | string` - Initial width of the column.
|
||||
* - `onChange?: (width: number) => void` - Called when the column width changes.
|
||||
*
|
||||
* See individual props for details and defaults.
|
||||
*/
|
||||
width?: ResizeProps;
|
||||
}
|
||||
|
||||
const SORT_ICON: Record<SortDirection, React.ElementType | null> = {
|
||||
const SORT_ICON: Record<SortDirection, React.ElementType> = {
|
||||
asc: SortUpIcon,
|
||||
desc: SortDownIcon,
|
||||
none: ArrowsUpDownIcon,
|
||||
@@ -57,42 +74,37 @@ const DataTableColumnHeader = forwardRef<
|
||||
>(
|
||||
(
|
||||
{
|
||||
id,
|
||||
label,
|
||||
sortable = false,
|
||||
width,
|
||||
cellType,
|
||||
className,
|
||||
children,
|
||||
sortable = false,
|
||||
sortDirection = "none",
|
||||
onSortClick,
|
||||
style,
|
||||
width,
|
||||
defaultWidth,
|
||||
autoWidth,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
onWidthChange,
|
||||
colSpan,
|
||||
rowSpan,
|
||||
UNSAFE_isSelection,
|
||||
...rest
|
||||
},
|
||||
forwardedRef,
|
||||
) => {
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const thRef = useRef<HTMLTableCellElement>(null);
|
||||
const mergedRef = useMergeRefs(forwardedRef, thRef);
|
||||
const { sortingState } = useDataTableContext();
|
||||
const { onSortClick, sortState } = sortingState;
|
||||
|
||||
const resizeResult = useTableColumnResize({
|
||||
...width,
|
||||
thRef,
|
||||
width,
|
||||
defaultWidth,
|
||||
autoWidth,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
onWidthChange,
|
||||
style,
|
||||
colSpan,
|
||||
colSpan: rest.colSpan,
|
||||
});
|
||||
|
||||
const SortIcon = sortable ? SORT_ICON[sortDirection] : null;
|
||||
const sortDirection = useMemo(() => {
|
||||
const sortEntry = sortState.find((s) => s.columnId === id);
|
||||
return sortEntry?.direction ?? "none";
|
||||
}, [id, sortState]);
|
||||
|
||||
const canSort = sortable && id !== undefined;
|
||||
|
||||
const SortIcon = canSort ? SORT_ICON[sortDirection] : null;
|
||||
|
||||
return (
|
||||
<DataTableBaseCell
|
||||
@@ -100,21 +112,18 @@ const DataTableColumnHeader = forwardRef<
|
||||
{...rest}
|
||||
ref={mergedRef}
|
||||
className={cl("aksel-data-table__column-header", className)}
|
||||
data-sortable={sortable}
|
||||
style={resizeResult.style}
|
||||
aria-sort={sortable ? getAriaSort(sortDirection) : undefined}
|
||||
UNSAFE_isSelection={UNSAFE_isSelection}
|
||||
colSpan={colSpan}
|
||||
rowSpan={rowSpan}
|
||||
data-sortable={canSort}
|
||||
style={{ ...style, width: resizeResult.width }}
|
||||
aria-sort={canSort ? getAriaSort(sortDirection) : undefined}
|
||||
cellType={cellType}
|
||||
>
|
||||
{sortable ? (
|
||||
{canSort ? (
|
||||
<button
|
||||
type="button"
|
||||
className="aksel-data-table__th-sort-button"
|
||||
onClick={onSortClick}
|
||||
onClick={(event) => onSortClick(id, event)}
|
||||
>
|
||||
<div ref={contentRef} className="aksel-data-table__th-content">
|
||||
{children}
|
||||
</div>
|
||||
<div className="aksel-data-table__th-content">{children}</div>
|
||||
{SortIcon && (
|
||||
<SortIcon
|
||||
aria-hidden
|
||||
@@ -126,46 +135,44 @@ const DataTableColumnHeader = forwardRef<
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cl({
|
||||
"aksel-data-table__th-content": !UNSAFE_isSelection,
|
||||
"aksel-data-table__th-content": cellType !== "action",
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resizeResult.enabled && !UNSAFE_isSelection && (
|
||||
{resizeResult.enabled && cellType !== "action" && (
|
||||
<button
|
||||
{...resizeResult.resizeHandlerProps}
|
||||
type="button"
|
||||
className="aksel-data-table__th-resize-handle"
|
||||
aria-label={
|
||||
resizeResult.isResizingWithKeyboard
|
||||
? "Bruk pil venstre/høyre"
|
||||
: "Endre bredde"
|
||||
: `Endre bredde ${label}`
|
||||
} // TODO Translate
|
||||
data-active={resizeResult.isResizingWithKeyboard}
|
||||
data-disable-keyboard-nav={resizeResult.isResizingWithKeyboard}
|
||||
data-block-keyboard-nav
|
||||
role="slider"
|
||||
aria-valuenow={
|
||||
typeof resizeResult.style.width === "number"
|
||||
? resizeResult.style.width
|
||||
: 0
|
||||
typeof resizeResult.width === "number" ? resizeResult.width : 0
|
||||
}
|
||||
aria-valuetext={
|
||||
typeof resizeResult.style.width === "number" &&
|
||||
typeof resizeResult.width === "number" &&
|
||||
resizeResult.isResizingWithKeyboard
|
||||
? resizeResult.style.width.toString()
|
||||
? resizeResult.width.toString()
|
||||
: "" // Needs to be blank when not in keyboard resizing mode to avoid NVDA announcing the value as part of the column heading
|
||||
} // Need either this or aria-valuemax to get SR (at least NVDA) to announce the value
|
||||
>
|
||||
{resizeResult.isResizingWithKeyboard && (
|
||||
<>
|
||||
<span className="aksel-data-table__th-resize-handle-indicator aksel-data-table__th-resize-handle-indicator--start">
|
||||
<span className="aksel-data-table__th-resize-handle-indicator">
|
||||
<CaretLeftCircleFillIcon aria-hidden fontSize="1.5rem" />
|
||||
</span>
|
||||
<span className="aksel-data-table__th-resize-handle-indicator aksel-data-table__th-resize-handle-indicator--end">
|
||||
<span className="aksel-data-table__th-resize-handle-indicator">
|
||||
<CaretRightCircleFillIcon aria-hidden fontSize="1.5rem" />
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -8,19 +8,15 @@ import {
|
||||
import { useControllableState } from "../../../utils/hooks";
|
||||
import { useDataTableContext } from "../root/DataTableRoot.context";
|
||||
|
||||
type ColumnWidth = number | string;
|
||||
|
||||
type ResizeProps = {
|
||||
// If/when we add support for composition, consider mentioning that resizing only works on first row in thead.
|
||||
/**
|
||||
* Controlled width of the column.
|
||||
* Whether the column should be resizable by the user.
|
||||
*
|
||||
* Should only be used to fully control column width state. Otherwise, use `defaultWidth` and let the component handle resizing.
|
||||
* **NB:** This is always disabled when `layout="auto"` on the root component.
|
||||
* @default true
|
||||
*/
|
||||
width?: ColumnWidth;
|
||||
/**
|
||||
* Initial width of the column. Only used when `width` is not set.
|
||||
*/
|
||||
defaultWidth?: ColumnWidth;
|
||||
resizable?: boolean;
|
||||
/**
|
||||
* Whether the column should automatically resize to fit its content. **Runs only once.**
|
||||
*
|
||||
@@ -30,46 +26,51 @@ type ResizeProps = {
|
||||
* consider using `layout="auto"` on the root instead for better performance.
|
||||
*
|
||||
* **NB:** This can cause a layout shift. Set a good initial width with `width` or `defaultWidth` to mitigate this.
|
||||
*/
|
||||
autoWidth?: boolean;
|
||||
/**
|
||||
* Minimum width of the column.
|
||||
*
|
||||
* Should be used in conjunction with `width` or `defaultWidth` to set limits when resizing.
|
||||
* **NB:** Does not work with block content.
|
||||
*/
|
||||
minWidth?: ColumnWidth;
|
||||
autoResizeOnce?: boolean;
|
||||
/**
|
||||
* Maximum width of the column.
|
||||
*
|
||||
* Should be used in conjunction with `width` or `defaultWidth` to set limits when resizing.
|
||||
* Minimum width of the column when resizing. Only used when `resizable` or `autoResizeOnce` is enabled.
|
||||
* @default 40
|
||||
*/
|
||||
maxWidth?: ColumnWidth;
|
||||
resizeMin?: number;
|
||||
/**
|
||||
* Maximum width of the column when resizing. Only used when `resizable` or `autoResizeOnce` is enabled.
|
||||
*/
|
||||
resizeMax?: number;
|
||||
// TODO: Consider "allowing" %-width on last column, if we find a solution to the overflow issue (width becomes 0px).
|
||||
/**
|
||||
* Controlled width of the column. (Does not respect `resizeMin` and `resizeMax`.)
|
||||
*
|
||||
* Should only be used to fully control column width state. Otherwise, use `default` and let the component handle resizing.
|
||||
*
|
||||
* **NB:** Percentage as initial width does not work well with resizing.
|
||||
*/
|
||||
value?: number | string;
|
||||
/**
|
||||
* Initial width of the column. Only used when `value` is not set.
|
||||
* (Does not respect `resizeMin` and `resizeMax`.)
|
||||
*
|
||||
* **NB:** Percentage as initial width does not work well with resizing.
|
||||
* @default 140px
|
||||
*/
|
||||
default?: number | string;
|
||||
/**
|
||||
* Called when the column width changes.
|
||||
* @param width New width in pixels.
|
||||
*/
|
||||
onWidthChange?: (width: ColumnWidth) => void;
|
||||
/**
|
||||
* Forwarded styles
|
||||
*/
|
||||
style?: React.CSSProperties;
|
||||
/**
|
||||
* Forwarded colSpan
|
||||
*/
|
||||
colSpan?: number;
|
||||
onChange?: (width: number) => void;
|
||||
};
|
||||
|
||||
type WithUndefined<T> = {
|
||||
[K in keyof T]: T[K] | undefined;
|
||||
};
|
||||
type Unomittable<T> = WithUndefined<Required<T>>;
|
||||
|
||||
type TableColumnResizeArgs = Unomittable<ResizeProps> & {
|
||||
type TableColumnResizeArgs = ResizeProps & {
|
||||
thRef: React.RefObject<HTMLTableCellElement | null>;
|
||||
colSpan: number | undefined;
|
||||
};
|
||||
|
||||
type TableColumnResizeResult =
|
||||
| {
|
||||
style: React.CSSProperties;
|
||||
width: number | string;
|
||||
resizeHandlerProps: {
|
||||
onMouseDown: DOMAttributes<HTMLButtonElement>["onMouseDown"];
|
||||
onTouchStart: DOMAttributes<HTMLButtonElement>["onTouchStart"];
|
||||
@@ -82,83 +83,74 @@ type TableColumnResizeResult =
|
||||
enabled: true;
|
||||
}
|
||||
| {
|
||||
style?: React.CSSProperties;
|
||||
width?: number | string;
|
||||
enabled: false;
|
||||
};
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* - Do we allow % widths?
|
||||
* - Auto-width mode is hard now since that might cause layout-shifts on mount. But would be preferable to
|
||||
* be able to set "1fr" or similar and have it fill remaining space.
|
||||
*/
|
||||
function useTableColumnResize(
|
||||
args: TableColumnResizeArgs,
|
||||
): TableColumnResizeResult {
|
||||
const {
|
||||
thRef,
|
||||
width: userWidth,
|
||||
defaultWidth,
|
||||
autoWidth,
|
||||
onWidthChange,
|
||||
maxWidth = Infinity,
|
||||
minWidth = 40,
|
||||
style,
|
||||
colSpan,
|
||||
} = args;
|
||||
|
||||
function useTableColumnResize({
|
||||
resizable = true,
|
||||
autoResizeOnce,
|
||||
resizeMin = 40,
|
||||
resizeMax = Infinity,
|
||||
value,
|
||||
default: defaultProp,
|
||||
onChange,
|
||||
thRef,
|
||||
colSpan,
|
||||
}: TableColumnResizeArgs): TableColumnResizeResult {
|
||||
const tableContext = useDataTableContext();
|
||||
|
||||
const [isResizingWithKeyboard, setIsResizingWithKeyboard] = useState(false);
|
||||
const ignoreNextOnClick = useRef(false);
|
||||
|
||||
const [width, _setWidth] = useControllableState({
|
||||
value: userWidth,
|
||||
defaultValue: defaultWidth ?? (colSpan ?? 1) * 140,
|
||||
const [width, setWidth] = useControllableState({
|
||||
value,
|
||||
defaultValue: defaultProp ?? (colSpan ?? 1) * 140,
|
||||
/**
|
||||
* TODO:
|
||||
* - Potential optimization: Only call when width as "stopped" changing, e.g. on mouse up or after a debounce when resizing with keyboard.
|
||||
* Otherwise, this could cause excessive calls when resizing quickly.
|
||||
*/
|
||||
onChange: onWidthChange,
|
||||
onChange,
|
||||
});
|
||||
|
||||
const setWidth = useCallback(
|
||||
const setClampedWidth = useCallback(
|
||||
(newWidth: number) => {
|
||||
const min = parseWidth(minWidth) ?? 0;
|
||||
const max = parseWidth(maxWidth) ?? Infinity;
|
||||
const clamped = Math.min(Math.max(newWidth, min), max);
|
||||
_setWidth(clamped);
|
||||
setWidth(Math.min(Math.max(newWidth, resizeMin), resizeMax));
|
||||
},
|
||||
[minWidth, maxWidth, _setWidth],
|
||||
[resizeMin, resizeMax, setWidth],
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: We only want to run this on mount and when autoWidth changes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: We only want to run this on mount and when autoResizeOnce changes
|
||||
useEffect(
|
||||
function autoResizeColumn() {
|
||||
if (!autoWidth) {
|
||||
if (!autoResizeOnce) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newColumnWidth = getAutoColumnWidth(thRef);
|
||||
if (newColumnWidth) {
|
||||
setWidth(newColumnWidth);
|
||||
setClampedWidth(newColumnWidth);
|
||||
}
|
||||
},
|
||||
[autoWidth], // eslint-disable-line react-hooks/exhaustive-deps
|
||||
[autoResizeOnce], // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
|
||||
const handleOnClick: DOMAttributes<HTMLButtonElement>["onClick"] =
|
||||
useCallback(() => {
|
||||
// We need to use the onClick event in order to support screen readers properly,
|
||||
// since some of them only send a mouse click when pressing enter/space.
|
||||
// since some of them only send a mouse click (no kbd events) when pressing enter/space.
|
||||
// We detect a "screen reader click" by checking if we had a mouseUp event right before.
|
||||
|
||||
if (ignoreNextOnClick.current) {
|
||||
ignoreNextOnClick.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setIsResizingWithKeyboard((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
@@ -173,19 +165,19 @@ function useTableColumnResize(
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
const delta = event.key === "ArrowRight" ? 20 : -20;
|
||||
setWidth(currentWidth + delta);
|
||||
setClampedWidth(currentWidth + delta);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
setWidth(0); // will fall back to minWidth
|
||||
setClampedWidth(0); // will fall back to minWidth
|
||||
return;
|
||||
}
|
||||
if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
const newWidth = getAutoColumnWidth(thRef);
|
||||
if (newWidth && newWidth > currentWidth) {
|
||||
setWidth(newWidth);
|
||||
setClampedWidth(newWidth);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -193,7 +185,7 @@ function useTableColumnResize(
|
||||
setIsResizingWithKeyboard(false);
|
||||
}
|
||||
},
|
||||
[isResizingWithKeyboard, setWidth, thRef],
|
||||
[isResizingWithKeyboard, setClampedWidth, thRef],
|
||||
);
|
||||
|
||||
const startResize = useCallback(
|
||||
@@ -204,19 +196,16 @@ function useTableColumnResize(
|
||||
const currentWidth = thRef.current?.offsetWidth ?? 0;
|
||||
const newWidth = startWidth + (clientX - startX);
|
||||
|
||||
const min = parseWidth(minWidth) ?? 0;
|
||||
const max = parseWidth(maxWidth) ?? Infinity;
|
||||
|
||||
if (newWidth > max) {
|
||||
if (newWidth > resizeMax) {
|
||||
setWidth(newWidth < currentWidth ? newWidth : currentWidth);
|
||||
return;
|
||||
}
|
||||
if (newWidth < min) {
|
||||
if (newWidth < resizeMin) {
|
||||
setWidth(newWidth > currentWidth ? newWidth : currentWidth);
|
||||
return;
|
||||
}
|
||||
|
||||
setWidth(newWidth);
|
||||
setClampedWidth(newWidth);
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
@@ -244,7 +233,7 @@ function useTableColumnResize(
|
||||
document.addEventListener("touchend", cleanup, { once: true });
|
||||
document.addEventListener("touchcancel", cleanup, { once: true });
|
||||
},
|
||||
[maxWidth, minWidth, setWidth, thRef],
|
||||
[resizeMax, resizeMin, setWidth, setClampedWidth, thRef],
|
||||
);
|
||||
|
||||
const handleMouseDown: DOMAttributes<HTMLButtonElement>["onMouseDown"] =
|
||||
@@ -263,27 +252,31 @@ function useTableColumnResize(
|
||||
[startResize],
|
||||
);
|
||||
|
||||
// Auto-size column to fit content on double click. NB: Doesn't work with block content!
|
||||
// Auto-size column to fit content on double click
|
||||
const handleDoubleClick: DOMAttributes<HTMLButtonElement>["onDoubleClick"] =
|
||||
useCallback(() => {
|
||||
const newColumnWidth = getAutoColumnWidth(thRef);
|
||||
if (newColumnWidth) {
|
||||
setWidth(newColumnWidth);
|
||||
setClampedWidth(newColumnWidth);
|
||||
}
|
||||
}, [setWidth, thRef]);
|
||||
}, [setClampedWidth, thRef]);
|
||||
|
||||
if (tableContext.layout !== "fixed") {
|
||||
return {
|
||||
style,
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!resizable) {
|
||||
return {
|
||||
width,
|
||||
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
style: {
|
||||
...style,
|
||||
width,
|
||||
},
|
||||
width,
|
||||
resizeHandlerProps: {
|
||||
onMouseDown: handleMouseDown,
|
||||
onTouchStart: handleTouchStart,
|
||||
@@ -297,33 +290,25 @@ function useTableColumnResize(
|
||||
};
|
||||
}
|
||||
|
||||
function parseWidth(width: ColumnWidth | undefined): number | undefined {
|
||||
if (width == null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof width === "number") {
|
||||
return width;
|
||||
}
|
||||
if (typeof width === "string") {
|
||||
const parsed = parseInt(width, 10);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Figures out how wide the column needs to be to fit all the content without truncation.
|
||||
* NB: Does not work with block content!
|
||||
*/
|
||||
function getAutoColumnWidth(
|
||||
thRef: React.RefObject<HTMLTableCellElement | null>,
|
||||
) {
|
||||
const th = thRef.current!;
|
||||
const thContent = th.querySelector(".aksel-data-table__th-content");
|
||||
const thPaddingEl = th.querySelector("div");
|
||||
const thPaddingEl = th.querySelector(".aksel-data-table__cell-content");
|
||||
const rows = th.closest("table")?.querySelectorAll("tbody tr, tfoot tr");
|
||||
if (!thContent || !thPaddingEl || !rows) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find needed width for header cell
|
||||
const contentWidth = thContent.scrollWidth;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(thContent);
|
||||
const contentWidth = range.getBoundingClientRect().width;
|
||||
const paddingElStyle = window.getComputedStyle(thPaddingEl);
|
||||
const thInlinePadding =
|
||||
parseInt(paddingElStyle.paddingLeft, 10) +
|
||||
@@ -339,7 +324,6 @@ function getAutoColumnWidth(
|
||||
}
|
||||
|
||||
// Find needed width for each cell in column in tbody and tfoot
|
||||
const range = document.createRange();
|
||||
let skipRows = 0;
|
||||
for (const row of rows) {
|
||||
// Skip rows where the cell in this column is covered by a rowspan from a previous row
|
||||
@@ -358,15 +342,18 @@ function getAutoColumnWidth(
|
||||
skipRows = cell.rowSpan - 1;
|
||||
|
||||
// Find needed width
|
||||
const cellContent = cell.firstChild as HTMLElement;
|
||||
const cellContent = cell.querySelector(
|
||||
".aksel-data-table__cell-content",
|
||||
) as HTMLElement;
|
||||
range.selectNodeContents(cellContent);
|
||||
const cellContentWidth = range.getBoundingClientRect().width;
|
||||
const contentElStyle = window.getComputedStyle(cellContent);
|
||||
const inlinePadding =
|
||||
parseInt(contentElStyle.paddingLeft, 10) +
|
||||
parseInt(contentElStyle.paddingRight, 10);
|
||||
const marginLeft = parseInt(contentElStyle.marginLeft, 10); // We don't have right margin for now
|
||||
const widthNeededForThisCell =
|
||||
(cellContentWidth + inlinePadding) / cell.colSpan;
|
||||
(cellContentWidth + inlinePadding + marginLeft) / cell.colSpan;
|
||||
if (widthNeededForThisCell > newColumnWidth) {
|
||||
newColumnWidth = widthNeededForThisCell;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from "react";
|
||||
import {
|
||||
getDataTableDetailsPanelId,
|
||||
useDataTableDetailsPanel,
|
||||
} from "../hooks/useTableDetailsPanel";
|
||||
import { useDataTableContext } from "../root/DataTableRoot.context";
|
||||
|
||||
function DataTableDetailsPanelRow<T>({
|
||||
rowId,
|
||||
rowData,
|
||||
}: {
|
||||
rowId: string;
|
||||
rowData: T;
|
||||
}) {
|
||||
const { tableId, totalColSpan } = useDataTableContext();
|
||||
const {
|
||||
enableDetailsPanel,
|
||||
isExpanded,
|
||||
getDetailsPanelContent,
|
||||
getDetailsPanelHeight,
|
||||
} = useDataTableDetailsPanel();
|
||||
|
||||
if (!enableDetailsPanel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isExpanded(rowId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = getDetailsPanelContent?.(rowData);
|
||||
const expansionId = getDataTableDetailsPanelId(tableId, rowId);
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const panelHeight = getDetailsPanelHeight?.(rowData);
|
||||
|
||||
const style: React.CSSProperties = panelHeight
|
||||
? { height: panelHeight, overflow: "auto" }
|
||||
: { height: "auto" };
|
||||
|
||||
return (
|
||||
<tr className="aksel-data-table__details-panel-row">
|
||||
<td
|
||||
id={expansionId}
|
||||
colSpan={totalColSpan}
|
||||
className="aksel-data-table__details-panel-row-cell"
|
||||
>
|
||||
<div style={style}>{content}</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export { DataTableDetailsPanelRow };
|
||||
@@ -1,21 +1,23 @@
|
||||
type TableRowEntryId = string | number;
|
||||
import type { TableRowEntryId } from "../root/DataTable.types";
|
||||
|
||||
type CollectTableRowEntriesArgs<T> = {
|
||||
items: T[];
|
||||
getRowId?: (rowData: T, index: number) => TableRowEntryId;
|
||||
getSubRows?: (rowData: T) => T[];
|
||||
isSubRowExpandable?: (rowData: T) => boolean;
|
||||
getRows?: (rowData: T) => T[];
|
||||
isRowExpandable?: (rowData: T) => boolean;
|
||||
};
|
||||
|
||||
interface ItemDetail<T> {
|
||||
id: string | number;
|
||||
id: TableRowEntryId;
|
||||
rowData: T;
|
||||
level: number;
|
||||
parent: null | T;
|
||||
children: readonly T[];
|
||||
parentId: TableRowEntryId | null;
|
||||
children: readonly TableRowEntryId[];
|
||||
}
|
||||
|
||||
type CollectTableRowEntriesReturn<T> = {
|
||||
itemDetails: Map<T, ItemDetail<T>>;
|
||||
itemDetails: Map<TableRowEntryId, ItemDetail<T>>;
|
||||
rootRowIds: TableRowEntryId[];
|
||||
/**
|
||||
* Direct child ids for each row, used to traverse nested selection groups
|
||||
* without storing every descendant list on each ancestor.
|
||||
@@ -26,65 +28,68 @@ type CollectTableRowEntriesReturn<T> = {
|
||||
function collectTableRowEntries<T>({
|
||||
items,
|
||||
getRowId,
|
||||
getSubRows,
|
||||
isSubRowExpandable,
|
||||
getRows,
|
||||
isRowExpandable,
|
||||
}: CollectTableRowEntriesArgs<T>): CollectTableRowEntriesReturn<T> {
|
||||
const itemDetailsMap = new Map<T, ItemDetail<T>>();
|
||||
const itemDetailsMap = new Map<TableRowEntryId, ItemDetail<T>>();
|
||||
const childRowIdsById = new Map<TableRowEntryId, TableRowEntryId[]>();
|
||||
const rootRowIds: TableRowEntryId[] = [];
|
||||
|
||||
const traverseRow = (
|
||||
rowData: T,
|
||||
rowIndex: number,
|
||||
level: number,
|
||||
parent: T | null,
|
||||
parentId?: TableRowEntryId,
|
||||
parentRowId: TableRowEntryId | null,
|
||||
): TableRowEntryId => {
|
||||
const rowId =
|
||||
getRowId?.(rowData, rowIndex) ??
|
||||
(parentId == null ? rowIndex : `${parentId}-${rowIndex}`);
|
||||
const isRowExpandable = isSubRowExpandable?.(rowData) ?? true;
|
||||
const children = (isRowExpandable ? getSubRows?.(rowData) : []) ?? [];
|
||||
const rowId = getRowId
|
||||
? getRowId(rowData, rowIndex)
|
||||
: getFallbackTableRowId(rowIndex, parentRowId);
|
||||
|
||||
itemDetailsMap.set(rowData, {
|
||||
id: rowId,
|
||||
level,
|
||||
parent,
|
||||
children,
|
||||
});
|
||||
const children =
|
||||
((isRowExpandable?.(rowData) ?? true) ? getRows?.(rowData) : []) ?? [];
|
||||
|
||||
const childRowIds: TableRowEntryId[] = [];
|
||||
|
||||
for (let childIndex = 0; childIndex < children.length; childIndex++) {
|
||||
const childRow = children[childIndex];
|
||||
const childRowId = traverseRow(
|
||||
childRow,
|
||||
childIndex,
|
||||
level + 1,
|
||||
rowData,
|
||||
rowId,
|
||||
);
|
||||
const childRowId = traverseRow(childRow, childIndex, level + 1, rowId);
|
||||
childRowIds.push(childRowId);
|
||||
}
|
||||
|
||||
itemDetailsMap.set(rowId, {
|
||||
id: rowId,
|
||||
rowData,
|
||||
level,
|
||||
parentId: parentRowId,
|
||||
children: childRowIds,
|
||||
});
|
||||
|
||||
childRowIdsById.set(rowId, childRowIds);
|
||||
|
||||
return rowId;
|
||||
};
|
||||
|
||||
for (let rowIndex = 0; rowIndex < items.length; rowIndex++) {
|
||||
traverseRow(items[rowIndex], rowIndex, 0, null);
|
||||
rootRowIds.push(traverseRow(items[rowIndex], rowIndex, 0, null));
|
||||
}
|
||||
|
||||
return {
|
||||
itemDetails: itemDetailsMap,
|
||||
rootRowIds,
|
||||
childRowIdsById,
|
||||
};
|
||||
}
|
||||
|
||||
function getFallbackTableRowId(
|
||||
rowIndex: number,
|
||||
parentRowId: TableRowEntryId | null,
|
||||
): string {
|
||||
return parentRowId == null ? String(rowIndex) : `${parentRowId}.${rowIndex}`;
|
||||
}
|
||||
|
||||
export { collectTableRowEntries };
|
||||
export type {
|
||||
CollectTableRowEntriesArgs,
|
||||
CollectTableRowEntriesReturn,
|
||||
TableRowEntryId,
|
||||
ItemDetail,
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { SelectionSubtreeHelper } from "./SelectionSubtreeHelper";
|
||||
|
||||
const childRowIdsById = new Map<string | number, (string | number)[]>([
|
||||
["a", ["a1", "a2"]],
|
||||
["a1", []],
|
||||
["a2", ["a2a"]],
|
||||
["a2a", []],
|
||||
["b", []],
|
||||
]);
|
||||
|
||||
describe("SelectionSubtreeHelper", () => {
|
||||
test("returns selectable subtree keys without duplicates", () => {
|
||||
const helper = new SelectionSubtreeHelper({
|
||||
childRowIdsById,
|
||||
disabledKeysSet: new Set(["a2"]),
|
||||
selectedKeysSet: new Set(),
|
||||
});
|
||||
|
||||
expect(helper.getSelectableKeys(["a", "a1"])).toEqual(["a", "a1", "a2a"]);
|
||||
});
|
||||
|
||||
test("returns cached subtree selection stats", () => {
|
||||
const helper = new SelectionSubtreeHelper({
|
||||
childRowIdsById,
|
||||
disabledKeysSet: new Set(["a2"]),
|
||||
selectedKeysSet: new Set(["a", "a1", "a2a"]),
|
||||
});
|
||||
|
||||
const firstStats = helper.getSelectionStats("a");
|
||||
const secondStats = helper.getSelectionStats("a");
|
||||
|
||||
expect(firstStats).toEqual({ selectableCount: 3, selectedCount: 3 });
|
||||
expect(secondStats).toBe(firstStats);
|
||||
expect(helper.isFullySelected("a")).toBe(true);
|
||||
});
|
||||
|
||||
test("handles deep trees iteratively", () => {
|
||||
const depth = 12000;
|
||||
const deepChildRowIdsById = new Map<string | number, (string | number)[]>();
|
||||
const selectedKeysSet = new Set<string | number>();
|
||||
|
||||
for (let index = 0; index < depth; index++) {
|
||||
const key = `node-${index}`;
|
||||
const childKey = `node-${index + 1}`;
|
||||
|
||||
deepChildRowIdsById.set(key, index === depth - 1 ? [] : [childKey]);
|
||||
|
||||
if (index % 2 === 0) {
|
||||
selectedKeysSet.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
const helper = new SelectionSubtreeHelper({
|
||||
childRowIdsById: deepChildRowIdsById,
|
||||
disabledKeysSet: new Set(),
|
||||
selectedKeysSet,
|
||||
});
|
||||
|
||||
expect(helper.getSelectableKeys(["node-0"])).toHaveLength(depth);
|
||||
expect(helper.getSelectionStats("node-0")).toEqual({
|
||||
selectableCount: depth,
|
||||
selectedCount: Math.ceil(depth / 2),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
type SelectionKey = string | number;
|
||||
|
||||
type SelectionStats = {
|
||||
selectableCount: number;
|
||||
selectedCount: number;
|
||||
};
|
||||
|
||||
type SelectionSubtreeHelperArgs = {
|
||||
childRowIdsById?: Map<SelectionKey, SelectionKey[]>;
|
||||
disabledKeysSet: Set<SelectionKey>;
|
||||
selectedKeysSet: Set<SelectionKey>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper class for managing selection state in a tree structure.
|
||||
* - It provides methods to get selectable keys in a subtree
|
||||
* - Compute selection statistics for a subtree
|
||||
* - Determine if a subtree is fully selected.
|
||||
*
|
||||
* Results of selection statistics are cached to optimize performance for repeated calls on the same subtree.
|
||||
*/
|
||||
class SelectionSubtreeHelper {
|
||||
private childRowIdsById: Map<SelectionKey, SelectionKey[]>;
|
||||
private disabledKeysSet: Set<SelectionKey>;
|
||||
private selectedKeysSet: Set<SelectionKey>;
|
||||
private selectionStatsCache = new Map<SelectionKey, SelectionStats>();
|
||||
|
||||
constructor({
|
||||
childRowIdsById,
|
||||
disabledKeysSet,
|
||||
selectedKeysSet,
|
||||
}: SelectionSubtreeHelperArgs) {
|
||||
this.childRowIdsById = childRowIdsById ?? new Map();
|
||||
this.disabledKeysSet = disabledKeysSet;
|
||||
this.selectedKeysSet = selectedKeysSet;
|
||||
}
|
||||
|
||||
getSelectableKeys(rootIds: SelectionKey[]): SelectionKey[] {
|
||||
const visitedKeys = new Set<SelectionKey>();
|
||||
const selectableKeys: SelectionKey[] = [];
|
||||
const stack = [...rootIds].reverse();
|
||||
|
||||
while (stack.length > 0) {
|
||||
const key = stack.pop();
|
||||
|
||||
if (key == null || visitedKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visitedKeys.add(key);
|
||||
|
||||
if (!this.disabledKeysSet.has(key)) {
|
||||
selectableKeys.push(key);
|
||||
}
|
||||
|
||||
const childRowIds = this.childRowIdsById.get(key) ?? [];
|
||||
|
||||
for (
|
||||
let childIndex = childRowIds.length - 1;
|
||||
childIndex >= 0;
|
||||
childIndex--
|
||||
) {
|
||||
stack.push(childRowIds[childIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
return selectableKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of selectable and selected rows in the subtree of the given root ID.
|
||||
* Results are cached after the first computation to optimize repeated calls for the same root ID.
|
||||
*
|
||||
* The selectable count excludes disabled rows, and the selected count excludes disabled rows that are selected.
|
||||
* The method is implemented iteratively to handle deep trees without hitting call stack limits.
|
||||
*
|
||||
* How it works:
|
||||
* - Manually add root ID to stack to get processing going. Note that the ready-flag is `false`.
|
||||
* - Pop stack until empty. For each entry:
|
||||
* - - If entry is already cached, skip it.
|
||||
* - - If entry is not ready, push it back as ready and push all its children as not ready.
|
||||
* - - If entry is ready, compute its stats based on its own state and the stats of its children, then cache the result.
|
||||
* - Since we add all the children to the stack after pushing element with ready: true, while "popping" the stack we will always encounter the children before their parent is ready, ensuring that the stats for all children are computed and cached before computing the stats for their parent.
|
||||
* - Finally, return the cached stats for the root ID.
|
||||
*/
|
||||
getSelectionStats(rootId: SelectionKey): SelectionStats {
|
||||
const cachedStats = this.selectionStatsCache.get(rootId);
|
||||
|
||||
if (cachedStats) {
|
||||
return cachedStats;
|
||||
}
|
||||
|
||||
/* Compute subtree totals iteratively so deep trees do not depend on call stack depth. */
|
||||
const stack: { key: SelectionKey; ready: boolean }[] = [
|
||||
{ key: rootId, ready: false },
|
||||
];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const entry = stack.pop();
|
||||
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.selectionStatsCache.has(entry.key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.ready) {
|
||||
let selectableCount = this.disabledKeysSet.has(entry.key) ? 0 : 1;
|
||||
let selectedCount =
|
||||
!this.disabledKeysSet.has(entry.key) &&
|
||||
this.selectedKeysSet.has(entry.key)
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
for (const childKey of this.childRowIdsById.get(entry.key) ?? []) {
|
||||
const childStats = this.selectionStatsCache.get(childKey);
|
||||
|
||||
if (!childStats) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selectableCount += childStats.selectableCount;
|
||||
selectedCount += childStats.selectedCount;
|
||||
}
|
||||
|
||||
this.selectionStatsCache.set(entry.key, {
|
||||
selectableCount,
|
||||
selectedCount,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
stack.push({ key: entry.key, ready: true });
|
||||
|
||||
for (const childKey of this.childRowIdsById.get(entry.key) ?? []) {
|
||||
if (!this.selectionStatsCache.has(childKey)) {
|
||||
stack.push({ key: childKey, ready: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
this.selectionStatsCache.get(rootId) ?? {
|
||||
selectableCount: 0,
|
||||
selectedCount: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
isFullySelected(rootId: SelectionKey): boolean {
|
||||
const stats = this.getSelectionStats(rootId);
|
||||
|
||||
return (
|
||||
stats.selectableCount > 0 && stats.selectedCount === stats.selectableCount
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { SelectionSubtreeHelper };
|
||||
export type { SelectionKey, SelectionStats, SelectionSubtreeHelperArgs };
|
||||
@@ -1,107 +1,87 @@
|
||||
import type { ChangeEventHandler, SetStateAction } from "react";
|
||||
import type { CheckboxInputProps } from "../../../../form/checkbox/checkbox-input/CheckboxInput";
|
||||
import { SelectionSubtreeHelper } from "./SelectionSubtreeHelper";
|
||||
import { consoleWarning } from "../../../../utils/helpers/consoleWarning";
|
||||
import type { UseTableItemsReturn } from "../../hooks/useTableItems";
|
||||
import type { TableRowEntryId } from "../../root/DataTable.types";
|
||||
import type { SelectedKeysT, SelectionProps } from "./selection.types";
|
||||
import { canSelectTableRow, mutateRowSelection } from "./selection.utils";
|
||||
|
||||
type GetMultipleSelectPropsArgs = {
|
||||
selectedKeysSet: Set<string | number>;
|
||||
selectedKeys: (string | number)[];
|
||||
setSelectedKeys: (keys: (string | number)[]) => void;
|
||||
disabledKeysSet: Set<string | number>;
|
||||
visibleRowIds: (string | number)[];
|
||||
childRowIdsById?: Map<string | number, (string | number)[]>;
|
||||
};
|
||||
type GetMultipleSelectPropsArgs<T> = {
|
||||
selectedKeysSet: Set<TableRowEntryId>;
|
||||
selectedKeys: SelectedKeysT;
|
||||
setSelectedKeys: (next: SetStateAction<SelectedKeysT>) => void;
|
||||
tableItems: UseTableItemsReturn<T>;
|
||||
} & Pick<SelectionProps<T>, "enableRowSelection">;
|
||||
|
||||
function getMultipleSelectProps({
|
||||
function getMultipleSelectProps<T>({
|
||||
selectedKeysSet,
|
||||
selectedKeys,
|
||||
setSelectedKeys,
|
||||
disabledKeysSet,
|
||||
visibleRowIds,
|
||||
childRowIdsById,
|
||||
}: GetMultipleSelectPropsArgs) {
|
||||
const subtreeHelper = new SelectionSubtreeHelper({
|
||||
childRowIdsById,
|
||||
disabledKeysSet,
|
||||
selectedKeysSet,
|
||||
});
|
||||
enableRowSelection,
|
||||
tableItems,
|
||||
}: GetMultipleSelectPropsArgs<T>) {
|
||||
const selectableIdsSet: Set<TableRowEntryId> = new Set();
|
||||
|
||||
// Header selection traverses the visible roots and skips already visited
|
||||
// descendants, so expanded trees stay linear in the number of rows.
|
||||
const headerSelectableKeys = subtreeHelper.getSelectableKeys(visibleRowIds);
|
||||
const headerSelectableKeysSet = new Set(headerSelectableKeys);
|
||||
|
||||
const selectedSelectableCount = headerSelectableKeys.filter((k) =>
|
||||
selectedKeysSet.has(k),
|
||||
).length;
|
||||
|
||||
const allSelectableSelected =
|
||||
headerSelectableKeys.length > 0 &&
|
||||
selectedSelectableCount === headerSelectableKeys.length;
|
||||
|
||||
const indeterminate =
|
||||
selectedSelectableCount > 0 &&
|
||||
selectedSelectableCount < headerSelectableKeys.length;
|
||||
|
||||
const selectedKeysNotInView = selectedKeys.filter(
|
||||
(k) => !headerSelectableKeysSet.has(k),
|
||||
);
|
||||
const disabledSelected = selectedKeys.filter((k) => disabledKeysSet.has(k));
|
||||
const preservedKeys = [
|
||||
...new Set([...selectedKeysNotInView, ...disabledSelected]),
|
||||
];
|
||||
|
||||
const isGroupFullySelected = (key: string | number) => {
|
||||
const groupStats = subtreeHelper.getSelectionStats(key);
|
||||
|
||||
return (
|
||||
groupStats.selectableCount > 0 &&
|
||||
groupStats.selectedCount === groupStats.selectableCount
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleAll = () => {
|
||||
if (allSelectableSelected) {
|
||||
setSelectedKeys(preservedKeys);
|
||||
} else {
|
||||
setSelectedKeys([
|
||||
...new Set([...preservedKeys, ...headerSelectableKeys]),
|
||||
]);
|
||||
for (const [id, { rowData }] of tableItems.itemDetails) {
|
||||
if (canSelectTableRow(enableRowSelection, { row: rowData, id })) {
|
||||
selectableIdsSet.add(id);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleToggleRow = (key: string | number) => {
|
||||
if (disabledKeysSet.has(key)) {
|
||||
return;
|
||||
}
|
||||
let selectedOnPageCount = 0;
|
||||
for (const id of selectableIdsSet) {
|
||||
selectedKeysSet.has(id) && selectedOnPageCount++;
|
||||
}
|
||||
|
||||
const groupKeys = subtreeHelper.getSelectableKeys([key]);
|
||||
const isAllSelected =
|
||||
selectableIdsSet.size > 0 && selectedOnPageCount === selectableIdsSet.size;
|
||||
const someSelected = selectedOnPageCount > 0;
|
||||
|
||||
if (isGroupFullySelected(key)) {
|
||||
const groupKeysSet = new Set(groupKeys);
|
||||
setSelectedKeys(
|
||||
selectedKeys.filter((selectedKey) => !groupKeysSet.has(selectedKey)),
|
||||
const handleToggleRow = (key: TableRowEntryId, row: T) => {
|
||||
if (!row) {
|
||||
consoleWarning(
|
||||
`Row data is undefined for key ${key}. This may cause issues with selection if enableRowSelection is used.`,
|
||||
);
|
||||
}
|
||||
|
||||
const checked = !selectedKeysSet.has(key);
|
||||
const nextSet = new Set(selectedKeysSet);
|
||||
const changed = mutateRowSelection({
|
||||
selectedRowIds: nextSet,
|
||||
rowId: key,
|
||||
checked,
|
||||
childRowIdsById: tableItems.childRowIdsById,
|
||||
itemDetails: tableItems.itemDetails,
|
||||
enableRowSelection,
|
||||
});
|
||||
if (changed) {
|
||||
setSelectedKeys([...nextSet]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAllRowSelected: ChangeEventHandler<HTMLInputElement> = (
|
||||
event,
|
||||
) => {
|
||||
if (event.target.checked) {
|
||||
const preserved = selectedKeys.filter((k) => !selectableIdsSet.has(k));
|
||||
setSelectedKeys([...preserved, ...selectableIdsSet]);
|
||||
} else {
|
||||
setSelectedKeys([...new Set([...selectedKeys, ...groupKeys])]);
|
||||
setSelectedKeys(selectedKeys.filter((k) => !selectableIdsSet.has(k)));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getTheadCheckboxProps: (): CheckboxInputProps => ({
|
||||
onChange: handleToggleAll,
|
||||
checked: allSelectableSelected,
|
||||
indeterminate,
|
||||
disabled: headerSelectableKeys.length === 0,
|
||||
checked: isAllSelected,
|
||||
indeterminate: !isAllSelected && someSelected,
|
||||
onChange: toggleAllRowSelected,
|
||||
}),
|
||||
getRowCheckboxProps: (key: string | number): CheckboxInputProps => {
|
||||
const groupStats = subtreeHelper.getSelectionStats(key);
|
||||
|
||||
getRowCheckboxProps: (key: TableRowEntryId, row: T): CheckboxInputProps => {
|
||||
return {
|
||||
onChange: () => handleToggleRow(key),
|
||||
checked: isGroupFullySelected(key),
|
||||
indeterminate:
|
||||
groupStats.selectedCount > 0 &&
|
||||
groupStats.selectedCount < groupStats.selectableCount,
|
||||
disabled: disabledKeysSet.has(key),
|
||||
onChange: () => handleToggleRow(key, row),
|
||||
checked: selectedKeysSet.has(key),
|
||||
indeterminate: false,
|
||||
disabled: !canSelectTableRow(enableRowSelection, { row, id: key }),
|
||||
};
|
||||
},
|
||||
toggleSelection: handleToggleRow,
|
||||
|
||||
@@ -1,33 +1,51 @@
|
||||
import type { RadioInputProps } from "../../../../form/radio/radio-input/RadioInput";
|
||||
import { consoleWarning } from "../../../../utils/helpers/consoleWarning";
|
||||
import type { TableRowEntryId } from "../../root/DataTable.types";
|
||||
import type { SelectedKeysT, SelectionProps } from "./selection.types";
|
||||
import { canSelectTableRow } from "./selection.utils";
|
||||
|
||||
type GetSingleSelectPropsArgs = {
|
||||
selectedKeysSet: Set<string | number>;
|
||||
setSelectedKeys: (keys: (string | number)[]) => void;
|
||||
disabledKeysSet: Set<string | number>;
|
||||
type GetSingleSelectPropsArgs<T> = {
|
||||
selectedKeysSet: Set<TableRowEntryId>;
|
||||
setSelectedKeys: (keys: SelectedKeysT) => void;
|
||||
name: string;
|
||||
};
|
||||
} & Pick<SelectionProps<T>, "enableRowSelection">;
|
||||
|
||||
function getSingleSelectProps({
|
||||
function getSingleSelectProps<T>({
|
||||
selectedKeysSet,
|
||||
setSelectedKeys,
|
||||
disabledKeysSet,
|
||||
name,
|
||||
}: GetSingleSelectPropsArgs) {
|
||||
const handleSelectionChange = (key: string | number) => {
|
||||
if (disabledKeysSet.has(key)) {
|
||||
enableRowSelection,
|
||||
}: GetSingleSelectPropsArgs<T>) {
|
||||
const handleSelectionChange = (key: TableRowEntryId, row: T) => {
|
||||
if (!row) {
|
||||
consoleWarning(
|
||||
`Row data is undefined for key ${key}. This may cause issues with selection if enableRowSelection is used.`,
|
||||
);
|
||||
}
|
||||
if (!canSelectTableRow(enableRowSelection, { row, id: key })) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedKeys([key]);
|
||||
};
|
||||
|
||||
return {
|
||||
getRowRadioProps: (key: string | number): RadioInputProps => ({
|
||||
checked: selectedKeysSet.has(key),
|
||||
onChange: () => handleSelectionChange(key),
|
||||
disabled: disabledKeysSet.has(key),
|
||||
value: key,
|
||||
name,
|
||||
}),
|
||||
getRowRadioProps: (key: TableRowEntryId, row: T): RadioInputProps => {
|
||||
const isSelectionDisabled = !canSelectTableRow(enableRowSelection, {
|
||||
row,
|
||||
id: key,
|
||||
});
|
||||
|
||||
return {
|
||||
checked: selectedKeysSet.has(key),
|
||||
onChange: isSelectionDisabled
|
||||
? () => null
|
||||
: () => handleSelectionChange(key, row),
|
||||
disabled: isSelectionDisabled,
|
||||
value: key,
|
||||
name,
|
||||
};
|
||||
},
|
||||
toggleSelection: handleSelectionChange,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { CheckboxInputProps } from "../../../../form/checkbox/checkbox-input/CheckboxInput";
|
||||
import type { RadioInputProps } from "../../../../form/radio/radio-input/RadioInput";
|
||||
import type { TableRowEntryId } from "../../root/DataTable.types";
|
||||
|
||||
type SelectedKeysT = (string | number)[];
|
||||
type SelectedKeysT = TableRowEntryId[];
|
||||
|
||||
type SelectionProps = {
|
||||
// TODO: Remove `any` if possible
|
||||
type SelectionProps<T = any> = {
|
||||
/**
|
||||
* Enables selection of rows.
|
||||
*
|
||||
@@ -14,7 +16,7 @@ type SelectionProps = {
|
||||
*
|
||||
* @default "none"
|
||||
*/
|
||||
selectionMode?: "none" | "single" | "multiple";
|
||||
selectionMode: "none" | "single" | "multiple";
|
||||
/**
|
||||
* Controlled selected keys. Should be used in conjunction with `onSelectionChange`.
|
||||
*/
|
||||
@@ -28,45 +30,43 @@ type SelectionProps = {
|
||||
*/
|
||||
onSelectionChange?: (keys: SelectedKeysT) => void;
|
||||
/**
|
||||
* Keys that should be disabled for selection. These keys will not be selectable and will be styled as disabled.
|
||||
* Callback to determine if a row should be enabled for selection.
|
||||
*
|
||||
*
|
||||
* TODO: Consider making this optionally a callback with (rowData:T) => boolean, to allow for more dynamic disabling of selection based on row data.
|
||||
* If set to a boolean, it will enable selection for all rows when true, and disable selection for all rows when false.
|
||||
*/
|
||||
disabledSelectionKeys?: SelectedKeysT;
|
||||
enableRowSelection?:
|
||||
| (({ row, id }: { row: T; id: TableRowEntryId }) => boolean)
|
||||
| boolean;
|
||||
/**
|
||||
* If true, stops clicking a row from toggling its selection state. This can be used if you want to only allow selection through the checkboxes/radios, and not have the entire row be clickable for selection.
|
||||
*
|
||||
* @default false
|
||||
* Determines if selection is triggered by clicking the row or the selection control (checkbox/radio).
|
||||
* @default "row"
|
||||
*/
|
||||
disableRowSelectionOnClick?: boolean;
|
||||
selectionTrigger?: "row" | "control";
|
||||
};
|
||||
|
||||
type NoneSelection = {
|
||||
selectionMode: "none";
|
||||
selectedKeys: SelectedKeysT;
|
||||
disabledSelectionKeys: SelectedKeysT;
|
||||
};
|
||||
|
||||
type SingleSelection = {
|
||||
selectionMode: "single";
|
||||
selectedKeys: SelectedKeysT;
|
||||
disabledSelectionKeys: SelectedKeysT;
|
||||
getRowRadioProps: (key: string | number) => RadioInputProps;
|
||||
toggleSelection: (key: string | number) => void;
|
||||
getRowRadioProps: (key: TableRowEntryId, row: any) => RadioInputProps;
|
||||
toggleSelection: (key: TableRowEntryId, row: any) => void;
|
||||
};
|
||||
|
||||
type MultipleSelection = {
|
||||
selectionMode: "multiple";
|
||||
selectedKeys: SelectedKeysT;
|
||||
disabledSelectionKeys: SelectedKeysT;
|
||||
getTheadCheckboxProps: () => CheckboxInputProps;
|
||||
getRowCheckboxProps: (key: string | number) => CheckboxInputProps;
|
||||
toggleSelection: (key: string | number) => void;
|
||||
getRowCheckboxProps: (key: TableRowEntryId, row: any) => CheckboxInputProps;
|
||||
toggleSelection: (key: TableRowEntryId, row: any) => void;
|
||||
};
|
||||
|
||||
type TableSelectionBase = {
|
||||
isRowSelected: (rowId: string | number) => boolean;
|
||||
isRowSelected: (rowId: TableRowEntryId) => boolean;
|
||||
};
|
||||
|
||||
type TableSelection = TableSelectionBase &
|
||||
@@ -75,8 +75,8 @@ type TableSelection = TableSelectionBase &
|
||||
export type {
|
||||
MultipleSelection,
|
||||
NoneSelection,
|
||||
SelectedKeysT,
|
||||
SelectionProps,
|
||||
SingleSelection,
|
||||
TableSelection,
|
||||
SelectedKeysT,
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { TableRowEntryId } from "../../root/DataTable.types";
|
||||
import type { ItemDetail } from "../collectTableRowEntries";
|
||||
import { mutateRowSelection } from "./selection.utils";
|
||||
|
||||
function makeItemDetails<T>(
|
||||
entries: { id: TableRowEntryId; rowData: T }[],
|
||||
): Map<TableRowEntryId, ItemDetail<T>> {
|
||||
return new Map(
|
||||
entries.map(({ id, rowData }) => [
|
||||
id,
|
||||
{ id, rowData, level: 0, parentId: null, children: [] },
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
describe("mutateRowSelection", () => {
|
||||
test("adds rowId to set when checked", () => {
|
||||
const set = new Set<TableRowEntryId>();
|
||||
const itemDetails = makeItemDetails([{ id: "a", rowData: {} }]);
|
||||
const changed = mutateRowSelection({
|
||||
selectedRowIds: set,
|
||||
rowId: "a",
|
||||
checked: true,
|
||||
childRowIdsById: new Map(),
|
||||
itemDetails,
|
||||
});
|
||||
expect(set.has("a")).toBe(true);
|
||||
expect(changed).toBe(true);
|
||||
});
|
||||
|
||||
test("removes rowId from set when unchecked", () => {
|
||||
const set = new Set<TableRowEntryId>(["a"]);
|
||||
const itemDetails = makeItemDetails([{ id: "a", rowData: {} }]);
|
||||
const changed = mutateRowSelection({
|
||||
selectedRowIds: set,
|
||||
rowId: "a",
|
||||
checked: false,
|
||||
childRowIdsById: new Map(),
|
||||
itemDetails,
|
||||
});
|
||||
expect(set.has("a")).toBe(false);
|
||||
expect(changed).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false and does not mutate when row is already in desired state", () => {
|
||||
const set = new Set<TableRowEntryId>(["a"]);
|
||||
const itemDetails = makeItemDetails([{ id: "a", rowData: {} }]);
|
||||
const changed = mutateRowSelection({
|
||||
selectedRowIds: set,
|
||||
rowId: "a",
|
||||
checked: true,
|
||||
childRowIdsById: new Map(),
|
||||
itemDetails,
|
||||
});
|
||||
expect(set.size).toBe(1);
|
||||
expect(changed).toBe(false);
|
||||
});
|
||||
|
||||
test("recursively selects all children", () => {
|
||||
const set = new Set<TableRowEntryId>();
|
||||
const itemDetails = makeItemDetails([
|
||||
{ id: "parent", rowData: {} },
|
||||
{ id: "child1", rowData: {} },
|
||||
{ id: "child2", rowData: {} },
|
||||
]);
|
||||
const childRowIdsById = new Map<TableRowEntryId, TableRowEntryId[]>([
|
||||
["parent", ["child1", "child2"]],
|
||||
]);
|
||||
mutateRowSelection({
|
||||
selectedRowIds: set,
|
||||
rowId: "parent",
|
||||
checked: true,
|
||||
childRowIdsById,
|
||||
itemDetails,
|
||||
});
|
||||
expect(set).toEqual(new Set(["parent", "child1", "child2"]));
|
||||
});
|
||||
|
||||
test("recursively deselects all children", () => {
|
||||
const set = new Set<TableRowEntryId>(["parent", "child1", "child2"]);
|
||||
const itemDetails = makeItemDetails([
|
||||
{ id: "parent", rowData: {} },
|
||||
{ id: "child1", rowData: {} },
|
||||
{ id: "child2", rowData: {} },
|
||||
]);
|
||||
const childRowIdsById = new Map<TableRowEntryId, TableRowEntryId[]>([
|
||||
["parent", ["child1", "child2"]],
|
||||
]);
|
||||
mutateRowSelection({
|
||||
selectedRowIds: set,
|
||||
rowId: "parent",
|
||||
checked: false,
|
||||
childRowIdsById,
|
||||
itemDetails,
|
||||
});
|
||||
expect(set.size).toBe(0);
|
||||
});
|
||||
|
||||
test("handles deeply nested children", () => {
|
||||
const set = new Set<TableRowEntryId>();
|
||||
const itemDetails = makeItemDetails([
|
||||
{ id: "a", rowData: {} },
|
||||
{ id: "a1", rowData: {} },
|
||||
{ id: "a1a", rowData: {} },
|
||||
]);
|
||||
const childRowIdsById = new Map<TableRowEntryId, TableRowEntryId[]>([
|
||||
["a", ["a1"]],
|
||||
["a1", ["a1a"]],
|
||||
]);
|
||||
mutateRowSelection({
|
||||
selectedRowIds: set,
|
||||
rowId: "a",
|
||||
checked: true,
|
||||
childRowIdsById,
|
||||
itemDetails,
|
||||
});
|
||||
expect(set).toEqual(new Set(["a", "a1", "a1a"]));
|
||||
});
|
||||
|
||||
test("skips disabled rows and their children", () => {
|
||||
const set = new Set<TableRowEntryId>();
|
||||
const itemDetails = makeItemDetails([
|
||||
{ id: "parent", rowData: { disabled: false } },
|
||||
{ id: "child1", rowData: { disabled: true } },
|
||||
{ id: "child1a", rowData: { disabled: false } },
|
||||
{ id: "child2", rowData: { disabled: false } },
|
||||
]);
|
||||
const childRowIdsById = new Map<TableRowEntryId, TableRowEntryId[]>([
|
||||
["parent", ["child1", "child2"]],
|
||||
["child1", ["child1a"]],
|
||||
]);
|
||||
mutateRowSelection({
|
||||
selectedRowIds: set,
|
||||
rowId: "parent",
|
||||
checked: true,
|
||||
childRowIdsById,
|
||||
itemDetails,
|
||||
enableRowSelection: ({ row }) => !row.disabled,
|
||||
});
|
||||
expect(set.has("parent")).toBe(true);
|
||||
expect(set.has("child1")).toBe(false);
|
||||
expect(set.has("child1a")).toBe(true);
|
||||
expect(set.has("child2")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when all matching rows were already disabled", () => {
|
||||
const set = new Set<TableRowEntryId>();
|
||||
const itemDetails = makeItemDetails([{ id: "a", rowData: {} }]);
|
||||
const changed = mutateRowSelection({
|
||||
selectedRowIds: set,
|
||||
rowId: "a",
|
||||
checked: true,
|
||||
childRowIdsById: new Map(),
|
||||
itemDetails,
|
||||
enableRowSelection: false,
|
||||
});
|
||||
expect(set.size).toBe(0);
|
||||
expect(changed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { TableRowEntryId } from "../../root/DataTable.types";
|
||||
import type { ItemDetail } from "../collectTableRowEntries";
|
||||
import type { SelectionProps } from "./selection.types";
|
||||
|
||||
function canSelectTableRow<T>(
|
||||
enableRowSelection: SelectionProps<T>["enableRowSelection"],
|
||||
args: { row: T; id: TableRowEntryId },
|
||||
): boolean {
|
||||
if (typeof enableRowSelection === "function") {
|
||||
return enableRowSelection(args);
|
||||
}
|
||||
return enableRowSelection ?? true;
|
||||
}
|
||||
|
||||
type MutateRowSelectionArgs<T> = {
|
||||
selectedRowIds: Set<TableRowEntryId>;
|
||||
rowId: TableRowEntryId;
|
||||
checked: boolean;
|
||||
childRowIdsById: Map<TableRowEntryId, TableRowEntryId[]>;
|
||||
itemDetails: Map<TableRowEntryId, ItemDetail<T>>;
|
||||
enableRowSelection?: SelectionProps<T>["enableRowSelection"];
|
||||
};
|
||||
|
||||
/**
|
||||
* Traverses the row and its children and updates selected-state directly on given selectedRowIds set.
|
||||
* Returns true if any changes were made to the set, false otherwise.
|
||||
*/
|
||||
function mutateRowSelection<T>({
|
||||
selectedRowIds,
|
||||
rowId,
|
||||
checked,
|
||||
childRowIdsById,
|
||||
itemDetails,
|
||||
enableRowSelection,
|
||||
}: MutateRowSelectionArgs<T>): boolean {
|
||||
let changed = false;
|
||||
const item = itemDetails.get(rowId);
|
||||
|
||||
if (
|
||||
item &&
|
||||
canSelectTableRow(enableRowSelection, { row: item.rowData, id: rowId })
|
||||
) {
|
||||
if (checked && !selectedRowIds.has(rowId)) {
|
||||
selectedRowIds.add(rowId);
|
||||
changed = true;
|
||||
} else if (!checked && selectedRowIds.has(rowId)) {
|
||||
selectedRowIds.delete(rowId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const children = childRowIdsById.get(rowId);
|
||||
if (children) {
|
||||
for (const childId of children) {
|
||||
if (
|
||||
mutateRowSelection({
|
||||
selectedRowIds,
|
||||
rowId: childId,
|
||||
checked,
|
||||
childRowIdsById,
|
||||
itemDetails,
|
||||
enableRowSelection,
|
||||
})
|
||||
) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
export { canSelectTableRow, mutateRowSelection };
|
||||
@@ -121,16 +121,70 @@ function prepareCellFocus(cell: Element): HTMLElement | null {
|
||||
/**
|
||||
* Applies focus and scroll to an element.
|
||||
*/
|
||||
function applyFocusAndScroll(element: HTMLElement): void {
|
||||
element.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
function getStickyOffsets(element: HTMLElement): {
|
||||
stickyOffsetStart: number;
|
||||
stickyOffsetEnd: number;
|
||||
stickyHeaderHeight: number;
|
||||
} {
|
||||
const table = element.closest(".aksel-data-table");
|
||||
|
||||
element.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
});
|
||||
if (!table) {
|
||||
return {
|
||||
stickyOffsetStart: 0,
|
||||
stickyOffsetEnd: 0,
|
||||
stickyHeaderHeight: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const stickyHeader = table.querySelector<HTMLElement>(
|
||||
`.aksel-data-table__tr[data-sticky="true"]`,
|
||||
);
|
||||
|
||||
const stickyNodesStart = table.querySelectorAll<HTMLElement>(
|
||||
`.aksel-data-table__column-header[data-sticky="start"]`,
|
||||
);
|
||||
|
||||
const stickyNodesEnd = table.querySelectorAll<HTMLElement>(
|
||||
`.aksel-data-table__column-header[data-sticky="end"]`,
|
||||
);
|
||||
|
||||
return {
|
||||
stickyOffsetStart: Array.from(stickyNodesStart).reduce(
|
||||
(offset, node) => offset + node.getBoundingClientRect().width,
|
||||
0,
|
||||
),
|
||||
stickyOffsetEnd: Array.from(stickyNodesEnd).reduce(
|
||||
(offset, node) => offset + node.getBoundingClientRect().width,
|
||||
0,
|
||||
),
|
||||
stickyHeaderHeight: stickyHeader?.getBoundingClientRect().height ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function applyFocusAndScroll(element: HTMLElement): void {
|
||||
const { stickyOffsetStart, stickyOffsetEnd, stickyHeaderHeight } =
|
||||
getStickyOffsets(element);
|
||||
|
||||
const originalScrollMarginInline = element.style.scrollMarginInline;
|
||||
const originalScrollMarginBlockStart = element.style.scrollMarginBlockStart;
|
||||
|
||||
element.style.scrollMarginInline = `${stickyOffsetStart}px ${stickyOffsetEnd}px`;
|
||||
element.style.scrollMarginBlockStart = `${stickyHeaderHeight}px`;
|
||||
|
||||
try {
|
||||
element.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
|
||||
element.scrollIntoView({
|
||||
behavior: "auto",
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
});
|
||||
} finally {
|
||||
element.style.scrollMarginBlockStart = originalScrollMarginBlockStart;
|
||||
element.style.scrollMarginInline = originalScrollMarginInline;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,8 +16,7 @@ type NavigationAction =
|
||||
|
||||
/**
|
||||
* Maps keyboard events to navigation actions.
|
||||
* Supports arrow keys, Home/End (row navigation), Ctrl/Cmd+Home/End (table navigation),
|
||||
* and PageUp/PageDown (multi-row navigation).
|
||||
* Supports arrow keys, Home/End (row navigation), Ctrl/Cmd+Home/End (table navigation).
|
||||
*/
|
||||
function getNavigationAction(event: KeyboardEvent): NavigationAction | null {
|
||||
const key = event.key;
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
DataTableExpansionProvider,
|
||||
useDataTableExpansion,
|
||||
} from "../useTableExpansion";
|
||||
import { useTableItems } from "../useTableItems";
|
||||
|
||||
type TestRow = {
|
||||
id: number;
|
||||
subRows?: TestRow[];
|
||||
};
|
||||
|
||||
function createWrapper(
|
||||
options: {
|
||||
onDetailsPanelChange?: (ids: (string | number)[]) => void;
|
||||
isDetailsPanelExpandable?: (row: TestRow) => boolean;
|
||||
} = {},
|
||||
) {
|
||||
const rows: TestRow[] = [{ id: 1, subRows: [{ id: 10 }] }, { id: 2 }];
|
||||
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const tableItems = useTableItems({
|
||||
items: rows,
|
||||
getRowId: (row) => row.id,
|
||||
getSubRows: (row) => row.subRows ?? [],
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTableExpansionProvider<TestRow>
|
||||
itemDetails={tableItems.itemDetails}
|
||||
getDetailsPanelContent={(row) => row.id}
|
||||
isDetailsPanelExpandable={options.isDetailsPanelExpandable}
|
||||
onDetailsPanelChange={options.onDetailsPanelChange}
|
||||
>
|
||||
{children}
|
||||
</DataTableExpansionProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe("useTableExpansion", () => {
|
||||
test("does not allow toggling rows that are not expandable", () => {
|
||||
const onDetailsPanelChange = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useDataTableExpansion(), {
|
||||
wrapper: createWrapper({
|
||||
onDetailsPanelChange,
|
||||
isDetailsPanelExpandable: (row) => row.id === 1,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.current.isDetailsPanelExpandable(1)).toBe(true);
|
||||
expect(result.current.isDetailsPanelExpandable(2)).toBe(false);
|
||||
expect(result.current.isDetailsPanelExpandable(10)).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleExpansion(2);
|
||||
});
|
||||
|
||||
expect(result.current.isExpanded(2)).toBe(false);
|
||||
expect(onDetailsPanelChange).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
result.current.toggleExpansion(1);
|
||||
});
|
||||
|
||||
expect(result.current.isExpanded(1)).toBe(true);
|
||||
expect(onDetailsPanelChange).toHaveBeenCalledWith([1]);
|
||||
});
|
||||
|
||||
test("expand all only expands expandable rows", () => {
|
||||
const onDetailsPanelChange = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useDataTableExpansion(), {
|
||||
wrapper: createWrapper({
|
||||
onDetailsPanelChange,
|
||||
isDetailsPanelExpandable: (row) => row.id === 1,
|
||||
}),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.toggleAll();
|
||||
});
|
||||
|
||||
expect(result.current.isExpanded(1)).toBe(true);
|
||||
expect(result.current.isExpanded(2)).toBe(false);
|
||||
expect(result.current.isAllExpanded).toBe(true);
|
||||
expect(onDetailsPanelChange).toHaveBeenCalledWith([1]);
|
||||
});
|
||||
|
||||
test("expand all only targets top-level table items", () => {
|
||||
const onDetailsPanelChange = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useDataTableExpansion(), {
|
||||
wrapper: createWrapper({
|
||||
onDetailsPanelChange,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.current.isDetailsPanelExpandable(1)).toBe(true);
|
||||
expect(result.current.isDetailsPanelExpandable(2)).toBe(true);
|
||||
expect(result.current.isDetailsPanelExpandable(10)).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleAll();
|
||||
});
|
||||
|
||||
expect(result.current.isExpanded(1)).toBe(true);
|
||||
expect(result.current.isExpanded(2)).toBe(true);
|
||||
expect(result.current.isExpanded(10)).toBe(false);
|
||||
expect(onDetailsPanelChange).toHaveBeenCalledWith([1, 2]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { TableRowEntryId } from "../../root/DataTable.types";
|
||||
import { useTableItems } from "../useTableItems";
|
||||
|
||||
type TestRow = {
|
||||
@@ -45,6 +46,12 @@ const fallbackRows: FallbackTestRow[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const duplicatedRowObject: TestRow = {
|
||||
id: "shared",
|
||||
name: "Shared",
|
||||
subRows: [{ id: "shared-child", name: "Child" }],
|
||||
};
|
||||
|
||||
const getSubRows = (row: TestRow) => row.subRows ?? [];
|
||||
|
||||
const getVisibleIds = (rows: TestRow[]) => rows.map((row) => row.id);
|
||||
@@ -59,16 +66,18 @@ describe("useTableItems", () => {
|
||||
);
|
||||
|
||||
expect(getVisibleIds(result.current.items)).toEqual(["a", "b"]);
|
||||
expect(result.current.itemDetails.get(plainRows[0])).toMatchObject({
|
||||
expect(result.current.itemDetails.get("a")).toMatchObject({
|
||||
id: "a",
|
||||
rowData: plainRows[0],
|
||||
level: 0,
|
||||
parent: null,
|
||||
parentId: null,
|
||||
children: [],
|
||||
});
|
||||
expect(result.current.itemDetails.get(plainRows[1])).toMatchObject({
|
||||
expect(result.current.itemDetails.get("b")).toMatchObject({
|
||||
id: "b",
|
||||
rowData: plainRows[1],
|
||||
level: 0,
|
||||
parent: null,
|
||||
parentId: null,
|
||||
children: [],
|
||||
});
|
||||
});
|
||||
@@ -78,8 +87,10 @@ describe("useTableItems", () => {
|
||||
useTableItems({
|
||||
items: nestedRows,
|
||||
getRowId: (row) => row.id,
|
||||
getSubRows,
|
||||
defaultExpandedSubRowIds: ["a"],
|
||||
subRows: {
|
||||
getRows: getSubRows,
|
||||
defaultExpandedRowIds: ["a"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -91,7 +102,7 @@ describe("useTableItems", () => {
|
||||
useTableItems({
|
||||
items: nestedRows,
|
||||
getRowId: (row) => row.id,
|
||||
getSubRows,
|
||||
subRows: { getRows: getSubRows },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -100,12 +111,14 @@ describe("useTableItems", () => {
|
||||
expect(result.current.childRowIdsById.get("b")).toEqual(["b1"]);
|
||||
});
|
||||
|
||||
test("uses the same fallback root id to reveal child rows when getRowId is omitted", () => {
|
||||
test("uses unique fallback ids to reveal child rows when getRowId is omitted", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableItems({
|
||||
items: fallbackRows,
|
||||
getSubRows: (row) => row.subRows ?? [],
|
||||
defaultExpandedSubRowIds: [0],
|
||||
subRows: {
|
||||
getRows: (row: any) => row.subRows ?? [],
|
||||
defaultExpandedRowIds: ["0"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -113,6 +126,7 @@ describe("useTableItems", () => {
|
||||
"Parent",
|
||||
"Child",
|
||||
]);
|
||||
expect(result.current.childRowIdsById.get("0")).toEqual(["0.0"]);
|
||||
});
|
||||
|
||||
test("updates visible rows in depth-first order for controlled expanded ids", () => {
|
||||
@@ -121,11 +135,13 @@ describe("useTableItems", () => {
|
||||
useTableItems({
|
||||
items: nestedRows,
|
||||
getRowId: (row) => row.id,
|
||||
getSubRows,
|
||||
expandedSubRowIds: expandedIds,
|
||||
subRows: {
|
||||
getRows: getSubRows,
|
||||
expandedRowIds: expandedIds,
|
||||
},
|
||||
}),
|
||||
{
|
||||
initialProps: { expandedIds: [] as (string | number)[] },
|
||||
initialProps: { expandedIds: [] as TableRowEntryId[] },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -142,4 +158,35 @@ describe("useTableItems", () => {
|
||||
"b1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("tracks duplicated row objects by row id instead of object identity", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableItems({
|
||||
items: [duplicatedRowObject, duplicatedRowObject],
|
||||
subRows: {
|
||||
getRows: getSubRows,
|
||||
defaultExpandedRowIds: ["0"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleRowIds).toEqual(["0", "0.0", "1"]);
|
||||
expect(getVisibleIds(result.current.items)).toEqual([
|
||||
"shared",
|
||||
"shared-child",
|
||||
"shared",
|
||||
]);
|
||||
expect(result.current.itemDetails.get("0")).toMatchObject({
|
||||
id: "0",
|
||||
rowData: duplicatedRowObject,
|
||||
parentId: null,
|
||||
children: ["0.0"],
|
||||
});
|
||||
expect(result.current.itemDetails.get("1")).toMatchObject({
|
||||
id: "1",
|
||||
rowData: duplicatedRowObject,
|
||||
parentId: null,
|
||||
children: ["1.0"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,475 +0,0 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type {
|
||||
MultipleSelection,
|
||||
SingleSelection,
|
||||
} from "../../helpers/selection/selection.types";
|
||||
import {
|
||||
type UseTableSelectionReturn,
|
||||
useTableSelection,
|
||||
} from "../useTableSelection";
|
||||
|
||||
type Item = { id: string; name: string };
|
||||
|
||||
const items: Item[] = [
|
||||
{ id: "a", name: "Alpha" },
|
||||
{ id: "b", name: "Beta" },
|
||||
{ id: "c", name: "Charlie" },
|
||||
];
|
||||
|
||||
const visibleRowIds = items.map((item) => item.id);
|
||||
const childRowIdsById = new Map<string | number, (string | number)[]>([
|
||||
["a", ["a1", "a2"]],
|
||||
["a1", []],
|
||||
["a2", ["a2a"]],
|
||||
["a2a", []],
|
||||
]);
|
||||
|
||||
function asSingle(result: {
|
||||
current: UseTableSelectionReturn;
|
||||
}): SingleSelection {
|
||||
return result.current.selection as SingleSelection;
|
||||
}
|
||||
|
||||
function asMultiple(result: {
|
||||
current: UseTableSelectionReturn;
|
||||
}): MultipleSelection {
|
||||
return result.current.selection as MultipleSelection;
|
||||
}
|
||||
|
||||
describe("useTableSelection", () => {
|
||||
describe('selectionMode="none"', () => {
|
||||
test("returns empty selectedKeys and no prop getters", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "none",
|
||||
visibleRowIds,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.selection.selectionMode).toBe("none");
|
||||
expect(result.current.selection.selectedKeys).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectionMode="single"', () => {
|
||||
test("returns getRowRadioProps", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "single",
|
||||
visibleRowIds,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.selection.selectionMode).toBe("single");
|
||||
expect(asSingle(result).getRowRadioProps).toBeDefined();
|
||||
});
|
||||
|
||||
test("selecting a row via radio onChange", () => {
|
||||
const onChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "single",
|
||||
visibleRowIds,
|
||||
onSelectionChange: onChange,
|
||||
}),
|
||||
);
|
||||
|
||||
const radioProps = asSingle(result).getRowRadioProps("a");
|
||||
expect(radioProps.checked).toBe(false);
|
||||
|
||||
act(() => {
|
||||
radioProps.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
expect(asSingle(result).selectedKeys).toEqual(["a"]);
|
||||
});
|
||||
|
||||
test("toggling the same row keeps it selected", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "single",
|
||||
visibleRowIds,
|
||||
defaultSelectedKeys: ["a"],
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
asSingle(result)
|
||||
.getRowRadioProps("a")
|
||||
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
expect(asSingle(result).selectedKeys).toEqual(["a"]);
|
||||
});
|
||||
|
||||
test("selecting a new row replaces the previous", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "single",
|
||||
visibleRowIds,
|
||||
defaultSelectedKeys: ["a"],
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
asSingle(result)
|
||||
.getRowRadioProps("b")
|
||||
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
expect(asSingle(result).selectedKeys).toEqual(["b"]);
|
||||
});
|
||||
|
||||
test("disabled rows have disabled prop", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "single",
|
||||
visibleRowIds,
|
||||
disabledSelectionKeys: ["b"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(asSingle(result).getRowRadioProps("a").disabled).toBe(false);
|
||||
expect(asSingle(result).getRowRadioProps("b").disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("controlled selectedKeys", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ selectedKeys }) =>
|
||||
useTableSelection({
|
||||
selectionMode: "single",
|
||||
visibleRowIds,
|
||||
selectedKeys,
|
||||
}),
|
||||
{ initialProps: { selectedKeys: ["a"] as (string | number)[] } },
|
||||
);
|
||||
|
||||
expect(asSingle(result).selectedKeys).toEqual(["a"]);
|
||||
|
||||
rerender({ selectedKeys: ["b"] });
|
||||
expect(asSingle(result).selectedKeys).toEqual(["b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectionMode="multiple"', () => {
|
||||
test("returns getTheadCheckboxProps and getRowCheckboxProps", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
visibleRowIds,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.selection.selectionMode).toBe("multiple");
|
||||
expect(asMultiple(result).getTheadCheckboxProps).toBeDefined();
|
||||
expect(asMultiple(result).getRowCheckboxProps).toBeDefined();
|
||||
});
|
||||
|
||||
test("selecting individual rows", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
visibleRowIds,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
asMultiple(result)
|
||||
.getRowCheckboxProps("a")
|
||||
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
expect(asMultiple(result).selectedKeys).toEqual(["a"]);
|
||||
|
||||
act(() => {
|
||||
asMultiple(result)
|
||||
.getRowCheckboxProps("c")
|
||||
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
expect(asMultiple(result).selectedKeys).toEqual(["a", "c"]);
|
||||
});
|
||||
|
||||
test("deselecting a row", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
visibleRowIds,
|
||||
defaultSelectedKeys: ["a", "b"],
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
asMultiple(result)
|
||||
.getRowCheckboxProps("a")
|
||||
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
expect(asMultiple(result).selectedKeys).toEqual(["b"]);
|
||||
});
|
||||
|
||||
test("select all via thead checkbox", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
visibleRowIds,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
asMultiple(result)
|
||||
.getTheadCheckboxProps()
|
||||
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
expect(asMultiple(result).selectedKeys).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("select all via thead includes hidden descendants for visible parents", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
visibleRowIds: ["a"],
|
||||
childRowIdsById,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
asMultiple(result)
|
||||
.getTheadCheckboxProps()
|
||||
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
expect(asMultiple(result).selectedKeys).toEqual(["a", "a1", "a2", "a2a"]);
|
||||
});
|
||||
|
||||
test("deselect all when all are selected", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
visibleRowIds,
|
||||
defaultSelectedKeys: ["a", "b", "c"],
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
asMultiple(result)
|
||||
.getTheadCheckboxProps()
|
||||
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
expect(asMultiple(result).selectedKeys).toEqual([]);
|
||||
});
|
||||
|
||||
test("deselect all clears hidden descendants for visible parents but preserves unrelated keys", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
visibleRowIds: ["a"],
|
||||
childRowIdsById,
|
||||
defaultSelectedKeys: ["a", "a1", "a2", "a2a", "external"],
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
asMultiple(result)
|
||||
.getTheadCheckboxProps()
|
||||
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
expect(asMultiple(result).selectedKeys).toEqual(["external"]);
|
||||
});
|
||||
|
||||
test("select all skips disabled keys", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
visibleRowIds,
|
||||
disabledSelectionKeys: ["b"],
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
asMultiple(result)
|
||||
.getTheadCheckboxProps()
|
||||
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
expect(asMultiple(result).selectedKeys).toEqual(["a", "c"]);
|
||||
});
|
||||
|
||||
test("deselect all preserves disabled-but-selected rows", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
visibleRowIds,
|
||||
defaultSelectedKeys: ["a", "b", "c"],
|
||||
disabledSelectionKeys: ["b"],
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
asMultiple(result)
|
||||
.getTheadCheckboxProps()
|
||||
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
expect(asMultiple(result).selectedKeys).toEqual(["b"]);
|
||||
});
|
||||
|
||||
test("thead checkbox shows indeterminate when partially selected", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
visibleRowIds,
|
||||
defaultSelectedKeys: ["a"],
|
||||
}),
|
||||
);
|
||||
|
||||
const theadProps = asMultiple(result).getTheadCheckboxProps();
|
||||
expect(theadProps.indeterminate).toBe(true);
|
||||
expect(theadProps.checked).toBe(false);
|
||||
});
|
||||
|
||||
test("thead checkbox shows checked when all selected", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
visibleRowIds,
|
||||
defaultSelectedKeys: ["a", "b", "c"],
|
||||
}),
|
||||
);
|
||||
|
||||
const theadProps = asMultiple(result).getTheadCheckboxProps();
|
||||
expect(theadProps.indeterminate).toBe(false);
|
||||
expect(theadProps.checked).toBe(true);
|
||||
});
|
||||
|
||||
test("thead checkbox shows checked when all selectable rows are selected", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
visibleRowIds,
|
||||
defaultSelectedKeys: ["a", "c"],
|
||||
disabledSelectionKeys: ["b"],
|
||||
}),
|
||||
);
|
||||
|
||||
const theadProps = asMultiple(result).getTheadCheckboxProps();
|
||||
expect(theadProps.indeterminate).toBe(false);
|
||||
expect(theadProps.checked).toBe(true);
|
||||
});
|
||||
|
||||
test("deselecting one row when all rows are selected", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
visibleRowIds,
|
||||
defaultSelectedKeys: ["a", "b", "c"],
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
asMultiple(result)
|
||||
.getRowCheckboxProps("b")
|
||||
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
expect(asMultiple(result).selectedKeys).toEqual(["a", "c"]);
|
||||
});
|
||||
|
||||
test("disabled rows have disabled prop", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
visibleRowIds,
|
||||
disabledSelectionKeys: ["b"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(asMultiple(result).getRowCheckboxProps("a").disabled).toBe(false);
|
||||
expect(asMultiple(result).getRowCheckboxProps("b").disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("thead checkbox disabled when all rows disabled", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
visibleRowIds,
|
||||
disabledSelectionKeys: ["a", "b", "c"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(asMultiple(result).getTheadCheckboxProps().disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("parent rows show indeterminate when visible descendants are partially selected", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
visibleRowIds: ["a", "a1", "a2"],
|
||||
childRowIdsById,
|
||||
defaultSelectedKeys: ["a1"],
|
||||
}),
|
||||
);
|
||||
|
||||
const parentProps = asMultiple(result).getRowCheckboxProps("a");
|
||||
|
||||
expect(parentProps.checked).toBe(false);
|
||||
expect(parentProps.indeterminate).toBe(true);
|
||||
});
|
||||
|
||||
test("toggling a parent row selects and deselects its descendants", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
visibleRowIds: ["a", "a1", "a2"],
|
||||
childRowIdsById,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
asMultiple(result)
|
||||
.getRowCheckboxProps("a")
|
||||
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
expect(asMultiple(result).selectedKeys).toEqual(["a", "a1", "a2", "a2a"]);
|
||||
|
||||
act(() => {
|
||||
asMultiple(result)
|
||||
.getRowCheckboxProps("a")
|
||||
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
expect(asMultiple(result).selectedKeys).toEqual([]);
|
||||
});
|
||||
|
||||
test("toggling a collapsed parent selects and deselects hidden descendants", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
visibleRowIds: ["a"],
|
||||
childRowIdsById,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
asMultiple(result)
|
||||
.getRowCheckboxProps("a")
|
||||
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
expect(asMultiple(result).selectedKeys).toEqual(["a", "a1", "a2", "a2a"]);
|
||||
|
||||
act(() => {
|
||||
asMultiple(result)
|
||||
.getRowCheckboxProps("a")
|
||||
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
expect(asMultiple(result).selectedKeys).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,48 +1,96 @@
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
ColumnDefinition,
|
||||
ColumnDefinitions,
|
||||
} from "../root/DataTable.types";
|
||||
import type { SelectionProps } from "./useTableSelection";
|
||||
import { ACTION_CELL_WIDTH } from "../tr/DataTableTr";
|
||||
|
||||
type UseColumnOptions = {
|
||||
stickyColumns?: {
|
||||
first?: "1";
|
||||
last?: "1";
|
||||
start?: "1";
|
||||
end?: "1";
|
||||
};
|
||||
selectionMode: SelectionProps["selectionMode"];
|
||||
hasSelection: boolean;
|
||||
hasDetailsPanel: boolean;
|
||||
layout: "fixed" | "auto";
|
||||
};
|
||||
|
||||
type StickyStartState = {
|
||||
selection: boolean;
|
||||
expansion: boolean;
|
||||
selectionOffset: number;
|
||||
firstColumnOffset: number;
|
||||
};
|
||||
|
||||
type UseColumnOptionsResult<T> = {
|
||||
columns: {
|
||||
isSticky: "start" | "end" | false;
|
||||
isStickyLast?: boolean;
|
||||
stickyLeftOffset?: number;
|
||||
colDef: ColumnDefinition<T>;
|
||||
}[];
|
||||
stickySelection: boolean;
|
||||
stickyStart: StickyStartState;
|
||||
totalColSpan: number;
|
||||
};
|
||||
|
||||
function useColumnOptions<T>(
|
||||
columnDefinitions: ColumnDefinitions<T>,
|
||||
options: UseColumnOptions,
|
||||
): UseColumnOptionsResult<T> {
|
||||
const { stickyColumns, selectionMode } = options;
|
||||
const { stickyColumns, hasSelection, hasDetailsPanel, layout } = options;
|
||||
|
||||
const hasSelection = selectionMode !== "none";
|
||||
const hasStickyStart = stickyColumns?.start === "1";
|
||||
|
||||
return {
|
||||
stickySelection: selectionMode !== "none" && stickyColumns?.first === "1",
|
||||
columns: columnDefinitions.map((colDef, index) => {
|
||||
const isFirstSticky =
|
||||
stickyColumns?.first === "1" && index === 0 && !hasSelection;
|
||||
const stickyExpansion = hasStickyStart && hasDetailsPanel;
|
||||
const stickySelection = hasStickyStart && hasSelection;
|
||||
|
||||
const stickySelectionOffset = stickyExpansion ? ACTION_CELL_WIDTH : 0;
|
||||
const stickyFirstColumnOffset =
|
||||
(stickyExpansion ? ACTION_CELL_WIDTH : 0) +
|
||||
(stickySelection ? ACTION_CELL_WIDTH : 0);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return columnDefinitions.map((colDef, index) => {
|
||||
const isFirstSticky = hasStickyStart && index === 0;
|
||||
const isLastSticky =
|
||||
stickyColumns?.last === "1" && index === columnDefinitions.length - 1;
|
||||
stickyColumns?.end === "1" && index === columnDefinitions.length - 1;
|
||||
|
||||
return {
|
||||
isSticky: isFirstSticky ? "start" : isLastSticky ? "end" : false,
|
||||
isSticky: isFirstSticky
|
||||
? ("start" as const)
|
||||
: isLastSticky
|
||||
? ("end" as const)
|
||||
: (false as const),
|
||||
isStickyLast: isFirstSticky && !isLastSticky,
|
||||
stickyLeftOffset: isFirstSticky ? stickyFirstColumnOffset : undefined,
|
||||
colDef,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}, [
|
||||
columnDefinitions,
|
||||
hasStickyStart,
|
||||
stickyColumns,
|
||||
stickyFirstColumnOffset,
|
||||
]);
|
||||
|
||||
const totalColSpan =
|
||||
columns.length +
|
||||
(layout === "fixed" ? 1 : 0) +
|
||||
(hasSelection ? 1 : 0) +
|
||||
(hasDetailsPanel ? 1 : 0);
|
||||
|
||||
return {
|
||||
stickyStart: {
|
||||
selection: stickySelection,
|
||||
expansion: stickyExpansion,
|
||||
selectionOffset: stickySelectionOffset,
|
||||
firstColumnOffset: stickyFirstColumnOffset,
|
||||
},
|
||||
columns,
|
||||
totalColSpan,
|
||||
};
|
||||
}
|
||||
|
||||
export { useColumnOptions };
|
||||
export type { StickyStartState };
|
||||
export type { UseColumnOptionsResult };
|
||||
@@ -14,7 +14,7 @@ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
|
||||
});
|
||||
|
||||
const [activeCell, setActiveCell] = useState<Element | null>(null);
|
||||
const activeCellRef = useValueAsRef(activeCell).current;
|
||||
const activeCellRef = useValueAsRef(activeCell);
|
||||
const observerRef = useRef<MutationObserver | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -24,7 +24,8 @@ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
|
||||
|
||||
observerRef.current = new MutationObserver(() => {
|
||||
gridCacheRef.current.dirty = true;
|
||||
if (activeCellRef && !activeCellRef.isConnected) {
|
||||
|
||||
if (activeCellRef.current && !activeCellRef.current.isConnected) {
|
||||
setActiveCell(null);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { createStrictContext } from "../../../utils/helpers";
|
||||
import { useControllableState } from "../../../utils/hooks";
|
||||
import type { TableRowEntryId } from "../root/DataTable.types";
|
||||
import { useDataTableContext } from "../root/DataTableRoot.context";
|
||||
|
||||
type DetailsPanelProps<T> = {
|
||||
/**
|
||||
* Function to get the content to show in the details panel for a given row.
|
||||
* When provided, an expand toggle column is added automatically.
|
||||
*/
|
||||
getContent: (rowData: T) => React.ReactNode;
|
||||
/**
|
||||
* Determines whether a row can be expanded to show details panel content.
|
||||
* @default () => true
|
||||
*/
|
||||
isRowExpandable?: (rowData: T) => boolean;
|
||||
/**
|
||||
* Controlled list of expanded row IDs.
|
||||
* Use with `onDetailsPanelChange` for controlled usage, or `defaultDetailsPanelRowIds` for uncontrolled.
|
||||
*/
|
||||
expandedRowIds?: TableRowEntryId[];
|
||||
/**
|
||||
* Initial list of expanded row IDs for uncontrolled usage.
|
||||
* @default []
|
||||
*/
|
||||
defaultExpandedRowIds?: TableRowEntryId[];
|
||||
/**
|
||||
* Called when the list of expanded row IDs changes.
|
||||
*/
|
||||
onExpandedRowIdsChange?: (ids: TableRowEntryId[]) => void; // TODO: Docs: This pattern is called "Master / Detail" in general terms
|
||||
/**
|
||||
* Returns the height (in px) or `"auto"` for a row's details panel.
|
||||
* When a number is returned, the panel scrolls within that fixed height.
|
||||
* @default "auto"
|
||||
*/
|
||||
getHeight?: (rowData: T) => number | "auto";
|
||||
/**
|
||||
* Shows an expand-all toggle button in the expand column header.
|
||||
* @default false
|
||||
*/
|
||||
showExpandAll?: boolean;
|
||||
};
|
||||
|
||||
type DataTableDetailsPanelContextT = {
|
||||
isExpanded: (id: TableRowEntryId) => boolean;
|
||||
isDetailsPanelExpandable: (id: TableRowEntryId) => boolean;
|
||||
toggleExpansion: (id: TableRowEntryId) => void;
|
||||
toggleAll: () => void;
|
||||
isAllExpanded: boolean;
|
||||
getDetailsPanelContent?: (row: unknown) => React.ReactNode;
|
||||
getDetailsPanelHeight?: (row: unknown) => number | "auto";
|
||||
showExpandAll: boolean;
|
||||
enableDetailsPanel: boolean;
|
||||
};
|
||||
|
||||
const {
|
||||
Provider: DataTableDetailsPanelContextProvider,
|
||||
useContext: useDataTableDetailsPanel,
|
||||
} = createStrictContext<DataTableDetailsPanelContextT>({
|
||||
name: "DataTableDetailsPanelContext",
|
||||
errorMessage:
|
||||
"useDataTableDetailsPanel must be used within a DataTableDetailsPanelProvider.",
|
||||
});
|
||||
|
||||
function DataTableDetailsPanelProvider<T>({
|
||||
children,
|
||||
detailsPanel,
|
||||
}: { detailsPanel?: DetailsPanelProps<T> } & { children: React.ReactNode }) {
|
||||
const {
|
||||
expandedRowIds,
|
||||
defaultExpandedRowIds = [],
|
||||
onExpandedRowIdsChange,
|
||||
getContent,
|
||||
isRowExpandable,
|
||||
getHeight,
|
||||
showExpandAll = false,
|
||||
} = detailsPanel || {};
|
||||
|
||||
const [expandedIds, setExpandedIds] = useControllableState({
|
||||
value: expandedRowIds,
|
||||
defaultValue: defaultExpandedRowIds,
|
||||
onChange: onExpandedRowIdsChange,
|
||||
});
|
||||
|
||||
/* TODO: False is just fallback until auto and root is merged */
|
||||
const tableContext = useDataTableContext(false);
|
||||
|
||||
const { itemDetails } = tableContext?.tableItems ?? {
|
||||
itemDetails: new Map<
|
||||
TableRowEntryId,
|
||||
{ rowData: T; id: TableRowEntryId; level: number }
|
||||
>(),
|
||||
};
|
||||
|
||||
const expandableIds = React.useMemo(() => {
|
||||
if (!getContent) {
|
||||
return new Set<TableRowEntryId>();
|
||||
}
|
||||
|
||||
const ids = new Set<TableRowEntryId>();
|
||||
|
||||
for (const { rowData, id, level } of itemDetails.values()) {
|
||||
/* We only allow Master - Details pattern on top level rows */
|
||||
if (level > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isRowExpandable || isRowExpandable(rowData)) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}, [getContent, isRowExpandable, itemDetails]);
|
||||
|
||||
const isDetailsPanelExpandableById = useCallback(
|
||||
(id: TableRowEntryId) => expandableIds.has(id),
|
||||
[expandableIds],
|
||||
);
|
||||
|
||||
const isExpanded = useCallback(
|
||||
(id: TableRowEntryId) =>
|
||||
isDetailsPanelExpandableById(id) && expandedIds.includes(id),
|
||||
[expandedIds, isDetailsPanelExpandableById],
|
||||
);
|
||||
|
||||
const toggleExpansion = useCallback(
|
||||
(id: TableRowEntryId) => {
|
||||
if (!isDetailsPanelExpandableById(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedIds((currentExpandedIds) =>
|
||||
currentExpandedIds.includes(id)
|
||||
? currentExpandedIds.filter((expandedId) => expandedId !== id)
|
||||
: [...currentExpandedIds, id],
|
||||
);
|
||||
},
|
||||
[isDetailsPanelExpandableById, setExpandedIds],
|
||||
);
|
||||
|
||||
const isAllExpanded =
|
||||
expandableIds.size > 0 &&
|
||||
Array.from(expandableIds).every((key) => expandedIds.includes(key));
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
setExpandedIds(isAllExpanded ? [] : Array.from(expandableIds));
|
||||
}, [expandableIds, isAllExpanded, setExpandedIds]);
|
||||
|
||||
return (
|
||||
<DataTableDetailsPanelContextProvider
|
||||
isExpanded={isExpanded}
|
||||
isDetailsPanelExpandable={isDetailsPanelExpandableById}
|
||||
toggleExpansion={toggleExpansion}
|
||||
toggleAll={toggleAll}
|
||||
isAllExpanded={isAllExpanded}
|
||||
getDetailsPanelContent={
|
||||
getContent as ((row: unknown) => React.ReactNode) | undefined
|
||||
}
|
||||
getDetailsPanelHeight={
|
||||
getHeight as ((row: unknown) => number | "auto") | undefined
|
||||
}
|
||||
showExpandAll={showExpandAll}
|
||||
enableDetailsPanel={!!getContent}
|
||||
>
|
||||
{children}
|
||||
</DataTableDetailsPanelContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function getDataTableDetailsPanelId(tableId: string, rowId: TableRowEntryId) {
|
||||
return `${tableId}-expansion-${rowId}`;
|
||||
}
|
||||
|
||||
export {
|
||||
DataTableDetailsPanelProvider,
|
||||
getDataTableDetailsPanelId,
|
||||
useDataTableDetailsPanel,
|
||||
};
|
||||
|
||||
export type { DetailsPanelProps };
|
||||
@@ -1,146 +0,0 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { createStrictContext } from "../../../utils/helpers";
|
||||
import { useControllableState } from "../../../utils/hooks";
|
||||
import { useTableItemsContext } from "./useTableItems";
|
||||
|
||||
type DataTableExpansionContextT = {
|
||||
isExpanded: (id: string | number) => boolean;
|
||||
isDetailsPanelExpandable: (id: string | number) => boolean;
|
||||
toggleExpansion: (id: string | number) => void;
|
||||
toggleAll: () => void;
|
||||
isAllExpanded: boolean;
|
||||
getDetailsPanelContent?: (row: unknown) => React.ReactNode;
|
||||
getDetailsPanelHeight?: (row: unknown) => number | "auto";
|
||||
showExpandAll: boolean;
|
||||
enableDetailsPanel: boolean;
|
||||
};
|
||||
|
||||
const {
|
||||
Provider: DataTableExpansionContextProvider,
|
||||
useContext: useDataTableExpansion,
|
||||
} = createStrictContext<DataTableExpansionContextT>({
|
||||
name: "DataTableExpansionContext",
|
||||
errorMessage:
|
||||
"useDataTableExpansion must be used within a DataTableExpansionProvider.",
|
||||
});
|
||||
|
||||
type TableExpansionOptions<T> = {
|
||||
detailsPanelRowIds?: (string | number)[];
|
||||
defaultDetailsPanelRowIds?: (string | number)[];
|
||||
onDetailsPanelChange?: (ids: (string | number)[]) => void;
|
||||
getDetailsPanelContent?: (row: T) => React.ReactNode;
|
||||
isDetailsPanelExpandable?: (rowData: T) => boolean;
|
||||
getDetailsPanelHeight?: (row: T) => number | "auto";
|
||||
showExpandAll?: boolean;
|
||||
};
|
||||
|
||||
function getDataTableExpansionId(tableId: string, rowId: string | number) {
|
||||
return `${tableId}-expansion-${rowId}`;
|
||||
}
|
||||
|
||||
function DataTableExpansionProvider<T>({
|
||||
children,
|
||||
detailsPanelRowIds,
|
||||
defaultDetailsPanelRowIds = [],
|
||||
onDetailsPanelChange,
|
||||
getDetailsPanelContent,
|
||||
isDetailsPanelExpandable,
|
||||
getDetailsPanelHeight,
|
||||
showExpandAll = false,
|
||||
}: TableExpansionOptions<T> & { children: React.ReactNode }) {
|
||||
const [expandedIds, setExpandedIds] = useControllableState({
|
||||
value: detailsPanelRowIds,
|
||||
defaultValue: defaultDetailsPanelRowIds,
|
||||
onChange: onDetailsPanelChange,
|
||||
});
|
||||
|
||||
/* TODO: False is just fallback until auto and root is merged */
|
||||
const tableItemsContext = useTableItemsContext(false);
|
||||
|
||||
const { itemDetails } = tableItemsContext ?? {
|
||||
itemDetails: new Map(),
|
||||
};
|
||||
|
||||
const expandableIds = React.useMemo(() => {
|
||||
if (!getDetailsPanelContent) {
|
||||
return new Set<string | number>();
|
||||
}
|
||||
|
||||
const ids = new Set<string | number>();
|
||||
|
||||
for (const [rowData, { id, level }] of itemDetails.entries()) {
|
||||
/* We only allow Master - Details pattern on top level rows */
|
||||
if (level > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isDetailsPanelExpandable || isDetailsPanelExpandable(rowData)) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}, [getDetailsPanelContent, isDetailsPanelExpandable, itemDetails]);
|
||||
|
||||
const isDetailsPanelExpandableById = useCallback(
|
||||
(id: string | number) => expandableIds.has(id),
|
||||
[expandableIds],
|
||||
);
|
||||
|
||||
const isExpanded = useCallback(
|
||||
(id: string | number) =>
|
||||
isDetailsPanelExpandableById(id) && expandedIds.includes(id),
|
||||
[expandedIds, isDetailsPanelExpandableById],
|
||||
);
|
||||
|
||||
const toggleExpansion = useCallback(
|
||||
(id: string | number) => {
|
||||
if (!isDetailsPanelExpandableById(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedIds((currentExpandedIds) =>
|
||||
currentExpandedIds.includes(id)
|
||||
? currentExpandedIds.filter((expandedId) => expandedId !== id)
|
||||
: [...currentExpandedIds, id],
|
||||
);
|
||||
},
|
||||
[isDetailsPanelExpandableById, setExpandedIds],
|
||||
);
|
||||
|
||||
const isAllExpanded =
|
||||
expandableIds.size > 0 &&
|
||||
Array.from(expandableIds).every((key) => expandedIds.includes(key));
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
setExpandedIds(isAllExpanded ? [] : Array.from(expandableIds));
|
||||
}, [expandableIds, isAllExpanded, setExpandedIds]);
|
||||
|
||||
return (
|
||||
<DataTableExpansionContextProvider
|
||||
isExpanded={isExpanded}
|
||||
isDetailsPanelExpandable={isDetailsPanelExpandableById}
|
||||
toggleExpansion={toggleExpansion}
|
||||
toggleAll={toggleAll}
|
||||
isAllExpanded={isAllExpanded}
|
||||
getDetailsPanelContent={
|
||||
getDetailsPanelContent as
|
||||
| ((row: unknown) => React.ReactNode)
|
||||
| undefined
|
||||
}
|
||||
getDetailsPanelHeight={
|
||||
getDetailsPanelHeight as ((row: unknown) => number | "auto") | undefined
|
||||
}
|
||||
showExpandAll={showExpandAll}
|
||||
enableDetailsPanel={!!getDetailsPanelContent}
|
||||
>
|
||||
{children}
|
||||
</DataTableExpansionContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DataTableExpansionProvider,
|
||||
getDataTableExpansionId,
|
||||
useDataTableExpansion,
|
||||
};
|
||||
@@ -1,62 +1,69 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { createStrictContext } from "../../../utils/helpers";
|
||||
import { useControllableState } from "../../../utils/hooks";
|
||||
import {
|
||||
type ItemDetail,
|
||||
type TableRowEntryId,
|
||||
collectTableRowEntries,
|
||||
} from "../helpers/collectTableRowEntries";
|
||||
import type { TableRowEntryId } from "../root/DataTable.types";
|
||||
|
||||
type SubRowsProps<T> = {
|
||||
/**
|
||||
* Function to get sub-rows for a given row.
|
||||
*/
|
||||
getRows: (rowData: T) => T[];
|
||||
/**
|
||||
* Controlled list of IDs of rows that should be expanded.
|
||||
*/
|
||||
expandedRowIds?: TableRowEntryId[];
|
||||
/**
|
||||
* IDs of rows that should be initially expanded.
|
||||
* Only used when `expandedRowIds` is not provided, i.e. when the expanded state is uncontrolled.
|
||||
*/
|
||||
defaultExpandedRowIds?: TableRowEntryId[];
|
||||
/**
|
||||
* Called when the list of expanded row IDs changes.
|
||||
*/
|
||||
onExpandedRowIdsChange?: (ids: TableRowEntryId[]) => void;
|
||||
/**
|
||||
* Function to get whether a row should be expandable.
|
||||
* By default, all rows are expandable when `getRows` is provided.
|
||||
*/
|
||||
isRowExpandable?: (rowData: T) => boolean;
|
||||
};
|
||||
|
||||
type UseTableItemsArgs<T> = {
|
||||
items: T[];
|
||||
getRowId?: (rowData: T, index: number) => string | number;
|
||||
/**
|
||||
* Master - Detail pattern props
|
||||
*/
|
||||
/* expandedDetailsPanelIds?: (string | number)[];
|
||||
defaultExpandedDetailsPanelIds?: (string | number)[];
|
||||
isDetailsPanelExpandable?: (rowData: T) => boolean;
|
||||
onDetailsPanelChange?: (ids: (string | number)[]) => void;
|
||||
|
||||
getDetailsPanelHeight?: (row: T) => number | "auto";
|
||||
getDetailsPanelContent?: (row: T) => React.ReactNode; */
|
||||
/**
|
||||
* Expanded/Nested rows pattern props
|
||||
*/
|
||||
getSubRows?: (rowData: T) => T[];
|
||||
expandedSubRowIds?: (string | number)[];
|
||||
defaultExpandedSubRowIds?: (string | number)[];
|
||||
isSubRowExpandable?: (rowData: T) => boolean;
|
||||
onExpandedSubRowIdsChange?: (ids: (string | number)[]) => void;
|
||||
getRowId?: (rowData: T) => TableRowEntryId;
|
||||
subRows?: SubRowsProps<T>;
|
||||
};
|
||||
|
||||
type useTableItemsReturn<T> = {
|
||||
type UseTableItemsReturn<T> = {
|
||||
items: T[];
|
||||
itemDetails: Map<T, ItemDetail<T>>;
|
||||
itemDetails: Map<TableRowEntryId, ItemDetail<T>>;
|
||||
/** Row ids for the rows currently rendered in the table body. */
|
||||
visibleRowIds: TableRowEntryId[];
|
||||
/** Direct child ids for each row, used to traverse selection groups lazily. */
|
||||
childRowIdsById: Map<TableRowEntryId, TableRowEntryId[]>;
|
||||
onExpandedSubRowIdsChange: (id: string | number) => void;
|
||||
isSubRowExpanded: (id: string | number) => boolean;
|
||||
onExpandedRowIdsChange: (id: TableRowEntryId) => void;
|
||||
isSubRowExpanded: (id: TableRowEntryId) => boolean;
|
||||
};
|
||||
|
||||
function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
|
||||
function useTableItems<T>(args: UseTableItemsArgs<T>): UseTableItemsReturn<T> {
|
||||
const { items, subRows, getRowId } = args;
|
||||
|
||||
const {
|
||||
items,
|
||||
expandedSubRowIds,
|
||||
defaultExpandedSubRowIds,
|
||||
getSubRows,
|
||||
getRowId,
|
||||
onExpandedSubRowIdsChange,
|
||||
isSubRowExpandable,
|
||||
} = args;
|
||||
expandedRowIds,
|
||||
defaultExpandedRowIds,
|
||||
getRows,
|
||||
onExpandedRowIdsChange,
|
||||
isRowExpandable,
|
||||
} = subRows || {};
|
||||
|
||||
const [nestedSubRowsExpandedIds, setNestedSubRowsExpandedIds] =
|
||||
useControllableState({
|
||||
value: expandedSubRowIds,
|
||||
defaultValue: defaultExpandedSubRowIds ?? [],
|
||||
onChange: onExpandedSubRowIdsChange,
|
||||
value: expandedRowIds,
|
||||
defaultValue: defaultExpandedRowIds ?? [],
|
||||
onChange: onExpandedRowIdsChange,
|
||||
});
|
||||
|
||||
const expandedIdsSet = useMemo(
|
||||
@@ -66,32 +73,35 @@ function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
|
||||
|
||||
const { itemDetails, visibleItems, visibleRowIds, childRowIdsById } =
|
||||
useMemo(() => {
|
||||
const { itemDetails: rowEntriesMap, childRowIdsById: _childRowIdsById } =
|
||||
collectTableRowEntries({
|
||||
items,
|
||||
getRowId,
|
||||
getSubRows,
|
||||
isSubRowExpandable,
|
||||
});
|
||||
const {
|
||||
itemDetails: rowEntriesMap,
|
||||
rootRowIds,
|
||||
childRowIdsById: _childRowIdsById,
|
||||
} = collectTableRowEntries({
|
||||
items,
|
||||
getRowId,
|
||||
getRows,
|
||||
isRowExpandable,
|
||||
});
|
||||
|
||||
const localVisibleItems: T[] = [];
|
||||
const localVisibleRowIds: TableRowEntryId[] = [];
|
||||
|
||||
const addVisibleRows = (rowData: T): TableRowEntryId[] => {
|
||||
const details = rowEntriesMap.get(rowData);
|
||||
const addVisibleRows = (rowId: TableRowEntryId): TableRowEntryId[] => {
|
||||
const details = rowEntriesMap.get(rowId);
|
||||
|
||||
if (!details) {
|
||||
return [];
|
||||
}
|
||||
|
||||
localVisibleItems.push(rowData);
|
||||
localVisibleItems.push(details.rowData);
|
||||
localVisibleRowIds.push(details.id);
|
||||
|
||||
const visibleDescendantRowIds: TableRowEntryId[] = [];
|
||||
|
||||
if (expandedIdsSet.has(details.id)) {
|
||||
for (const childRow of details.children) {
|
||||
const childVisibleRowIds = addVisibleRows(childRow);
|
||||
for (const childRowId of details.children) {
|
||||
const childVisibleRowIds = addVisibleRows(childRowId);
|
||||
visibleDescendantRowIds.push(...childVisibleRowIds);
|
||||
}
|
||||
}
|
||||
@@ -99,8 +109,8 @@ function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
|
||||
return [details.id, ...visibleDescendantRowIds];
|
||||
};
|
||||
|
||||
for (const rowData of items) {
|
||||
addVisibleRows(rowData);
|
||||
for (const rowId of rootRowIds) {
|
||||
addVisibleRows(rowId);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -109,10 +119,10 @@ function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
|
||||
childRowIdsById: _childRowIdsById,
|
||||
itemDetails: rowEntriesMap,
|
||||
};
|
||||
}, [getSubRows, items, getRowId, isSubRowExpandable, expandedIdsSet]);
|
||||
}, [getRows, items, getRowId, isRowExpandable, expandedIdsSet]);
|
||||
|
||||
const handleExpandedSubRowIdChange = useCallback(
|
||||
(id: string | number) => {
|
||||
(id: TableRowEntryId) => {
|
||||
setNestedSubRowsExpandedIds((prev) =>
|
||||
prev.includes(id)
|
||||
? prev.filter((expandedId) => expandedId !== id)
|
||||
@@ -127,20 +137,10 @@ function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
|
||||
itemDetails,
|
||||
visibleRowIds,
|
||||
childRowIdsById,
|
||||
onExpandedSubRowIdsChange: handleExpandedSubRowIdChange,
|
||||
isSubRowExpanded: (id: string | number) => expandedIdsSet.has(id),
|
||||
onExpandedRowIdsChange: handleExpandedSubRowIdChange,
|
||||
isSubRowExpanded: (id: TableRowEntryId) => expandedIdsSet.has(id),
|
||||
};
|
||||
}
|
||||
|
||||
const { Provider: TableItemsProvider, useContext: useTableItemsContext } =
|
||||
/* TODO: Can we type this better? */
|
||||
createStrictContext<
|
||||
Omit<useTableItemsReturn<any>, "visibleRowIds" | "childRowIdsById">
|
||||
>({
|
||||
name: "TableItemsContext",
|
||||
errorMessage:
|
||||
"useTableItemsContext must be used within a TableItemsProvider",
|
||||
});
|
||||
|
||||
export { useTableItems, TableItemsProvider, useTableItemsContext };
|
||||
export type { ItemDetail };
|
||||
export { useTableItems };
|
||||
export type { ItemDetail, SubRowsProps, UseTableItemsReturn };
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEventCallback } from "../../../utils/hooks";
|
||||
import { focusInitialTableTarget } from "../helpers/table-cell";
|
||||
import { focusCellAndUpdateTabIndex } from "../helpers/table-focus";
|
||||
import { focusCell, focusCellAndUpdateTabIndex } from "../helpers/table-focus";
|
||||
import {
|
||||
findFirstCell,
|
||||
findFirstCellInRow,
|
||||
@@ -18,17 +18,9 @@ import { useGridCache } from "./useGridCache";
|
||||
|
||||
type UseTableKeyboardNavOptions = {
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Custom callback to determine if navigation should be blocked.
|
||||
* Called before default blocking logic.
|
||||
*/
|
||||
shouldBlockNavigation?: (event: KeyboardEvent) => boolean;
|
||||
};
|
||||
|
||||
function useTableKeyboardNav({
|
||||
enabled,
|
||||
shouldBlockNavigation: customBlockFn,
|
||||
}: UseTableKeyboardNavOptions) {
|
||||
function useTableKeyboardNav({ enabled }: UseTableKeyboardNavOptions) {
|
||||
const [tableRef, setTableRef] = useState<HTMLTableElement | null>(null);
|
||||
const { getTableGrid, activeCell, setActiveCell } = useGridCache(
|
||||
tableRef,
|
||||
@@ -110,10 +102,6 @@ function useTableKeyboardNav({
|
||||
* Checks if navigation should be blocked based on current focus context.
|
||||
*/
|
||||
const handleTableKeyDown = useEventCallback((event: KeyboardEvent): void => {
|
||||
if (customBlockFn?.(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = getNavigationAction(event);
|
||||
if (!action) {
|
||||
return;
|
||||
@@ -137,6 +125,10 @@ function useTableKeyboardNav({
|
||||
const target = event.target as Element | null;
|
||||
|
||||
if (tableRef && target === tableRef) {
|
||||
if (activeCell) {
|
||||
focusCell(activeCell);
|
||||
return;
|
||||
}
|
||||
focusInitialTableTarget(tableRef);
|
||||
return;
|
||||
}
|
||||
@@ -173,7 +165,7 @@ function useTableKeyboardNav({
|
||||
|
||||
return {
|
||||
/* Table should only have tabIndex until the focus is moved inside and is enabled */
|
||||
tabIndex: enabled ? (activeCell ? undefined : 0) : undefined,
|
||||
tabIndex: enabled ? 0 : undefined,
|
||||
setTableRef,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,28 +8,37 @@ import type {
|
||||
SelectionProps,
|
||||
TableSelection,
|
||||
} from "../helpers/selection/selection.types";
|
||||
import type { TableRowEntryId } from "../root/DataTable.types";
|
||||
import type { UseTableItemsReturn } from "./useTableItems";
|
||||
|
||||
type UseTableSelectionArgs = SelectionProps & {
|
||||
/* Visible rows manage the header checkbox state and render selection cells. */
|
||||
visibleRowIds: (string | number)[];
|
||||
/* Direct child ids let selection walk nested rows lazily. */
|
||||
childRowIdsById?: Map<string | number, (string | number)[]>;
|
||||
type UseTableSelectionArgs<T> = {
|
||||
selection?: SelectionProps<T>;
|
||||
tableItems: UseTableItemsReturn<T>;
|
||||
};
|
||||
|
||||
type UseTableSelectionReturn = {
|
||||
selection: TableSelection;
|
||||
renderSelection: boolean;
|
||||
selectionTrigger: "row" | "control";
|
||||
};
|
||||
|
||||
function useTableSelection({
|
||||
selectionMode = "none",
|
||||
defaultSelectedKeys,
|
||||
selectedKeys: selectedKeysProp,
|
||||
onSelectionChange,
|
||||
disabledSelectionKeys = [],
|
||||
visibleRowIds = [],
|
||||
childRowIdsById,
|
||||
}: UseTableSelectionArgs): UseTableSelectionReturn {
|
||||
function useTableSelection<T>({
|
||||
selection = {
|
||||
selectionMode: "none",
|
||||
},
|
||||
tableItems,
|
||||
}: UseTableSelectionArgs<T>): UseTableSelectionReturn {
|
||||
const {
|
||||
selectionMode,
|
||||
defaultSelectedKeys,
|
||||
selectedKeys: selectedKeysProp,
|
||||
onSelectionChange,
|
||||
enableRowSelection,
|
||||
selectionTrigger = "row",
|
||||
} = selection;
|
||||
|
||||
const { visibleRowIds = [] } = tableItems;
|
||||
|
||||
const radioGroupName = useId();
|
||||
|
||||
const [selectedKeys, setSelectedKeys] = useControllableState<SelectedKeysT>({
|
||||
@@ -40,17 +49,12 @@ function useTableSelection({
|
||||
|
||||
const selectedKeysSet = useMemo(() => new Set(selectedKeys), [selectedKeys]);
|
||||
|
||||
const disabledKeysSet = useMemo(
|
||||
() => new Set(disabledSelectionKeys),
|
||||
[disabledSelectionKeys],
|
||||
);
|
||||
|
||||
const isRowSelected = useCallback(
|
||||
(rowId: string | number) => selectedKeysSet.has(rowId),
|
||||
(rowId: TableRowEntryId) => selectedKeysSet.has(rowId),
|
||||
[selectedKeysSet],
|
||||
);
|
||||
|
||||
const baseSelection = { selectedKeys, disabledSelectionKeys, isRowSelected };
|
||||
const baseSelection = { selectedKeys, isRowSelected };
|
||||
|
||||
if (selectionMode === "none") {
|
||||
return {
|
||||
@@ -59,6 +63,7 @@ function useTableSelection({
|
||||
...baseSelection,
|
||||
selectedKeys: [],
|
||||
},
|
||||
selectionTrigger,
|
||||
renderSelection: false,
|
||||
};
|
||||
}
|
||||
@@ -71,10 +76,11 @@ function useTableSelection({
|
||||
...getSingleSelectProps({
|
||||
selectedKeysSet,
|
||||
setSelectedKeys,
|
||||
disabledKeysSet,
|
||||
name: radioGroupName,
|
||||
enableRowSelection,
|
||||
}),
|
||||
},
|
||||
selectionTrigger,
|
||||
renderSelection: visibleRowIds.length !== 0,
|
||||
};
|
||||
}
|
||||
@@ -87,11 +93,11 @@ function useTableSelection({
|
||||
selectedKeysSet,
|
||||
selectedKeys,
|
||||
setSelectedKeys,
|
||||
disabledKeysSet,
|
||||
visibleRowIds,
|
||||
childRowIdsById,
|
||||
enableRowSelection,
|
||||
tableItems,
|
||||
}),
|
||||
},
|
||||
selectionTrigger,
|
||||
renderSelection: visibleRowIds.length !== 0,
|
||||
};
|
||||
}
|
||||
@@ -103,9 +109,9 @@ const noSelectionState: UseTableSelectionReturn = {
|
||||
selection: {
|
||||
selectionMode: "none",
|
||||
selectedKeys: [],
|
||||
disabledSelectionKeys: [],
|
||||
isRowSelected: () => false,
|
||||
},
|
||||
selectionTrigger: "row",
|
||||
renderSelection: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import { consoleWarning } from "../../../utils/helpers/consoleWarning";
|
||||
import { useControllableState } from "../../../utils/hooks";
|
||||
import type { SortChangeDetail, SortEntry } from "../root/DataTable.types";
|
||||
|
||||
@@ -40,22 +41,22 @@ type UseTableSortResults = {
|
||||
sortState: SortEntry[];
|
||||
};
|
||||
|
||||
function useTableSort(options: TableSortOptions): UseTableSortResults {
|
||||
const { defaultSort = [], onSortChange, sort: sortOption } = options;
|
||||
function useTableSort(options?: TableSortOptions): UseTableSortResults {
|
||||
const { defaultSort, onSortChange, sort: sortOption } = options || {};
|
||||
|
||||
const [sort, setSort] = useControllableState({
|
||||
value: sortOption,
|
||||
defaultValue: defaultSort,
|
||||
defaultValue: defaultSort || [],
|
||||
});
|
||||
|
||||
const handleSortClick = useCallback(
|
||||
(id: string, event: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||
if (id === undefined) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn(
|
||||
`Aksel: Column id is undefined for sort event on target ${event.target}. Make sure your column definitions include an 'id' property.`,
|
||||
);
|
||||
}
|
||||
consoleWarning(
|
||||
"DataTable: Column id is undefined for sort event on target",
|
||||
event.target,
|
||||
"Make sure your column definitions include an 'id' property.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -100,4 +101,4 @@ function nextSortEntries(
|
||||
}
|
||||
|
||||
export { useTableSort };
|
||||
export type { TableSortOptions };
|
||||
export type { TableSortOptions, UseTableSortResults };
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
export {
|
||||
/* export {
|
||||
default as DataTable,
|
||||
DataTableCaption,
|
||||
DataTableThead,
|
||||
@@ -8,7 +8,7 @@ export {
|
||||
DataTableTh,
|
||||
DataTableTd,
|
||||
DataTableTfoot,
|
||||
} from "./root/DataTableRoot";
|
||||
} from "./root/DataTableRoot.legacy";
|
||||
export type {
|
||||
DataTableProps,
|
||||
DataTableCaptionProps,
|
||||
@@ -18,4 +18,6 @@ export type {
|
||||
DataTableThProps,
|
||||
DataTableTdProps,
|
||||
DataTableTfootProps,
|
||||
} from "./root/DataTableRoot";
|
||||
} from "./root/DataTableRoot.legacy"; */
|
||||
|
||||
export { DataTable, type DataTableProps } from "./root/DataTableRoot";
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { DataTableColumnHeaderProps } from "../column-header/DataTableColumnHeader";
|
||||
|
||||
type SortDirection = "asc" | "desc" | "none";
|
||||
|
||||
/**
|
||||
@@ -7,27 +9,22 @@ type SortDirection = "asc" | "desc" | "none";
|
||||
*/
|
||||
type ColumnDefinition<T> = {
|
||||
id: string;
|
||||
width?: number | string;
|
||||
defaultWidth?: number | string;
|
||||
minWidth?: number | string;
|
||||
maxWidth?: number | string;
|
||||
/**
|
||||
* Currently only handles cell alignment.
|
||||
* TODO: Should this include centering?
|
||||
* type "icon" or something to avoid ellipsis on actions, tags etc
|
||||
* Text alignment for cells in this column.
|
||||
*
|
||||
* @default "left"
|
||||
*/
|
||||
type?: "string" | "number";
|
||||
align?: "left" | "right" | "center"; // TODO: Pri zero: Use same name as in DataTableColumnHeaderProps (DataTableBaseCellProps) so that we can just Pick<DataTableColumnHeaderProps, "textAlign">
|
||||
/**
|
||||
* Assigned to the cell's `th` element instead of `td` if true.
|
||||
*
|
||||
* Should be used for cells that act as row headers. Each row should have one rowheader, and only have one cell with `isRowHeader: true`,
|
||||
*
|
||||
* TODO: Not implemented
|
||||
* - Add a generic tablecell component that can render either a td or th based on context or this prop.
|
||||
* TODO: Better documentation, consider warning if not one column has this set to true.
|
||||
*/
|
||||
isRowHeader?: boolean;
|
||||
/**
|
||||
* Renders table header-cell
|
||||
* TODO: Pri zero rename to headerCell
|
||||
*/
|
||||
header?: React.ReactNode;
|
||||
/**
|
||||
@@ -36,6 +33,7 @@ type ColumnDefinition<T> = {
|
||||
cell: (item: T) => React.ReactNode;
|
||||
/**
|
||||
* Label of header. Renders if header is not provided.
|
||||
* TODO: Pri zero consider renaming to header
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
@@ -43,7 +41,7 @@ type ColumnDefinition<T> = {
|
||||
* Use `sort` and `onSortChange` on the root component to control sort state.
|
||||
*/
|
||||
sortable?: boolean;
|
||||
};
|
||||
} & Pick<DataTableColumnHeaderProps, "width">;
|
||||
|
||||
type ColumnDefinitions<T> = ColumnDefinition<T>[];
|
||||
|
||||
@@ -66,10 +64,32 @@ type SortChangeDetail = {
|
||||
direction: "asc" | "desc" | "none";
|
||||
};
|
||||
|
||||
type DataTableLoadingConfig = {
|
||||
isLoading?: boolean;
|
||||
} & (
|
||||
| {
|
||||
variant: "content";
|
||||
content: React.ReactNode;
|
||||
}
|
||||
| {
|
||||
variant: "skeleton";
|
||||
rows?: number;
|
||||
label?: string;
|
||||
}
|
||||
| {
|
||||
variant: "overlay";
|
||||
label?: string;
|
||||
}
|
||||
);
|
||||
|
||||
type TableRowEntryId = string;
|
||||
|
||||
export type {
|
||||
ColumnDefinition,
|
||||
ColumnDefinitions,
|
||||
DataTableLoadingConfig,
|
||||
SortDirection,
|
||||
SortEntry,
|
||||
SortChangeDetail,
|
||||
TableRowEntryId,
|
||||
};
|
||||
@@ -1,244 +0,0 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DataTableColumnHeader } from "../column-header/DataTableColumnHeader";
|
||||
import type { ColumnDefinitions } from "./DataTable.types";
|
||||
import { DataTableAuto } from "./DataTableAuto";
|
||||
import { DataTable } from "./DataTableRoot";
|
||||
|
||||
type TestRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
subRows?: TestRow[];
|
||||
};
|
||||
|
||||
const data: TestRow[] = [
|
||||
{
|
||||
id: "root",
|
||||
name: "Root",
|
||||
subRows: [{ id: "child", name: "Child" }],
|
||||
},
|
||||
];
|
||||
|
||||
const deepNestedData: TestRow[] = [
|
||||
{
|
||||
id: "root",
|
||||
name: "Root",
|
||||
subRows: [
|
||||
{
|
||||
id: "child",
|
||||
name: "Child",
|
||||
subRows: [{ id: "grandchild", name: "Grandchild" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const fallbackIdData: TestRow[] = [
|
||||
{
|
||||
id: "unused-root-1",
|
||||
name: "Root",
|
||||
subRows: [{ id: "unused-child", name: "Child" }],
|
||||
},
|
||||
{
|
||||
id: "unused-root-2",
|
||||
name: "Sibling",
|
||||
},
|
||||
];
|
||||
|
||||
const columns: ColumnDefinitions<TestRow> = [
|
||||
{
|
||||
id: "name",
|
||||
label: "Name",
|
||||
header: "Name",
|
||||
cell: (row) => row.name,
|
||||
},
|
||||
];
|
||||
|
||||
describe("DataTableAuto", () => {
|
||||
test("renders expanded child rows and includes them in select-all", () => {
|
||||
render(
|
||||
<DataTableAuto
|
||||
columnDefinitions={columns}
|
||||
data={data}
|
||||
getRowId={(row) => row.id}
|
||||
getSubRows={(row) => row.subRows ?? []}
|
||||
selectionMode="multiple"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Child")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Vis under-rader" }));
|
||||
|
||||
expect(screen.getByText("Child")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("checkbox")).toHaveLength(3);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("checkbox", { name: "Velg alle synlige rader" }),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole("checkbox")
|
||||
.every((checkbox) => (checkbox as HTMLInputElement).checked),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("select-all checks all visible rows when fallback ids are used", () => {
|
||||
render(
|
||||
<DataTableAuto
|
||||
columnDefinitions={columns}
|
||||
data={fallbackIdData}
|
||||
getSubRows={(row) => row.subRows ?? []}
|
||||
defaultExpandedSubRowIds={[0]}
|
||||
selectionMode="multiple"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("checkbox", { name: "Velg alle synlige rader" }),
|
||||
);
|
||||
|
||||
const rowCheckboxes = screen
|
||||
.getAllByRole("checkbox")
|
||||
.slice(1) as HTMLInputElement[];
|
||||
|
||||
expect(rowCheckboxes).toHaveLength(3);
|
||||
expect(rowCheckboxes.every((checkbox) => checkbox.checked)).toBe(true);
|
||||
});
|
||||
|
||||
test("parent row selection follows visible nested rows", () => {
|
||||
render(
|
||||
<DataTableAuto
|
||||
columnDefinitions={columns}
|
||||
data={data}
|
||||
getRowId={(row) => row.id}
|
||||
getSubRows={(row) => row.subRows ?? []}
|
||||
selectionMode="multiple"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Vis under-rader" }));
|
||||
|
||||
const getCheckboxes = () =>
|
||||
screen.getAllByRole("checkbox") as HTMLInputElement[];
|
||||
|
||||
fireEvent.click(getCheckboxes()[2]);
|
||||
|
||||
expect(getCheckboxes()[1].checked).toBe(false);
|
||||
expect(getCheckboxes()[1].indeterminate).toBe(true);
|
||||
|
||||
fireEvent.click(getCheckboxes()[1]);
|
||||
|
||||
expect(getCheckboxes()[1].checked).toBe(true);
|
||||
expect(getCheckboxes()[2].checked).toBe(true);
|
||||
|
||||
fireEvent.click(getCheckboxes()[1]);
|
||||
|
||||
expect(getCheckboxes()[1].checked).toBe(false);
|
||||
expect(getCheckboxes()[2].checked).toBe(false);
|
||||
});
|
||||
|
||||
test("collapsed parent selection includes hidden descendants and can clear them again", () => {
|
||||
render(
|
||||
<DataTableAuto
|
||||
columnDefinitions={columns}
|
||||
data={deepNestedData}
|
||||
getRowId={(row) => row.id}
|
||||
getSubRows={(row) => row.subRows ?? []}
|
||||
selectionMode="multiple"
|
||||
/>,
|
||||
);
|
||||
|
||||
const getCheckboxes = () =>
|
||||
screen.getAllByRole("checkbox") as HTMLInputElement[];
|
||||
|
||||
fireEvent.click(getCheckboxes()[1]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Vis under-rader" }));
|
||||
|
||||
expect(getCheckboxes()[1].checked).toBe(true);
|
||||
expect(getCheckboxes()[2].checked).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Vis under-rader" }));
|
||||
|
||||
expect(getCheckboxes()[3].checked).toBe(true);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("button", { name: "Skjul under-rader" })[0],
|
||||
);
|
||||
fireEvent.click(getCheckboxes()[1]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Vis under-rader" }));
|
||||
|
||||
expect(getCheckboxes()[1].checked).toBe(false);
|
||||
expect(getCheckboxes()[2].checked).toBe(false);
|
||||
expect(getCheckboxes()[3].checked).toBe(false);
|
||||
});
|
||||
|
||||
test("select-all includes hidden descendants for collapsed parents and clears them again", () => {
|
||||
render(
|
||||
<DataTableAuto
|
||||
columnDefinitions={columns}
|
||||
data={deepNestedData}
|
||||
getRowId={(row) => row.id}
|
||||
getSubRows={(row) => row.subRows ?? []}
|
||||
selectionMode="multiple"
|
||||
/>,
|
||||
);
|
||||
|
||||
const getCheckboxes = () =>
|
||||
screen.getAllByRole("checkbox") as HTMLInputElement[];
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("checkbox", { name: "Velg alle synlige rader" }),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Vis under-rader" }));
|
||||
|
||||
expect(getCheckboxes()[1].checked).toBe(true);
|
||||
expect(getCheckboxes()[2].checked).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Vis under-rader" }));
|
||||
|
||||
expect(getCheckboxes()[3].checked).toBe(true);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("checkbox", { name: "Fjern alle synlige valgte rader" }),
|
||||
);
|
||||
|
||||
expect(getCheckboxes()[0].checked).toBe(false);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("button", { name: "Skjul under-rader" })[0],
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Vis under-rader" }));
|
||||
|
||||
expect(getCheckboxes()[1].checked).toBe(false);
|
||||
expect(getCheckboxes()[2].checked).toBe(false);
|
||||
expect(getCheckboxes()[3].checked).toBe(false);
|
||||
});
|
||||
|
||||
test("does not render expansion controls in the manual table variant", () => {
|
||||
render(
|
||||
<DataTable>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Name</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Root</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Root")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /vis detaljer|skjul detaljer/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { DataGrid } from "../../data-grid";
|
||||
import type { ColumnDefinitions } from "./DataTable.types";
|
||||
import { DataTable } from "./DataTableRoot";
|
||||
|
||||
type TestRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
subRows?: TestRow[];
|
||||
};
|
||||
|
||||
const meta: Meta<typeof DataTable> = {
|
||||
title: "ds-react/Data/DataTable/Tests",
|
||||
component: DataTable,
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DataTable>;
|
||||
|
||||
const data: TestRow[] = [
|
||||
{
|
||||
id: "root",
|
||||
name: "Root",
|
||||
subRows: [{ id: "child", name: "Child" }],
|
||||
},
|
||||
];
|
||||
|
||||
const deepNestedData: TestRow[] = [
|
||||
{
|
||||
id: "root",
|
||||
name: "Root",
|
||||
subRows: [
|
||||
{
|
||||
id: "child",
|
||||
name: "Child",
|
||||
subRows: [{ id: "grandchild", name: "Grandchild" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const fallbackIdData: TestRow[] = [
|
||||
{
|
||||
id: "unused-root-1",
|
||||
name: "Root",
|
||||
subRows: [{ id: "unused-child", name: "Child" }],
|
||||
},
|
||||
{
|
||||
id: "unused-root-2",
|
||||
name: "Sibling",
|
||||
},
|
||||
];
|
||||
|
||||
const columns: ColumnDefinitions<TestRow> = [
|
||||
{
|
||||
id: "name",
|
||||
label: "Name",
|
||||
header: "Name",
|
||||
cell: (row) => row.name,
|
||||
},
|
||||
];
|
||||
|
||||
const getSubRows = (row: TestRow) => row.subRows ?? [];
|
||||
|
||||
const getCheckboxes = (canvasElement: HTMLElement) =>
|
||||
within(canvasElement).getAllByRole("checkbox") as HTMLInputElement[];
|
||||
|
||||
export const ExpandedChildRowsIncludedInSelectAll: Story = {
|
||||
render: () => (
|
||||
<DataGrid
|
||||
columnDefinitions={columns}
|
||||
data={data}
|
||||
getRowId={(row) => row.id}
|
||||
>
|
||||
<DataTable
|
||||
subRows={{ getRows: getSubRows }}
|
||||
selection={{ selectionMode: "multiple" }}
|
||||
/>
|
||||
</DataGrid>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(canvas.queryByText("Child")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Vis under-rader" }),
|
||||
);
|
||||
|
||||
expect(canvas.getByText("Child")).toBeInTheDocument();
|
||||
expect(getCheckboxes(canvasElement)).toHaveLength(3);
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("checkbox", { name: "Velg alle synlige rader" }),
|
||||
);
|
||||
|
||||
expect(
|
||||
getCheckboxes(canvasElement).every((checkbox) => checkbox.checked),
|
||||
).toBe(true);
|
||||
},
|
||||
};
|
||||
|
||||
export const FallbackIdsSelectAllVisibleRows: Story = {
|
||||
render: () => (
|
||||
<DataGrid
|
||||
columnDefinitions={columns}
|
||||
data={fallbackIdData}
|
||||
getRowId={(row) => row.id}
|
||||
>
|
||||
<DataTable
|
||||
subRows={{
|
||||
getRows: getSubRows,
|
||||
defaultExpandedRowIds: ["0"],
|
||||
}}
|
||||
selection={{ selectionMode: "multiple" }}
|
||||
/>
|
||||
</DataGrid>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("checkbox", { name: "Velg alle synlige rader" }),
|
||||
);
|
||||
|
||||
const rowCheckboxes = getCheckboxes(canvasElement) as HTMLInputElement[];
|
||||
|
||||
expect(rowCheckboxes).toHaveLength(3);
|
||||
expect(rowCheckboxes.every((checkbox) => checkbox.checked)).toBe(true);
|
||||
},
|
||||
};
|
||||
|
||||
export const CollapsedParentSelectionIncludesHiddenDescendants: Story = {
|
||||
render: () => (
|
||||
<DataGrid
|
||||
columnDefinitions={columns}
|
||||
data={deepNestedData}
|
||||
getRowId={(row) => row.id}
|
||||
>
|
||||
<DataTable
|
||||
subRows={{ getRows: getSubRows }}
|
||||
selection={{ selectionMode: "multiple" }}
|
||||
/>
|
||||
</DataGrid>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(getCheckboxes(canvasElement)[1]);
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Vis under-rader" }),
|
||||
);
|
||||
|
||||
expect(getCheckboxes(canvasElement)[1].checked).toBe(true);
|
||||
expect(getCheckboxes(canvasElement)[2].checked).toBe(true);
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Vis under-rader" }),
|
||||
);
|
||||
|
||||
expect(getCheckboxes(canvasElement)[3].checked).toBe(true);
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getAllByRole("button", { name: "Skjul under-rader" })[0],
|
||||
);
|
||||
await userEvent.click(getCheckboxes(canvasElement)[1]);
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Vis under-rader" }),
|
||||
);
|
||||
|
||||
expect(getCheckboxes(canvasElement)[1].checked).toBe(false);
|
||||
expect(getCheckboxes(canvasElement)[2].checked).toBe(false);
|
||||
expect(getCheckboxes(canvasElement)[3].checked).toBe(false);
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectAllIncludesHiddenDescendantsForCollapsedParents: Story = {
|
||||
render: () => (
|
||||
<DataGrid
|
||||
columnDefinitions={columns}
|
||||
data={deepNestedData}
|
||||
getRowId={(row) => row.id}
|
||||
>
|
||||
<DataTable
|
||||
subRows={{ getRows: getSubRows }}
|
||||
selection={{ selectionMode: "multiple" }}
|
||||
/>
|
||||
</DataGrid>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("checkbox", { name: "Velg alle synlige rader" }),
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Vis under-rader" }),
|
||||
);
|
||||
|
||||
expect(getCheckboxes(canvasElement)[1].checked).toBe(true);
|
||||
expect(getCheckboxes(canvasElement)[2].checked).toBe(true);
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Vis under-rader" }),
|
||||
);
|
||||
|
||||
expect(getCheckboxes(canvasElement)[3].checked).toBe(true);
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("checkbox", {
|
||||
name: "Fjern alle synlige valgte rader",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getCheckboxes(canvasElement)[0].checked).toBe(false);
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getAllByRole("button", { name: "Skjul under-rader" })[0],
|
||||
);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Vis under-rader" }),
|
||||
);
|
||||
|
||||
expect(getCheckboxes(canvasElement)[1].checked).toBe(false);
|
||||
expect(getCheckboxes(canvasElement)[2].checked).toBe(false);
|
||||
expect(getCheckboxes(canvasElement)[3].checked).toBe(false);
|
||||
},
|
||||
};
|
||||
@@ -1,596 +0,0 @@
|
||||
/** biome-ignore-all lint/correctness/useHookAtTopLevel: False positive because of the way forwardRef() is added */
|
||||
import React, { forwardRef } from "react";
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "@navikt/aksel-icons";
|
||||
import { Button } from "../../../button";
|
||||
import { Skeleton } from "../../../skeleton";
|
||||
import { useId } from "../../../utils-external";
|
||||
import { cl } from "../../../utils/helpers";
|
||||
import { useMergeRefs } from "../../../utils/hooks";
|
||||
import { DataTableBaseCell } from "../base-cell/DataTableBaseCell";
|
||||
import { DataTableColumnHeader } from "../column-header/DataTableColumnHeader";
|
||||
import { DataTableEmptyState } from "../empty-state/DataTableEmptyState";
|
||||
import { useColumnOptions } from "../hooks/useColumnOptions";
|
||||
import {
|
||||
DataTableExpansionProvider,
|
||||
getDataTableExpansionId,
|
||||
useDataTableExpansion,
|
||||
} from "../hooks/useTableExpansion";
|
||||
import {
|
||||
type ItemDetail,
|
||||
TableItemsProvider,
|
||||
useTableItems,
|
||||
useTableItemsContext,
|
||||
} from "../hooks/useTableItems";
|
||||
import { useTableKeyboardNav } from "../hooks/useTableKeyboardNav";
|
||||
import {
|
||||
type SelectionProps,
|
||||
useTableSelection,
|
||||
} from "../hooks/useTableSelection";
|
||||
import { type TableSortOptions, useTableSort } from "../hooks/useTableSort";
|
||||
import { DataTableLoadingState } from "../loading-state/DataTableLoadingState";
|
||||
import { DataTableTbody } from "../tbody/DataTableTbody";
|
||||
import { DataTableThead } from "../thead/DataTableThead";
|
||||
import { DataTableTr } from "../tr/DataTableTr";
|
||||
import type { ColumnDefinitions } from "./DataTable.types";
|
||||
import {
|
||||
DataTableContextProvider,
|
||||
useDataTableContext,
|
||||
} from "./DataTableRoot.context";
|
||||
|
||||
interface DataTableProps<T>
|
||||
extends
|
||||
React.HTMLAttributes<HTMLTableElement>,
|
||||
SelectionProps,
|
||||
TableSortOptions {
|
||||
children?: never;
|
||||
/**
|
||||
* Controls vertical cell padding.
|
||||
* @default "normal"
|
||||
*/
|
||||
rowDensity?: "condensed" | "normal" | "spacious";
|
||||
/**
|
||||
* Zebra striped table
|
||||
* @default false
|
||||
*/
|
||||
zebraStripes?: boolean;
|
||||
/**
|
||||
* Truncate content in cells and show ellipsis for overflowed text.
|
||||
*
|
||||
* **NB:** When using `layout="auto"`, you have to manually set a `maxWidth` on columns that should be truncated.
|
||||
* @default true
|
||||
*/
|
||||
truncateContent?: boolean; // TODO: Consider making this default false when layout=auto, and maybe disallow it but add a wrap prop on the td-comp.
|
||||
/**
|
||||
* Enables keyboard navigation for table rows and cells.
|
||||
* @default false
|
||||
*/
|
||||
withKeyboardNav?: boolean;
|
||||
/**
|
||||
* Custom callback to determine if navigation should be blocked.
|
||||
* Called before default blocking logic.
|
||||
* Requires `withKeyboardNav` to be `true`.
|
||||
*/
|
||||
shouldBlockNavigation?: (event: KeyboardEvent) => boolean;
|
||||
/**
|
||||
* Controls table layout.
|
||||
*
|
||||
* ### fixed
|
||||
* Gives you full control of column widths. This is required for resizable columns.
|
||||
*
|
||||
* ### auto
|
||||
* Makes the columns resize automatically based on the content.
|
||||
* The table will take up at least 100% of available width.
|
||||
*
|
||||
* **NB:** When using this with `truncateContent`, you have to manually
|
||||
* set a `contentMaxWidth` on cells that should be truncated.
|
||||
* @default "fixed"
|
||||
*/
|
||||
layout?: "fixed" | "auto";
|
||||
/**
|
||||
* Defines the columns of the table and how to render them.
|
||||
*
|
||||
*
|
||||
* Each column definition should have a unique `id` (or use the column index as fallback) and a `cell`-renderer function that takes the row data as argument and returns a React node.
|
||||
*/
|
||||
columnDefinitions: ColumnDefinitions<T>;
|
||||
/**
|
||||
* The data to display in the table.
|
||||
*
|
||||
*
|
||||
* Each object in the array represents a row, and the properties of the object are used to render the cells based on the `columnDefinitions`.
|
||||
*/
|
||||
data: T[];
|
||||
/**
|
||||
* 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: T, index: number) => string | number;
|
||||
/**
|
||||
* Sticky columns that remain visible when horizontally scrolling the table.
|
||||
*
|
||||
* You can specify 1 sticky column on the left and 1 on the right.
|
||||
*/
|
||||
stickyColumns?: {
|
||||
first?: "1";
|
||||
last?: "1";
|
||||
};
|
||||
/**
|
||||
* @default false
|
||||
*/
|
||||
stickyHeader?: boolean;
|
||||
/**
|
||||
* Callback invoked when a data row is clicked.
|
||||
* Not called when clicking header, loading, or empty-state rows.
|
||||
*/
|
||||
onRowClick?: (
|
||||
rowId: string | number,
|
||||
event: React.MouseEvent<HTMLTableRowElement>,
|
||||
) => void;
|
||||
/**
|
||||
* Content to render when `data` is empty.
|
||||
* Rendered inside a `DataTable.EmptyState` row spanning all columns.
|
||||
*/
|
||||
emptyState?: React.ReactNode;
|
||||
/**
|
||||
* Shows the table in a loading state.
|
||||
*
|
||||
* - When `loadingState` is provided, it is rendered inside a `DataTable.LoadingState` row.
|
||||
* - When `loadingState` is **not** provided, skeleton placeholder rows are rendered instead.
|
||||
* @default false
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
/**
|
||||
* Custom content to render when `isLoading` is `true`.
|
||||
* Rendered inside a `DataTable.LoadingState` row spanning all columns.
|
||||
* When omitted, skeleton rows are rendered based on `loadingRows`.
|
||||
*/
|
||||
loadingState?: React.ReactNode;
|
||||
/**
|
||||
* Number of skeleton rows to render when `isLoading` is `true` and no `loadingState` is provided.
|
||||
*
|
||||
*
|
||||
* If not provided, the rendered content will get a temporarily overlay while loading
|
||||
*/
|
||||
loadingRows?: number;
|
||||
/**
|
||||
* Visually hidden label announced to screen readers when skeleton rows are shown.
|
||||
* Only used when `isLoading` is `true` and no `loadingState` is provided.
|
||||
* @default "Laster innhold"
|
||||
*/
|
||||
loadingLabel?: string;
|
||||
/**
|
||||
* Renders a details panel below the row when expanded.
|
||||
* When provided, an expand toggle column is added automatically.
|
||||
*/
|
||||
getDetailsPanelContent?: (rowData: T) => React.ReactNode;
|
||||
/**
|
||||
* Determines whether a row can be expanded to show details panel content.
|
||||
* @default () => true
|
||||
*/
|
||||
isDetailsPanelExpandable?: (rowData: T) => boolean;
|
||||
/**
|
||||
* Controlled list of expanded row IDs.
|
||||
* Use with `onDetailsPanelChange` for controlled usage, or `defaultDetailsPanelRowIds` for uncontrolled.
|
||||
*/
|
||||
detailsPanelRowIds?: (string | number)[];
|
||||
/**
|
||||
* Initial list of expanded row IDs for uncontrolled usage.
|
||||
* @default []
|
||||
*/
|
||||
defaultDetailsPanelRowIds?: (string | number)[];
|
||||
/**
|
||||
* Called when the list of expanded row IDs changes.
|
||||
*
|
||||
*
|
||||
* TODO:
|
||||
* - Docs: This pattern is called "Master / Detail" in general terms
|
||||
*/
|
||||
onDetailsPanelChange?: (ids: (string | number)[]) => void;
|
||||
/**
|
||||
* Returns the height (in px) or `"auto"` for a row's details panel.
|
||||
* When a number is returned, the panel scrolls within that fixed height.
|
||||
* @default "auto"
|
||||
*/
|
||||
getDetailsPanelHeight?: (rowData: T) => number | "auto";
|
||||
/**
|
||||
* Shows an expand-all toggle button in the expand column header.
|
||||
* @default false
|
||||
*/
|
||||
showExpandAll?: boolean;
|
||||
/**
|
||||
* Function to get sub-rows for a given row, used for nested rows.
|
||||
* When provided, an expand toggle column is added automatically.
|
||||
*/
|
||||
getSubRows?: (rowData: T) => T[];
|
||||
expandedSubRowIds?: (string | number)[];
|
||||
defaultExpandedSubRowIds?: (string | number)[];
|
||||
isSubRowExpandable?: (rowData: T) => boolean;
|
||||
onExpandedSubRowIdsChange?: (ids: (string | number)[]) => void;
|
||||
}
|
||||
|
||||
function DataTableAutoInner<T>(
|
||||
{
|
||||
className,
|
||||
id,
|
||||
rowDensity = "normal",
|
||||
withKeyboardNav = false,
|
||||
zebraStripes = false,
|
||||
truncateContent = true,
|
||||
shouldBlockNavigation,
|
||||
layout = "fixed",
|
||||
selectionMode: selectionModeProp = "none",
|
||||
selectedKeys,
|
||||
defaultSelectedKeys,
|
||||
onSelectionChange,
|
||||
disabledSelectionKeys = [],
|
||||
data,
|
||||
columnDefinitions,
|
||||
getRowId,
|
||||
stickyColumns,
|
||||
stickyHeader = false,
|
||||
sort: sortProp,
|
||||
defaultSort = [],
|
||||
onSortChange,
|
||||
onRowClick,
|
||||
emptyState,
|
||||
isLoading = false,
|
||||
loadingState,
|
||||
loadingRows,
|
||||
loadingLabel = "Laster innhold",
|
||||
disableRowSelectionOnClick = false,
|
||||
getDetailsPanelContent,
|
||||
isDetailsPanelExpandable,
|
||||
getDetailsPanelHeight,
|
||||
showExpandAll = false,
|
||||
detailsPanelRowIds,
|
||||
defaultDetailsPanelRowIds,
|
||||
onDetailsPanelChange,
|
||||
getSubRows,
|
||||
expandedSubRowIds,
|
||||
defaultExpandedSubRowIds,
|
||||
isSubRowExpandable,
|
||||
onExpandedSubRowIdsChange,
|
||||
...rest
|
||||
}: DataTableProps<T>,
|
||||
forwardedRef: React.ForwardedRef<HTMLTableElement>,
|
||||
) {
|
||||
const { tabIndex, setTableRef } = useTableKeyboardNav({
|
||||
enabled: withKeyboardNav,
|
||||
shouldBlockNavigation,
|
||||
});
|
||||
|
||||
const { sortState, onSortClick } = useTableSort({
|
||||
defaultSort,
|
||||
onSortChange,
|
||||
sort: sortProp,
|
||||
});
|
||||
|
||||
const mergedRef = useMergeRefs(forwardedRef, setTableRef);
|
||||
|
||||
const tableItems = useTableItems({
|
||||
items: data,
|
||||
getRowId,
|
||||
getSubRows,
|
||||
expandedSubRowIds,
|
||||
defaultExpandedSubRowIds,
|
||||
isSubRowExpandable,
|
||||
onExpandedSubRowIdsChange,
|
||||
});
|
||||
|
||||
const tableSelectionState = useTableSelection({
|
||||
selectionMode: selectionModeProp,
|
||||
selectedKeys,
|
||||
defaultSelectedKeys,
|
||||
onSelectionChange,
|
||||
disabledSelectionKeys,
|
||||
visibleRowIds: tableItems.visibleRowIds,
|
||||
childRowIdsById: tableItems.childRowIdsById,
|
||||
});
|
||||
|
||||
const { columns, stickySelection } = useColumnOptions<T>(columnDefinitions, {
|
||||
stickyColumns,
|
||||
selectionMode: tableSelectionState.selection.selectionMode,
|
||||
});
|
||||
|
||||
const fullWidthColSpan =
|
||||
columns.length +
|
||||
(layout === "fixed" ? 1 : 0) +
|
||||
(tableSelectionState.selection.selectionMode !== "none" ? 1 : 0) +
|
||||
(getDetailsPanelContent ? 1 : 0);
|
||||
|
||||
const tableId = useId(id);
|
||||
|
||||
return (
|
||||
<DataTableContextProvider
|
||||
layout={layout}
|
||||
withKeyboardNav={withKeyboardNav}
|
||||
selectionState={tableSelectionState}
|
||||
stickySelection={stickySelection}
|
||||
stickyHeader={stickyHeader}
|
||||
tableId={tableId}
|
||||
showLoadingSkeletons={isLoading && loadingState == null}
|
||||
onRowClick={onRowClick}
|
||||
disableRowSelectionOnClick={disableRowSelectionOnClick}
|
||||
isLoading={isLoading}
|
||||
showLoadingOverlay={isLoading && !loadingState && !loadingRows}
|
||||
columns={columns}
|
||||
>
|
||||
<TableItemsProvider
|
||||
itemDetails={tableItems.itemDetails}
|
||||
items={tableItems.items}
|
||||
onExpandedSubRowIdsChange={tableItems.onExpandedSubRowIdsChange}
|
||||
isSubRowExpanded={tableItems.isSubRowExpanded}
|
||||
>
|
||||
<DataTableExpansionProvider
|
||||
detailsPanelRowIds={detailsPanelRowIds}
|
||||
defaultDetailsPanelRowIds={defaultDetailsPanelRowIds}
|
||||
onDetailsPanelChange={onDetailsPanelChange}
|
||||
getDetailsPanelContent={getDetailsPanelContent}
|
||||
isDetailsPanelExpandable={isDetailsPanelExpandable}
|
||||
getDetailsPanelHeight={getDetailsPanelHeight}
|
||||
showExpandAll={showExpandAll}
|
||||
>
|
||||
<div className="aksel-data-table__border-wrapper">
|
||||
<div className="aksel-data-table__scroll-wrapper">
|
||||
<table
|
||||
{...rest}
|
||||
ref={mergedRef}
|
||||
className={cl("aksel-data-table", className)}
|
||||
data-zebra-stripes={zebraStripes}
|
||||
data-truncate-content={truncateContent}
|
||||
data-density={rowDensity}
|
||||
data-layout={layout}
|
||||
data-loading={isLoading || undefined}
|
||||
tabIndex={tabIndex}
|
||||
aria-busy={isLoading || undefined}
|
||||
>
|
||||
<DataTableThead>
|
||||
<DataTableTr>
|
||||
{columns.map(({ isSticky, colDef }) => {
|
||||
const sortEntry = sortState.find(
|
||||
(s) => s.columnId === colDef.id,
|
||||
);
|
||||
const sortDirection = sortEntry?.direction ?? "none";
|
||||
return (
|
||||
<DataTableColumnHeader
|
||||
maxWidth={colDef.maxWidth}
|
||||
minWidth={colDef.minWidth}
|
||||
width={colDef.width}
|
||||
defaultWidth={colDef.defaultWidth ?? "100%"}
|
||||
textAlign={
|
||||
colDef.type === "number" ? "right" : "left"
|
||||
}
|
||||
key={colDef.id}
|
||||
isSticky={isSticky}
|
||||
sortable={colDef.sortable}
|
||||
sortDirection={sortDirection}
|
||||
onSortClick={(event) => onSortClick(colDef.id, event)}
|
||||
>
|
||||
{colDef.header}
|
||||
</DataTableColumnHeader>
|
||||
);
|
||||
})}
|
||||
</DataTableTr>
|
||||
</DataTableThead>
|
||||
|
||||
<DataTableTbody>
|
||||
<DataTableAutoTBodyContent
|
||||
loadingState={loadingState}
|
||||
loadingRows={loadingRows}
|
||||
loadingLabel={loadingLabel}
|
||||
emptyState={emptyState}
|
||||
fullWidthColSpan={fullWidthColSpan}
|
||||
/>
|
||||
</DataTableTbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</DataTableExpansionProvider>
|
||||
</TableItemsProvider>
|
||||
</DataTableContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataTableAutoTBodyContentProps {
|
||||
loadingState: React.ReactNode;
|
||||
loadingLabel: string;
|
||||
loadingRows?: number;
|
||||
emptyState: React.ReactNode;
|
||||
fullWidthColSpan: number;
|
||||
}
|
||||
|
||||
function DataTableAutoTBodyContent({
|
||||
loadingState,
|
||||
loadingRows,
|
||||
loadingLabel,
|
||||
emptyState,
|
||||
fullWidthColSpan,
|
||||
}: DataTableAutoTBodyContentProps) {
|
||||
const { items, itemDetails } = useTableItemsContext();
|
||||
const { columns, isLoading } = useDataTableContext();
|
||||
|
||||
if (isLoading && loadingState != null) {
|
||||
return (
|
||||
<DataTableLoadingState colSpan={fullWidthColSpan}>
|
||||
{loadingState}
|
||||
</DataTableLoadingState>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && loadingRows) {
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td colSpan={fullWidthColSpan} className="aksel-sr-only">
|
||||
{loadingLabel}
|
||||
</td>
|
||||
</tr>
|
||||
{Array.from({ length: loadingRows }, (_, rowIndex) => (
|
||||
<DataTableTr key={`skeleton-row-${rowIndex}`} aria-hidden>
|
||||
{columns.map(({ isSticky, colDef }, colDefIndex) => (
|
||||
<DataTableBaseCell
|
||||
textAlign={colDef.type === "number" ? "right" : "left"}
|
||||
key={colDef.id || colDefIndex}
|
||||
as={colDef.isRowHeader ? "th" : "td"}
|
||||
isSticky={isSticky}
|
||||
>
|
||||
<Skeleton variant="text" />
|
||||
</DataTableBaseCell>
|
||||
))}
|
||||
</DataTableTr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0 && emptyState !== undefined) {
|
||||
return (
|
||||
<DataTableEmptyState colSpan={fullWidthColSpan}>
|
||||
{emptyState}
|
||||
</DataTableEmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
const renderLoadingAnnouncement = isLoading && !loadingState && !loadingRows;
|
||||
|
||||
return items.map((rowData) => {
|
||||
const details = itemDetails.get(rowData);
|
||||
|
||||
/* Should in theory be impossible. Look about typing this? */
|
||||
if (!details) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasSubRows = details.children.length > 0;
|
||||
|
||||
return (
|
||||
<React.Fragment key={details.id}>
|
||||
{renderLoadingAnnouncement && (
|
||||
<tr>
|
||||
<td colSpan={fullWidthColSpan} className="aksel-sr-only">
|
||||
{loadingLabel}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<DataTableTr rowId={details.id}>
|
||||
{columns.map(({ isSticky, colDef }, colDefIndex) => {
|
||||
const renderNestedToggle = colDefIndex === 0 && hasSubRows;
|
||||
const renderNestedIndent =
|
||||
colDefIndex === 0 && (details.level > 0 || hasSubRows);
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
"--__axc-data-table-nested-depth": details.level,
|
||||
};
|
||||
|
||||
return (
|
||||
<DataTableBaseCell
|
||||
/* TODO: Make this configurable */
|
||||
textAlign={colDef.type === "number" ? "right" : "left"}
|
||||
key={colDef.id || colDefIndex}
|
||||
as={colDef.isRowHeader ? "th" : "td"}
|
||||
isSticky={isSticky}
|
||||
data-nested={renderNestedIndent || undefined}
|
||||
style={style}
|
||||
>
|
||||
{renderNestedToggle && <NestedRowToggle details={details} />}
|
||||
{colDef.cell(rowData)}
|
||||
</DataTableBaseCell>
|
||||
);
|
||||
})}
|
||||
</DataTableTr>
|
||||
<DataTableExpandedRow
|
||||
rowId={details.id}
|
||||
rowData={rowData}
|
||||
fullWidthColSpan={fullWidthColSpan}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function NestedRowToggle({ details }: { details: ItemDetail<any> }) {
|
||||
const { isSubRowExpanded, onExpandedSubRowIdsChange } =
|
||||
useTableItemsContext();
|
||||
|
||||
const subRows = details.children;
|
||||
const hasSubRows = subRows && subRows.length > 0;
|
||||
const isRowExpanded = isSubRowExpanded(details.id);
|
||||
|
||||
return (
|
||||
<div className="aksel-data-table__nested-toggle">
|
||||
{hasSubRows && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
data-color="neutral"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExpandedSubRowIdsChange(details.id);
|
||||
}}
|
||||
aria-expanded={isRowExpanded}
|
||||
aria-label={isRowExpanded ? "Skjul under-rader" : "Vis under-rader"}
|
||||
icon={
|
||||
isRowExpanded ? (
|
||||
<ChevronDownIcon aria-hidden />
|
||||
) : (
|
||||
<ChevronRightIcon aria-hidden />
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTableExpandedRow<T>({
|
||||
rowId,
|
||||
rowData,
|
||||
fullWidthColSpan,
|
||||
}: {
|
||||
rowId: string | number;
|
||||
rowData: T;
|
||||
fullWidthColSpan: number;
|
||||
}) {
|
||||
const { tableId } = useDataTableContext();
|
||||
const {
|
||||
enableDetailsPanel,
|
||||
isExpanded,
|
||||
getDetailsPanelContent,
|
||||
getDetailsPanelHeight,
|
||||
} = useDataTableExpansion();
|
||||
|
||||
if (!enableDetailsPanel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isExpanded(rowId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = getDetailsPanelContent?.(rowData);
|
||||
const expansionId = getDataTableExpansionId(tableId, rowId);
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td id={expansionId} colSpan={fullWidthColSpan}>
|
||||
<div style={{ height: getDetailsPanelHeight?.(rowData) }}>
|
||||
{content}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
const DataTableAuto = forwardRef(DataTableAutoInner) as <T>(
|
||||
props: DataTableProps<T> & React.RefAttributes<HTMLTableElement>,
|
||||
) => React.ReactElement | null;
|
||||
|
||||
export { DataTableAuto };
|
||||
export type { DataTableProps };
|
||||
export default DataTableAuto;
|
||||
@@ -1,23 +1,39 @@
|
||||
import { createStrictContext } from "../../../utils/helpers";
|
||||
import type { UseColumnOptionsResult } from "../hooks/useColumnOptions";
|
||||
import type {
|
||||
StickyStartState,
|
||||
UseColumnOptionsResult,
|
||||
} from "../hooks/useColumnOptions";
|
||||
import type { UseTableItemsReturn } from "../hooks/useTableItems";
|
||||
import type { UseTableSelectionReturn } from "../hooks/useTableSelection";
|
||||
import type { UseTableSortResults } from "../hooks/useTableSort";
|
||||
import type {
|
||||
DataTableLoadingConfig,
|
||||
TableRowEntryId,
|
||||
} from "./DataTable.types";
|
||||
|
||||
type DataTableContextProps<T> = {
|
||||
layout: "fixed" | "auto";
|
||||
withKeyboardNav: boolean;
|
||||
selectionState: UseTableSelectionReturn;
|
||||
stickySelection: boolean;
|
||||
stickyStart: StickyStartState;
|
||||
stickyHeader: boolean;
|
||||
tableId: string;
|
||||
showLoadingSkeletons: boolean;
|
||||
loading: DataTableLoadingConfig | undefined;
|
||||
onRowClick?: (
|
||||
rowId: string | number,
|
||||
rowId: TableRowEntryId,
|
||||
event: React.MouseEvent<HTMLTableRowElement>,
|
||||
) => void;
|
||||
disableRowSelectionOnClick: boolean;
|
||||
isLoading?: boolean;
|
||||
showLoadingOverlay: boolean;
|
||||
columns: UseColumnOptionsResult<T>["columns"];
|
||||
/**
|
||||
* Used to set exact colspan for detailsPanel, loadingState and emptyState.
|
||||
* This is necessary to ensure that these components span the entire width of the table.
|
||||
*/
|
||||
totalColSpan: number;
|
||||
/**
|
||||
* The current items and related metadata.
|
||||
*/
|
||||
tableItems: UseTableItemsReturn<T>;
|
||||
sortingState: UseTableSortResults;
|
||||
};
|
||||
|
||||
const { Provider: DataTableContextProvider, useContext: useDataTableContext } =
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { useId } from "../../../utils-external";
|
||||
import { cl } from "../../../utils/helpers";
|
||||
import { useMergeRefs } from "../../../utils/hooks";
|
||||
import {
|
||||
DataTableCaption,
|
||||
type DataTableCaptionProps,
|
||||
} from "../caption/DataTableCaption";
|
||||
import {
|
||||
DataTableEmptyState,
|
||||
type DataTableEmptyStateProps,
|
||||
} from "../empty-state/DataTableEmptyState";
|
||||
import { DataTableDetailsPanelProvider } from "../hooks/useTableDetailsPanel";
|
||||
import { useTableKeyboardNav } from "../hooks/useTableKeyboardNav";
|
||||
import {
|
||||
type SelectionProps,
|
||||
noSelectionState,
|
||||
} from "../hooks/useTableSelection";
|
||||
import {
|
||||
DataTableLoadingState,
|
||||
type DataTableLoadingStateProps,
|
||||
} from "../loading-state/DataTableLoadingState";
|
||||
import {
|
||||
DataTableTbody,
|
||||
type DataTableTbodyProps,
|
||||
} from "../tbody/DataTableTbody";
|
||||
import { DataTableTd, type DataTableTdProps } from "../td/DataTableTd";
|
||||
import {
|
||||
DataTableTfoot,
|
||||
type DataTableTfootProps,
|
||||
} from "../tfoot/DataTableTfoot";
|
||||
import { DataTableTh, type DataTableThProps } from "../th/DataTableTh";
|
||||
import {
|
||||
DataTableThead,
|
||||
type DataTableTheadProps,
|
||||
} from "../thead/DataTableThead";
|
||||
import { DataTableTr, type DataTableTrProps } from "../tr/DataTableTr";
|
||||
import { DataTableContextProvider } from "./DataTableRoot.context";
|
||||
|
||||
interface DataTableProps extends React.HTMLAttributes<HTMLTableElement> {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Controls vertical cell padding.
|
||||
* @default "normal"
|
||||
*/
|
||||
rowDensity?: "condensed" | "normal" | "spacious";
|
||||
/**
|
||||
* Zebra striped table
|
||||
* @default false
|
||||
*/
|
||||
zebraStripes?: boolean;
|
||||
/**
|
||||
* Truncate content in cells and show ellipsis for overflowed text.
|
||||
*
|
||||
* **NB:** When using `layout="auto"`, you have to manually set a `maxWidth` on columns that should be truncated.
|
||||
* @default true
|
||||
*/
|
||||
truncateContent?: boolean; // TODO: Consider making this default false when layout=auto, and maybe disallow it but add a wrap prop on the td-comp.
|
||||
/**
|
||||
* Enables keyboard navigation for table rows and cells.
|
||||
* @default false
|
||||
*/
|
||||
withKeyboardNav?: boolean;
|
||||
/**
|
||||
* Controls table layout.
|
||||
*
|
||||
* ### fixed
|
||||
* Gives you full control of column widths. This is required for resizable columns.
|
||||
*
|
||||
* ### auto
|
||||
* Makes the columns resize automatically based on the content.
|
||||
* The table will take up at least 100% of available width.
|
||||
*
|
||||
* **NB:** When using this with `truncateContent`, you have to manually
|
||||
* set a `contentMaxWidth` on cells that should be truncated.
|
||||
* @default "fixed"
|
||||
*/
|
||||
layout?: "fixed" | "auto";
|
||||
selection?: SelectionProps;
|
||||
}
|
||||
|
||||
interface DataTableRootComponent extends React.ForwardRefExoticComponent<
|
||||
DataTableProps & React.RefAttributes<HTMLTableElement>
|
||||
> {
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableCaptionProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Caption>
|
||||
* Lorem ipsum
|
||||
* </DataTable.Caption
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Caption: typeof DataTableCaption;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableTheadProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Thead>
|
||||
* ... TODO
|
||||
* </DataTable.Thead>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Thead: typeof DataTableThead;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableTbodyProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Tbody>
|
||||
* ... TODO
|
||||
* </DataTable.Tbody>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Tbody: typeof DataTableTbody;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableTrProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Tr>
|
||||
* ... TODO
|
||||
* </DataTable.Tr
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Tr: typeof DataTableTr;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableThProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* ```
|
||||
*/
|
||||
Th: typeof DataTableTh;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableTdProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Tbody>
|
||||
* <DataTable.Td>
|
||||
* Lorem ipsum
|
||||
* </DataTable.Td>
|
||||
* <DataTable.Td>
|
||||
* Dolor sit amet
|
||||
* </DataTable.Td>
|
||||
* </DataTable.Tbody>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Td: typeof DataTableTd;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableTfootProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Tfoot>
|
||||
* ...
|
||||
* </DataTable.Tfoot>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Tfoot: typeof DataTableTfoot;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableEmptyStateProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.TBody>
|
||||
* <DataTable.EmptyState />
|
||||
* </DataTable.TBody>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
EmptyState: typeof DataTableEmptyState;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableEmptyStateProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.TBody>
|
||||
* <DataTable.LoadingState />
|
||||
* </DataTable.TBody>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
LoadingState: typeof DataTableLoadingState;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO Component description etc.
|
||||
*
|
||||
* **NB:** To get sticky headers, you have to set a height restriction on the table container. You can use VStack for this:
|
||||
* TODO example
|
||||
*/
|
||||
const DataTable = forwardRef<HTMLTableElement, DataTableProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
rowDensity = "normal",
|
||||
withKeyboardNav = false,
|
||||
zebraStripes = false,
|
||||
truncateContent = true,
|
||||
layout = "fixed",
|
||||
...rest
|
||||
},
|
||||
forwardedRef,
|
||||
) => {
|
||||
const { tabIndex, setTableRef } = useTableKeyboardNav({
|
||||
enabled: withKeyboardNav,
|
||||
});
|
||||
|
||||
const mergedRef = useMergeRefs(forwardedRef, setTableRef);
|
||||
|
||||
return (
|
||||
<DataTableContextProvider
|
||||
layout={layout}
|
||||
withKeyboardNav={withKeyboardNav}
|
||||
selectionState={noSelectionState}
|
||||
stickyStart={{
|
||||
expansion: false,
|
||||
selection: false,
|
||||
selectionOffset: 0,
|
||||
firstColumnOffset: 0,
|
||||
}}
|
||||
stickyHeader={true}
|
||||
tableId={useId()}
|
||||
loading={undefined}
|
||||
onRowClick={undefined}
|
||||
columns={[]}
|
||||
totalColSpan={9999}
|
||||
tableItems={{
|
||||
childRowIdsById: new Map(),
|
||||
visibleRowIds: [],
|
||||
itemDetails: new Map(),
|
||||
items: [],
|
||||
isSubRowExpanded: () => false,
|
||||
onExpandedRowIdsChange: () => null,
|
||||
}}
|
||||
sortingState={{
|
||||
onSortClick: () => null,
|
||||
sortState: [],
|
||||
}}
|
||||
>
|
||||
<DataTableDetailsPanelProvider>
|
||||
<div className="aksel-data-table__border-wrapper">
|
||||
<div className="aksel-data-table__scroll-wrapper">
|
||||
<table
|
||||
{...rest}
|
||||
ref={mergedRef}
|
||||
className={cl("aksel-data-table", className)}
|
||||
data-zebra-stripes={zebraStripes}
|
||||
data-truncate-content={truncateContent}
|
||||
data-density={rowDensity}
|
||||
data-layout={layout}
|
||||
tabIndex={tabIndex}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DataTableDetailsPanelProvider>
|
||||
</DataTableContextProvider>
|
||||
);
|
||||
},
|
||||
) as DataTableRootComponent;
|
||||
|
||||
DataTable.Caption = DataTableCaption;
|
||||
DataTable.Thead = DataTableThead;
|
||||
DataTable.Tbody = DataTableTbody;
|
||||
DataTable.Th = DataTableTh;
|
||||
DataTable.Tr = DataTableTr;
|
||||
DataTable.Td = DataTableTd;
|
||||
DataTable.Tfoot = DataTableTfoot;
|
||||
DataTable.EmptyState = DataTableEmptyState;
|
||||
DataTable.LoadingState = DataTableLoadingState;
|
||||
|
||||
export {
|
||||
DataTable,
|
||||
DataTableCaption,
|
||||
DataTableEmptyState,
|
||||
DataTableLoadingState,
|
||||
DataTableTbody,
|
||||
DataTableTd,
|
||||
DataTableTfoot,
|
||||
DataTableTh,
|
||||
DataTableThead,
|
||||
DataTableTr,
|
||||
};
|
||||
export default DataTable;
|
||||
export type {
|
||||
DataTableCaptionProps,
|
||||
DataTableEmptyStateProps,
|
||||
DataTableLoadingStateProps,
|
||||
DataTableProps,
|
||||
DataTableTbodyProps,
|
||||
DataTableTdProps,
|
||||
DataTableTfootProps,
|
||||
DataTableTheadProps,
|
||||
DataTableThProps,
|
||||
DataTableTrProps,
|
||||
};
|
||||
@@ -1,45 +1,61 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import React, {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Skeleton } from "../../../skeleton";
|
||||
import { useId } from "../../../utils-external";
|
||||
import { Slot } from "../../../utils/components/slot/Slot";
|
||||
import { cl } from "../../../utils/helpers";
|
||||
import { useMergeRefs } from "../../../utils/hooks";
|
||||
import { useDataGridContext } from "../../data-grid/root/DataGridRoot.context";
|
||||
import { DataTableBaseCell } from "../base-cell/DataTableBaseCell";
|
||||
import { DataTableColumnHeader } from "../column-header/DataTableColumnHeader";
|
||||
import { DataTableDetailsPanelRow } from "../details-panel-row/DataTableDetailsPanelRow";
|
||||
import { DataTableEmptyState } from "../empty-state/DataTableEmptyState";
|
||||
import { useColumnOptions } from "../hooks/useColumnOptions";
|
||||
import {
|
||||
DataTableCaption,
|
||||
type DataTableCaptionProps,
|
||||
} from "../caption/DataTableCaption";
|
||||
import {
|
||||
DataTableEmptyState,
|
||||
type DataTableEmptyStateProps,
|
||||
} from "../empty-state/DataTableEmptyState";
|
||||
import { DataTableExpansionProvider } from "../hooks/useTableExpansion";
|
||||
DataTableDetailsPanelProvider,
|
||||
type DetailsPanelProps,
|
||||
} from "../hooks/useTableDetailsPanel";
|
||||
import { type SubRowsProps, useTableItems } from "../hooks/useTableItems";
|
||||
import { useTableKeyboardNav } from "../hooks/useTableKeyboardNav";
|
||||
import {
|
||||
type SelectionProps,
|
||||
noSelectionState,
|
||||
useTableSelection,
|
||||
} from "../hooks/useTableSelection";
|
||||
import { type TableSortOptions, useTableSort } from "../hooks/useTableSort";
|
||||
import { DataTableLoadingState } from "../loading-state/DataTableLoadingState";
|
||||
import { DataTableSubRowToggle } from "../sub-row-toggle/DataTableSubRowToggle";
|
||||
import { DataTableTbody } from "../tbody/DataTableTbody";
|
||||
import { DataTableThead } from "../thead/DataTableThead";
|
||||
import { DataTableTr } from "../tr/DataTableTr";
|
||||
import type {
|
||||
DataTableLoadingConfig,
|
||||
TableRowEntryId,
|
||||
} from "./DataTable.types";
|
||||
import {
|
||||
DataTableLoadingState,
|
||||
type DataTableLoadingStateProps,
|
||||
} from "../loading-state/DataTableLoadingState";
|
||||
import {
|
||||
DataTableTbody,
|
||||
type DataTableTbodyProps,
|
||||
} from "../tbody/DataTableTbody";
|
||||
import { DataTableTd, type DataTableTdProps } from "../td/DataTableTd";
|
||||
import {
|
||||
DataTableTfoot,
|
||||
type DataTableTfootProps,
|
||||
} from "../tfoot/DataTableTfoot";
|
||||
import { DataTableTh, type DataTableThProps } from "../th/DataTableTh";
|
||||
import {
|
||||
DataTableThead,
|
||||
type DataTableTheadProps,
|
||||
} from "../thead/DataTableThead";
|
||||
import { DataTableTr, type DataTableTrProps } from "../tr/DataTableTr";
|
||||
import { DataTableContextProvider } from "./DataTableRoot.context";
|
||||
DataTableContextProvider,
|
||||
useDataTableContext,
|
||||
} from "./DataTableRoot.context";
|
||||
|
||||
interface DataTableProps
|
||||
extends React.HTMLAttributes<HTMLTableElement>, SelectionProps {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* TODO: For consideration:
|
||||
* - Use namespacing for types. There will be a lot of standalone types connected to this component,
|
||||
* it could make sense to access them under DataTable.X instead of separate imports.
|
||||
* - Consider having a "Wrapper" component that only handles context and logic like,
|
||||
* "DataTableRoot" or "DataGrid" or something, and then have the main "DataTable" component only handle rendering of table itself.
|
||||
* This would make props more focused and discoverable since its not mixed with htmltable-props.
|
||||
*/
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* - Test `onColumnDefinitionChange` callback that is called when resize, sort, order etc changes
|
||||
*/
|
||||
interface DataTableProps<T> extends React.HTMLAttributes<HTMLTableElement> {
|
||||
children?: never;
|
||||
/**
|
||||
* Controls vertical cell padding.
|
||||
* @default "normal"
|
||||
@@ -53,21 +69,16 @@ interface DataTableProps
|
||||
/**
|
||||
* Truncate content in cells and show ellipsis for overflowed text.
|
||||
*
|
||||
* **NB:** When using `layout="auto"`, you have to manually set a `maxWidth` on columns that should be truncated.
|
||||
* @default true
|
||||
* **NB:** When using this together with `layout="auto"`,
|
||||
* you have to manually set a `maxWidth` on columns that should be truncated.
|
||||
* @default false if layout="auto", else true
|
||||
*/
|
||||
truncateContent?: boolean; // TODO: Consider making this default false when layout=auto, and maybe disallow it but add a wrap prop on the td-comp.
|
||||
truncateContent?: boolean;
|
||||
/**
|
||||
* Enables keyboard navigation for table rows and cells.
|
||||
* @default false
|
||||
* @default true
|
||||
*/
|
||||
withKeyboardNav?: boolean;
|
||||
/**
|
||||
* Custom callback to determine if navigation should be blocked.
|
||||
* Called before default blocking logic.
|
||||
* Requires `withKeyboardNav` to be `true`.
|
||||
*/
|
||||
shouldBlockNavigation?: (event: KeyboardEvent) => boolean;
|
||||
/**
|
||||
* Controls table layout.
|
||||
*
|
||||
@@ -83,215 +94,420 @@ interface DataTableProps
|
||||
* @default "fixed"
|
||||
*/
|
||||
layout?: "fixed" | "auto";
|
||||
/**
|
||||
* Sticky columns that remain visible when horizontally scrolling the table.
|
||||
*
|
||||
* You can specify 1 sticky column on the left and 1 on the right.
|
||||
*/
|
||||
stickyColumns?: {
|
||||
start?: "1";
|
||||
end?: "1";
|
||||
};
|
||||
/**
|
||||
* @default true
|
||||
*/
|
||||
stickyHeader?: boolean;
|
||||
/**
|
||||
* Callback invoked when a data row is clicked.
|
||||
* Not called when clicking header, loading, or empty-state rows.
|
||||
*/
|
||||
onRowClick?: (
|
||||
rowId: TableRowEntryId,
|
||||
event: React.MouseEvent<HTMLTableRowElement>,
|
||||
) => void;
|
||||
/**
|
||||
* Content to render when `data` is empty.
|
||||
* Rendered inside a `DataTable.EmptyState` row spanning all columns.
|
||||
*/
|
||||
emptyContent?: React.ReactNode;
|
||||
/**
|
||||
* Configures how the table behaves during loading.
|
||||
*
|
||||
* Use `variant` to select the loading strategy:
|
||||
* - `"content"` — renders custom content inside a full-width row.
|
||||
* - `"skeleton"` — renders skeleton placeholder rows.
|
||||
* - `"overlay"` — keeps existing data visible with a loading overlay.
|
||||
*/
|
||||
loading?: DataTableLoadingConfig;
|
||||
/**
|
||||
* Adjusts font-size
|
||||
* @default "medium"
|
||||
*/
|
||||
textSize?: "small" | "medium";
|
||||
/**
|
||||
* Object with props related to row selection.
|
||||
*/
|
||||
selection?: SelectionProps<T>;
|
||||
/**
|
||||
* Object with props related to nested rows (sub-rows).
|
||||
*/
|
||||
subRows?: SubRowsProps<T>; // TODO: Table might need to be implemented with role="treegrid" for a11y when having nested rows.
|
||||
/**
|
||||
* Object with props related to details panel.
|
||||
* This is a panel that can be expanded below each row to show arbitrary content.
|
||||
*/
|
||||
detailsPanel?: DetailsPanelProps<T>;
|
||||
/**
|
||||
* Object with props related to sorting.
|
||||
*/
|
||||
sorting?: TableSortOptions;
|
||||
}
|
||||
|
||||
interface DataTableRootComponent extends React.ForwardRefExoticComponent<
|
||||
DataTableProps & React.RefAttributes<HTMLTableElement>
|
||||
> {
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableCaptionProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Caption>
|
||||
* Lorem ipsum
|
||||
* </DataTable.Caption
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Caption: typeof DataTableCaption;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableTheadProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Thead>
|
||||
* ... TODO
|
||||
* </DataTable.Thead>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Thead: typeof DataTableThead;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableTbodyProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Tbody>
|
||||
* ... TODO
|
||||
* </DataTable.Tbody>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Tbody: typeof DataTableTbody;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableTrProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Tr>
|
||||
* ... TODO
|
||||
* </DataTable.Tr
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Tr: typeof DataTableTr;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableThProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* ```
|
||||
*/
|
||||
Th: typeof DataTableTh;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableTdProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Tbody>
|
||||
* <DataTable.Td>
|
||||
* Lorem ipsum
|
||||
* </DataTable.Td>
|
||||
* <DataTable.Td>
|
||||
* Dolor sit amet
|
||||
* </DataTable.Td>
|
||||
* </DataTable.Tbody>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Td: typeof DataTableTd;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableTfootProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Tfoot>
|
||||
* ...
|
||||
* </DataTable.Tfoot>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Tfoot: typeof DataTableTfoot;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableEmptyStateProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.TBody>
|
||||
* <DataTable.EmptyState />
|
||||
* </DataTable.TBody>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
EmptyState: typeof DataTableEmptyState;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableEmptyStateProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.TBody>
|
||||
* <DataTable.LoadingState />
|
||||
* </DataTable.TBody>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
LoadingState: typeof DataTableLoadingState;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO Component description etc.
|
||||
*
|
||||
* **NB:** To get sticky headers, you have to set a height restriction on the table container. You can use VStack for this:
|
||||
* TODO example
|
||||
*/
|
||||
const DataTable = forwardRef<HTMLTableElement, DataTableProps>(
|
||||
const DataTableInternal = forwardRef<HTMLTableElement, DataTableProps<any>>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
id,
|
||||
rowDensity = "normal",
|
||||
withKeyboardNav = false,
|
||||
textSize = "medium",
|
||||
withKeyboardNav = true,
|
||||
zebraStripes = false,
|
||||
truncateContent = true,
|
||||
shouldBlockNavigation,
|
||||
truncateContent: truncateContentProp,
|
||||
layout = "fixed",
|
||||
stickyColumns,
|
||||
stickyHeader = true,
|
||||
onRowClick,
|
||||
emptyContent,
|
||||
selection,
|
||||
loading,
|
||||
detailsPanel,
|
||||
subRows,
|
||||
sorting,
|
||||
...rest
|
||||
},
|
||||
}: DataTableProps<any>, // Have to use <any> for docgen to work
|
||||
forwardedRef,
|
||||
) => {
|
||||
const { tabIndex, setTableRef } = useTableKeyboardNav({
|
||||
enabled: withKeyboardNav,
|
||||
shouldBlockNavigation,
|
||||
const { columnDefinitions, data, getRowId } = useDataGridContext();
|
||||
|
||||
const sortingState = useTableSort(sorting);
|
||||
|
||||
const tableItems = useTableItems({
|
||||
items: data,
|
||||
getRowId,
|
||||
subRows,
|
||||
});
|
||||
|
||||
const mergedRef = useMergeRefs(forwardedRef, setTableRef);
|
||||
const tableSelectionState = useTableSelection({
|
||||
selection,
|
||||
tableItems,
|
||||
});
|
||||
|
||||
const { columns, stickyStart, totalColSpan } = useColumnOptions(
|
||||
columnDefinitions,
|
||||
{
|
||||
stickyColumns,
|
||||
hasSelection: tableSelectionState.selection.selectionMode !== "none",
|
||||
hasDetailsPanel: !!detailsPanel?.getContent,
|
||||
layout,
|
||||
},
|
||||
);
|
||||
|
||||
const tableId = useId(id);
|
||||
|
||||
const truncateContent = truncateContentProp ?? layout !== "auto";
|
||||
|
||||
return (
|
||||
<DataTableContextProvider
|
||||
layout={layout}
|
||||
withKeyboardNav={withKeyboardNav}
|
||||
selectionState={noSelectionState}
|
||||
stickySelection={false}
|
||||
stickyHeader={false}
|
||||
tableId={useId()}
|
||||
showLoadingSkeletons={false}
|
||||
onRowClick={undefined}
|
||||
disableRowSelectionOnClick={false}
|
||||
showLoadingOverlay={false}
|
||||
columns={[]}
|
||||
selectionState={tableSelectionState}
|
||||
stickyStart={stickyStart}
|
||||
stickyHeader={stickyHeader}
|
||||
tableId={tableId}
|
||||
loading={loading}
|
||||
onRowClick={onRowClick}
|
||||
columns={columns}
|
||||
totalColSpan={totalColSpan}
|
||||
tableItems={tableItems}
|
||||
sortingState={sortingState}
|
||||
>
|
||||
<DataTableExpansionProvider>
|
||||
<div className="aksel-data-table__border-wrapper">
|
||||
<div className="aksel-data-table__scroll-wrapper">
|
||||
<table
|
||||
{...rest}
|
||||
ref={mergedRef}
|
||||
className={cl("aksel-data-table", className)}
|
||||
data-zebra-stripes={zebraStripes}
|
||||
data-truncate-content={truncateContent}
|
||||
data-density={rowDensity}
|
||||
data-layout={layout}
|
||||
tabIndex={tabIndex}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DataTableExpansionProvider>
|
||||
<TableElementWrapper enabled={withKeyboardNav}>
|
||||
<table
|
||||
{...rest}
|
||||
ref={forwardedRef}
|
||||
className={cl("aksel-data-table", className)}
|
||||
data-zebra-stripes={zebraStripes}
|
||||
data-truncate-content={truncateContent}
|
||||
data-density={rowDensity}
|
||||
data-text-size={textSize}
|
||||
data-layout={layout}
|
||||
data-loading={loading?.isLoading || undefined}
|
||||
aria-busy={loading?.isLoading || undefined}
|
||||
>
|
||||
<DataTableDetailsPanelProvider detailsPanel={detailsPanel}>
|
||||
<DataTableThead>
|
||||
<DataTableTr>
|
||||
{columns.map(
|
||||
({ isSticky, isStickyLast, stickyLeftOffset, colDef }) => {
|
||||
return (
|
||||
<DataTableColumnHeader
|
||||
id={colDef.id}
|
||||
width={colDef.width}
|
||||
textAlign={colDef.align ?? "left"}
|
||||
key={colDef.id}
|
||||
isSticky={isSticky}
|
||||
sortable={colDef.sortable}
|
||||
label={colDef.label}
|
||||
style={
|
||||
stickyLeftOffset
|
||||
? { left: stickyLeftOffset }
|
||||
: undefined
|
||||
}
|
||||
data-sticky-last={isStickyLast || undefined}
|
||||
>
|
||||
{colDef.header ?? colDef.label}
|
||||
</DataTableColumnHeader>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</DataTableTr>
|
||||
</DataTableThead>
|
||||
|
||||
<DataTableTbody>
|
||||
<DataTableTBodyContent emptyContent={emptyContent} />
|
||||
</DataTableTbody>
|
||||
</DataTableDetailsPanelProvider>
|
||||
</table>
|
||||
</TableElementWrapper>
|
||||
</DataTableContextProvider>
|
||||
);
|
||||
},
|
||||
) as DataTableRootComponent;
|
||||
);
|
||||
|
||||
DataTable.Caption = DataTableCaption;
|
||||
DataTable.Thead = DataTableThead;
|
||||
DataTable.Tbody = DataTableTbody;
|
||||
DataTable.Th = DataTableTh;
|
||||
DataTable.Tr = DataTableTr;
|
||||
DataTable.Td = DataTableTd;
|
||||
DataTable.Tfoot = DataTableTfoot;
|
||||
DataTable.EmptyState = DataTableEmptyState;
|
||||
DataTable.LoadingState = DataTableLoadingState;
|
||||
/**
|
||||
* Temp optimization to avoid re-renders on every keyboard-move, selection change etc
|
||||
*/
|
||||
function TableElementWrapper({
|
||||
children,
|
||||
enabled,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const [applyStickyStyles, setApplyStickyStyles] = useState<boolean>(false);
|
||||
|
||||
export {
|
||||
DataTable,
|
||||
DataTableCaption,
|
||||
DataTableEmptyState,
|
||||
DataTableLoadingState,
|
||||
DataTableTbody,
|
||||
DataTableTd,
|
||||
DataTableTfoot,
|
||||
DataTableTh,
|
||||
DataTableThead,
|
||||
DataTableTr,
|
||||
};
|
||||
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const tableRef = useRef<HTMLTableElement>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const { tabIndex, setTableRef } = useTableKeyboardNav({
|
||||
enabled,
|
||||
});
|
||||
|
||||
const mergedTableRefs = useMergeRefs(tableRef, setTableRef);
|
||||
|
||||
const updateStickyStyles = useCallback(() => {
|
||||
if (!tableWrapperRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const doesWrapperHasScroll =
|
||||
tableWrapperRef.current.scrollWidth > tableWrapperRef.current.clientWidth;
|
||||
|
||||
setApplyStickyStyles(doesWrapperHasScroll);
|
||||
}, []);
|
||||
|
||||
const scheduleStickyStylesUpdate = useCallback(() => {
|
||||
if (rafRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
updateStickyStyles();
|
||||
});
|
||||
}, [updateStickyStyles]);
|
||||
|
||||
useEffect(
|
||||
function observeAndUpdateStickyStyles() {
|
||||
const tableWrapperElement = tableWrapperRef.current;
|
||||
|
||||
if (!tableWrapperElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleResize = () => scheduleStickyStylesUpdate();
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
let resizeObserver: ResizeObserver | undefined;
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(tableWrapperElement);
|
||||
if (tableRef.current) {
|
||||
resizeObserver.observe(tableRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleStickyStylesUpdate();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
resizeObserver?.disconnect();
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
},
|
||||
[scheduleStickyStylesUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="aksel-data-table__border-wrapper">
|
||||
<div ref={tableWrapperRef} className="aksel-data-table__scroll-wrapper">
|
||||
<Slot
|
||||
tabIndex={tabIndex}
|
||||
/* @ts-expect-error Ref is not typed correctly to handle this case */
|
||||
ref={mergedTableRefs}
|
||||
data-scroll={applyStickyStyles ? "true" : undefined}
|
||||
>
|
||||
{children}
|
||||
</Slot>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataTableTBodyContentProps {
|
||||
emptyContent: React.ReactNode;
|
||||
}
|
||||
|
||||
function DataTableTBodyContent({ emptyContent }: DataTableTBodyContentProps) {
|
||||
const { columns, loading, totalColSpan, tableItems } = useDataTableContext();
|
||||
|
||||
if (loading?.isLoading && loading?.variant === "content") {
|
||||
return (
|
||||
<DataTableLoadingState colSpan={totalColSpan}>
|
||||
{loading.content}
|
||||
</DataTableLoadingState>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading?.isLoading && loading?.variant === "skeleton") {
|
||||
const rows = loading.rows ?? 5;
|
||||
const label = loading.label ?? "Laster innhold";
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td colSpan={totalColSpan} className="aksel-sr-only">
|
||||
{label}
|
||||
</td>
|
||||
</tr>
|
||||
{Array.from({ length: rows }, (_, rowIndex) => (
|
||||
<DataTableTr key={`skeleton-row-${rowIndex}`} aria-hidden>
|
||||
{columns.map(
|
||||
(
|
||||
{ isSticky, isStickyLast, stickyLeftOffset, colDef },
|
||||
colDefIndex,
|
||||
) => (
|
||||
<DataTableBaseCell
|
||||
textAlign={colDef.align ?? "left"}
|
||||
key={colDef.id || colDefIndex}
|
||||
as={colDef.isRowHeader ? "th" : "td"}
|
||||
isSticky={isSticky}
|
||||
style={
|
||||
stickyLeftOffset ? { left: stickyLeftOffset } : undefined
|
||||
}
|
||||
data-sticky-last={isStickyLast || undefined}
|
||||
>
|
||||
<Skeleton variant="text" />
|
||||
</DataTableBaseCell>
|
||||
),
|
||||
)}
|
||||
</DataTableTr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (tableItems.items.length === 0 && emptyContent !== undefined) {
|
||||
return (
|
||||
<DataTableEmptyState colSpan={totalColSpan}>
|
||||
{emptyContent}
|
||||
</DataTableEmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
const renderLoadingAnnouncement =
|
||||
loading?.isLoading && loading?.variant === "overlay";
|
||||
|
||||
const overlayLabel =
|
||||
loading?.variant === "overlay"
|
||||
? (loading.label ?? "Laster innhold")
|
||||
: "Laster innhold";
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderLoadingAnnouncement && (
|
||||
<tr>
|
||||
<td colSpan={totalColSpan} className="aksel-sr-only">
|
||||
{overlayLabel}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{tableItems.items.map((rowData, rowIndex) => {
|
||||
const rowId = tableItems.visibleRowIds[rowIndex];
|
||||
const details =
|
||||
rowId != null ? tableItems.itemDetails.get(rowId) : undefined;
|
||||
|
||||
/* Should in theory be impossible. Look about typing this? */
|
||||
if (!details) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasSubRows = details.children.length > 0;
|
||||
|
||||
return (
|
||||
<React.Fragment key={details.id}>
|
||||
<DataTableTr rowId={details.id}>
|
||||
{columns.map(
|
||||
(
|
||||
{ isSticky, isStickyLast, stickyLeftOffset, colDef },
|
||||
colDefIndex,
|
||||
) => {
|
||||
const renderNestedToggle = colDefIndex === 0 && hasSubRows;
|
||||
const renderNestedIndent =
|
||||
colDefIndex === 0 && (details.level > 0 || hasSubRows);
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
"--__axc-data-table-nested-depth": details.level,
|
||||
...(stickyLeftOffset ? { left: stickyLeftOffset } : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<DataTableBaseCell
|
||||
textAlign={colDef.align ?? "left"}
|
||||
key={colDef.id || colDefIndex}
|
||||
as={colDef.isRowHeader ? "th" : "td"}
|
||||
isSticky={isSticky}
|
||||
data-nested={renderNestedIndent || undefined}
|
||||
data-sticky-last={isStickyLast || undefined}
|
||||
style={style}
|
||||
beforeContent={
|
||||
renderNestedToggle ? (
|
||||
<DataTableSubRowToggle details={details} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{colDef.cell(rowData)}
|
||||
</DataTableBaseCell>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</DataTableTr>
|
||||
<DataTableDetailsPanelRow rowId={details.id} rowData={rowData} />
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const DataTable = DataTableInternal as <T>(
|
||||
props: DataTableProps<T> & React.RefAttributes<HTMLTableElement>,
|
||||
) => React.ReactElement | null;
|
||||
|
||||
// docgen doesn't work well with type params, so we let it use DataTableInternal instead
|
||||
export { DataTable, DataTableInternal };
|
||||
export type { DataTableProps };
|
||||
export default DataTable;
|
||||
export type {
|
||||
DataTableCaptionProps,
|
||||
DataTableEmptyStateProps,
|
||||
DataTableLoadingStateProps,
|
||||
DataTableProps,
|
||||
DataTableTbodyProps,
|
||||
DataTableTdProps,
|
||||
DataTableTfootProps,
|
||||
DataTableTheadProps,
|
||||
DataTableThProps,
|
||||
DataTableTrProps,
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from "react";
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "@navikt/aksel-icons";
|
||||
import { Button } from "../../../button";
|
||||
import { type ItemDetail } from "../hooks/useTableItems";
|
||||
import { useDataTableContext } from "../root/DataTableRoot.context";
|
||||
|
||||
function DataTableSubRowToggle({ details }: { details: ItemDetail<any> }) {
|
||||
const { tableItems } = useDataTableContext();
|
||||
|
||||
const subRows = details.children;
|
||||
const hasSubRows = subRows && subRows.length > 0;
|
||||
const isRowExpanded = tableItems.isSubRowExpanded(details.id);
|
||||
|
||||
return (
|
||||
<div className="aksel-data-table__nested-toggle">
|
||||
{hasSubRows && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
data-color="neutral"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
tableItems.onExpandedRowIdsChange(details.id);
|
||||
}}
|
||||
aria-expanded={isRowExpanded}
|
||||
aria-label={isRowExpanded ? "Skjul under-rader" : "Vis under-rader"}
|
||||
icon={
|
||||
isRowExpanded ? (
|
||||
<ChevronDownIcon aria-hidden />
|
||||
) : (
|
||||
<ChevronRightIcon aria-hidden />
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { DataTableSubRowToggle };
|
||||
@@ -11,7 +11,7 @@ const inertValue = parseInt(version.split(".")[0], 10) > 18 ? true : ""; // Supp
|
||||
|
||||
const DataTableTbody = forwardRef<HTMLTableSectionElement, DataTableTbodyProps>(
|
||||
({ className, ...rest }, forwardedRef) => {
|
||||
const { showLoadingOverlay } = useDataTableContext();
|
||||
const { loading } = useDataTableContext();
|
||||
return (
|
||||
<DataTableLocationProvider location="tbody">
|
||||
<tbody
|
||||
@@ -19,7 +19,11 @@ const DataTableTbody = forwardRef<HTMLTableSectionElement, DataTableTbodyProps>(
|
||||
ref={forwardedRef}
|
||||
className={cl("aksel-data-table__tbody", className)}
|
||||
// @ts-expect-error - inert is not yet recognized by React's type definitions, but we want to use it for better accessibility when showing the loading overlay.
|
||||
inert={showLoadingOverlay ? inertValue : false}
|
||||
inert={
|
||||
loading?.isLoading && loading.variant === "overlay"
|
||||
? inertValue
|
||||
: false
|
||||
}
|
||||
/>
|
||||
</DataTableLocationProvider>
|
||||
);
|
||||
|
||||
@@ -12,26 +12,30 @@ import { Skeleton } from "../../../skeleton";
|
||||
import { Label } from "../../../typography";
|
||||
import { useId } from "../../../utils-external";
|
||||
import { cl, composeEventHandlers } from "../../../utils/helpers";
|
||||
import { consoleWarning } from "../../../utils/helpers/consoleWarning";
|
||||
import { DataTableBaseCell } from "../base-cell/DataTableBaseCell";
|
||||
import { DataTableColumnHeader } from "../column-header/DataTableColumnHeader";
|
||||
import {
|
||||
getDataTableExpansionId,
|
||||
useDataTableExpansion,
|
||||
} from "../hooks/useTableExpansion";
|
||||
getDataTableDetailsPanelId,
|
||||
useDataTableDetailsPanel,
|
||||
} from "../hooks/useTableDetailsPanel";
|
||||
import type { TableRowEntryId } from "../root/DataTable.types";
|
||||
import {
|
||||
useDataTableContext,
|
||||
useDataTableLocation,
|
||||
} from "../root/DataTableRoot.context";
|
||||
import { DataTableTd } from "../td/DataTableTd";
|
||||
|
||||
const SELECTION_CELL_WIDTH = "50px";
|
||||
const ACTION_CELL_WIDTH = 50;
|
||||
|
||||
const ACTION_CELL_CSS_WIDTH = `${ACTION_CELL_WIDTH}px`;
|
||||
|
||||
type DataTableTrProps = React.HTMLAttributes<HTMLTableRowElement> & {
|
||||
selected?: boolean;
|
||||
/**
|
||||
* Unique identifier for the row, used for selection..
|
||||
*/
|
||||
rowId?: string | number;
|
||||
rowId?: TableRowEntryId;
|
||||
};
|
||||
|
||||
const DataTableTr = forwardRef<HTMLTableRowElement, DataTableTrProps>(
|
||||
@@ -46,14 +50,10 @@ const DataTableTr = forwardRef<HTMLTableRowElement, DataTableTrProps>(
|
||||
},
|
||||
forwardedRef,
|
||||
) => {
|
||||
const {
|
||||
layout,
|
||||
stickyHeader,
|
||||
selectionState,
|
||||
onRowClick,
|
||||
disableRowSelectionOnClick,
|
||||
} = useDataTableContext();
|
||||
const { layout, stickyHeader, selectionState, onRowClick } =
|
||||
useDataTableContext();
|
||||
const { location } = useDataTableLocation();
|
||||
const { tableItems } = useDataTableContext();
|
||||
|
||||
const renderFillerCell = layout === "fixed" && children;
|
||||
|
||||
@@ -81,10 +81,17 @@ const DataTableTr = forwardRef<HTMLTableRowElement, DataTableTrProps>(
|
||||
}
|
||||
|
||||
if (
|
||||
!disableRowSelectionOnClick &&
|
||||
selectionState.selectionTrigger === "row" &&
|
||||
selectionState.selection.selectionMode !== "none"
|
||||
) {
|
||||
selectionState.selection.toggleSelection(rowId);
|
||||
const rowData = tableItems.itemDetails.get(rowId)?.rowData;
|
||||
|
||||
if (!rowData) {
|
||||
consoleWarning(
|
||||
`No row data found for rowId ${rowId}. This may cause issues with selection if enableRowSelection is used.`,
|
||||
);
|
||||
}
|
||||
selectionState.selection.toggleSelection(rowId, rowData);
|
||||
}
|
||||
onRowClick?.(rowId, event);
|
||||
}
|
||||
@@ -118,8 +125,10 @@ const DataTableTr = forwardRef<HTMLTableRowElement, DataTableTrProps>(
|
||||
},
|
||||
);
|
||||
|
||||
function RowExpansionCell({ rowId }: { rowId?: string | number }) {
|
||||
const { tableId, showLoadingSkeletons } = useDataTableContext();
|
||||
function RowExpansionCell({ rowId }: { rowId?: TableRowEntryId }) {
|
||||
const { tableId, loading, stickyStart } = useDataTableContext();
|
||||
const stickyExpansion = stickyStart.expansion;
|
||||
|
||||
const { location } = useDataTableLocation();
|
||||
|
||||
const {
|
||||
@@ -130,25 +139,31 @@ function RowExpansionCell({ rowId }: { rowId?: string | number }) {
|
||||
isAllExpanded,
|
||||
toggleAll,
|
||||
showExpandAll,
|
||||
} = useDataTableExpansion();
|
||||
} = useDataTableDetailsPanel();
|
||||
|
||||
if (!enableDetailsPanel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (showLoadingSkeletons) {
|
||||
if (loading?.isLoading && loading?.variant === "skeleton") {
|
||||
if (location === "thead") {
|
||||
return (
|
||||
<DataTableColumnHeader
|
||||
width={SELECTION_CELL_WIDTH}
|
||||
UNSAFE_isSelection
|
||||
width={{ value: ACTION_CELL_CSS_WIDTH }}
|
||||
cellType="action"
|
||||
data-block-keyboard-nav
|
||||
/* isSticky={stickySelection && "start"} */
|
||||
label=""
|
||||
isSticky={stickyExpansion && "start"}
|
||||
style={stickyExpansion ? { left: 0 } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DataTableBaseCell as="td">
|
||||
<DataTableBaseCell
|
||||
as="td"
|
||||
isSticky={stickyExpansion && "start"}
|
||||
style={stickyExpansion ? { left: 0 } : undefined}
|
||||
>
|
||||
<Skeleton variant="text" />
|
||||
</DataTableBaseCell>
|
||||
);
|
||||
@@ -157,10 +172,12 @@ function RowExpansionCell({ rowId }: { rowId?: string | number }) {
|
||||
if (location === "thead" && !showExpandAll) {
|
||||
return (
|
||||
<DataTableColumnHeader
|
||||
width={SELECTION_CELL_WIDTH}
|
||||
UNSAFE_isSelection
|
||||
width={{ value: ACTION_CELL_CSS_WIDTH }}
|
||||
cellType="action"
|
||||
data-block-keyboard-nav
|
||||
/* isSticky={stickySelection && "start"} */
|
||||
label=""
|
||||
isSticky={stickyExpansion && "start"}
|
||||
style={stickyExpansion ? { left: 0 } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -169,9 +186,11 @@ function RowExpansionCell({ rowId }: { rowId?: string | number }) {
|
||||
return (
|
||||
<DataTableColumnHeader
|
||||
textAlign="center"
|
||||
width={SELECTION_CELL_WIDTH}
|
||||
UNSAFE_isSelection
|
||||
/* isSticky={stickySelection && "start"} */
|
||||
width={{ value: ACTION_CELL_CSS_WIDTH }}
|
||||
cellType="action"
|
||||
label=""
|
||||
isSticky={stickyExpansion && "start"}
|
||||
style={stickyExpansion ? { left: 0 } : undefined}
|
||||
>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
@@ -198,14 +217,26 @@ function RowExpansionCell({ rowId }: { rowId?: string | number }) {
|
||||
|
||||
const isRowExpanded = isExpanded(rowId);
|
||||
const canExpandRow = isDetailsPanelExpandable(rowId);
|
||||
const expansionId = getDataTableExpansionId(tableId, rowId);
|
||||
const expansionId = getDataTableDetailsPanelId(tableId, rowId);
|
||||
|
||||
if (!canExpandRow) {
|
||||
return <DataTableTd UNSAFE_isSelection preventRowClick />;
|
||||
return (
|
||||
<DataTableTd
|
||||
cellType="action"
|
||||
preventRowClick
|
||||
isSticky={stickyExpansion && "start"}
|
||||
style={stickyExpansion ? { left: 0 } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTableTd UNSAFE_isSelection preventRowClick>
|
||||
<DataTableTd
|
||||
cellType="action"
|
||||
preventRowClick
|
||||
isSticky={stickyExpansion && "start"}
|
||||
style={stickyExpansion ? { left: 0 } : undefined}
|
||||
>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
data-color="neutral"
|
||||
@@ -229,10 +260,14 @@ function RowExpansionCell({ rowId }: { rowId?: string | number }) {
|
||||
* TODO: How do these cells handle multiple thead rows, or col/row-spans?
|
||||
* TODO: a11y for labels
|
||||
*/
|
||||
function RowSelectionCell({ rowId }: { rowId?: string | number }) {
|
||||
const { selectionState, stickySelection, showLoadingSkeletons } =
|
||||
useDataTableContext();
|
||||
function RowSelectionCell({ rowId }: { rowId?: TableRowEntryId }) {
|
||||
const { selectionState, stickyStart, loading } = useDataTableContext();
|
||||
const stickySelection = stickyStart.selection;
|
||||
const stickySelectionOffset = stickyStart.selectionOffset;
|
||||
const { location } = useDataTableLocation();
|
||||
|
||||
const { tableItems } = useDataTableContext();
|
||||
|
||||
const inputId = useId();
|
||||
|
||||
const { selection, renderSelection } = selectionState;
|
||||
@@ -241,20 +276,26 @@ function RowSelectionCell({ rowId }: { rowId?: string | number }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (showLoadingSkeletons) {
|
||||
if (loading?.isLoading && loading?.variant === "skeleton") {
|
||||
if (location === "thead") {
|
||||
return (
|
||||
<DataTableColumnHeader
|
||||
width={SELECTION_CELL_WIDTH}
|
||||
UNSAFE_isSelection
|
||||
width={{ value: ACTION_CELL_CSS_WIDTH }}
|
||||
cellType="action"
|
||||
label=""
|
||||
data-block-keyboard-nav
|
||||
isSticky={stickySelection && "start"}
|
||||
style={stickySelection ? { left: stickySelectionOffset } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTableBaseCell as="td">
|
||||
<DataTableBaseCell
|
||||
as="td"
|
||||
isSticky={stickySelection && "start"}
|
||||
style={stickySelection ? { left: stickySelectionOffset } : undefined}
|
||||
>
|
||||
<Skeleton variant="text" />
|
||||
</DataTableBaseCell>
|
||||
);
|
||||
@@ -272,9 +313,11 @@ function RowSelectionCell({ rowId }: { rowId?: string | number }) {
|
||||
return (
|
||||
<DataTableColumnHeader
|
||||
textAlign="center"
|
||||
width={SELECTION_CELL_WIDTH}
|
||||
UNSAFE_isSelection
|
||||
width={{ value: ACTION_CELL_CSS_WIDTH }}
|
||||
cellType="action"
|
||||
label=""
|
||||
isSticky={stickySelection && "start"}
|
||||
style={stickySelection ? { left: stickySelectionOffset } : undefined}
|
||||
>
|
||||
<Label htmlFor={inputId} visuallyHidden>
|
||||
{labelText}
|
||||
@@ -287,10 +330,12 @@ function RowSelectionCell({ rowId }: { rowId?: string | number }) {
|
||||
if (selection.selectionMode === "single" && location === "thead") {
|
||||
return (
|
||||
<DataTableColumnHeader
|
||||
width={SELECTION_CELL_WIDTH}
|
||||
UNSAFE_isSelection
|
||||
width={{ value: ACTION_CELL_CSS_WIDTH }}
|
||||
cellType="action"
|
||||
label=""
|
||||
data-block-keyboard-nav
|
||||
isSticky={stickySelection && "start"}
|
||||
style={stickySelection ? { left: stickySelectionOffset } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -301,16 +346,35 @@ function RowSelectionCell({ rowId }: { rowId?: string | number }) {
|
||||
|
||||
if (selection.selectionMode === "multiple" && location === "tbody") {
|
||||
return (
|
||||
<DataTableTd UNSAFE_isSelection isSticky={stickySelection && "start"}>
|
||||
<CheckboxInput {...selection.getRowCheckboxProps(rowId)} compact />
|
||||
<DataTableTd
|
||||
cellType="action"
|
||||
isSticky={stickySelection && "start"}
|
||||
style={stickySelection ? { left: stickySelectionOffset } : undefined}
|
||||
>
|
||||
<CheckboxInput
|
||||
{...selection.getRowCheckboxProps(
|
||||
rowId,
|
||||
tableItems.itemDetails.get(rowId)?.rowData,
|
||||
)}
|
||||
compact
|
||||
/>
|
||||
</DataTableTd>
|
||||
);
|
||||
}
|
||||
|
||||
if (selection.selectionMode === "single" && location === "tbody") {
|
||||
return (
|
||||
<DataTableTd UNSAFE_isSelection isSticky={stickySelection && "start"}>
|
||||
<RadioInput {...selection.getRowRadioProps(rowId)} />
|
||||
<DataTableTd
|
||||
cellType="action"
|
||||
isSticky={stickySelection && "start"}
|
||||
style={stickySelection ? { left: stickySelectionOffset } : undefined}
|
||||
>
|
||||
<RadioInput
|
||||
{...selection.getRowRadioProps(
|
||||
rowId,
|
||||
tableItems.itemDetails.get(rowId)?.rowData,
|
||||
)}
|
||||
/>
|
||||
</DataTableTd>
|
||||
);
|
||||
}
|
||||
@@ -325,5 +389,5 @@ function isInteractiveTarget(target: EventTarget | null): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export { DataTableTr };
|
||||
export { DataTableTr, ACTION_CELL_WIDTH };
|
||||
export type { DataTableTrProps };
|
||||
@@ -1,8 +1,8 @@
|
||||
import { StoryObj } from "@storybook/react-vite";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { VStack } from "../../primitives/stack";
|
||||
import { DataTable } from "../table";
|
||||
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
|
||||
import { DataTable } from "../table/root/DataTableRoot.legacy";
|
||||
import { TokenFilter } from "./TokenFilter";
|
||||
import type {
|
||||
ExternalPropertyDefinition,
|
||||
@@ -407,10 +407,14 @@ export const WithDataTable: Story = {
|
||||
<DataTable zebraStripes layout="auto">
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Name</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Status</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Region</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>CPU</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Name">Name</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Status">
|
||||
Status
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Region">
|
||||
Region
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="CPU">CPU</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
|
||||
@@ -35,6 +35,7 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
"indeterminate",
|
||||
"errorId",
|
||||
"readOnly",
|
||||
"className",
|
||||
])}
|
||||
{...omit(inputProps, ["aria-invalid", "aria-describedby"])}
|
||||
aria-describedby={
|
||||
|
||||
@@ -24,7 +24,13 @@ export const Radio = forwardRef<HTMLInputElement, RadioProps>(
|
||||
>
|
||||
<RadioInput
|
||||
ref={forwardedRef}
|
||||
{...omit(props, ["children", "size", "description", "readOnly"])}
|
||||
{...omit(props, [
|
||||
"children",
|
||||
"size",
|
||||
"description",
|
||||
"readOnly",
|
||||
"className",
|
||||
])}
|
||||
{...omit(inputProps, ["aria-invalid", "aria-describedby"])}
|
||||
aria-describedby={
|
||||
cl(inputProps["aria-describedby"], {
|
||||
|
||||
@@ -39,7 +39,8 @@ interface ModalPropsBase extends React.DialogHTMLAttributes<HTMLDialogElement> {
|
||||
/**
|
||||
* Called when the user tries to close the modal by one of the built-in methods.
|
||||
* Used if you want to ask the user for confirmation before closing.
|
||||
* @warning Will not always be called when pressing Esc. See `onCancel` for more info.
|
||||
*
|
||||
* **NB:** Will not always be called when pressing Esc. See `onCancel` for more info.
|
||||
* @returns Whether to close the modal or not
|
||||
*/
|
||||
onBeforeClose?: () => boolean;
|
||||
@@ -52,7 +53,8 @@ interface ModalPropsBase extends React.DialogHTMLAttributes<HTMLDialogElement> {
|
||||
onCancel?: React.ReactEventHandler<HTMLDialogElement>;
|
||||
/**
|
||||
* Whether to close when clicking on the backdrop.
|
||||
* @warning Users may click outside by accident. Don't use if closing can cause data loss, or the modal contains important info.
|
||||
*
|
||||
* **NB:** Users may click outside by accident. Don't use if closing can cause data loss, or the modal contains important info.
|
||||
* @default false
|
||||
*/
|
||||
closeOnBackdropClick?: boolean;
|
||||
@@ -77,13 +79,15 @@ interface ModalPropsBase extends React.DialogHTMLAttributes<HTMLDialogElement> {
|
||||
/**
|
||||
* ID of the element that labels the modal.
|
||||
* No need to set this manually if the `header` prop is used. A reference to `header.heading` will be created automatically.
|
||||
* @warning If not using `header`, you should set either `aria-labelledby` or `aria-label`.
|
||||
*
|
||||
* **NB:** If not using `header`, you should set either `aria-labelledby` or `aria-label`.
|
||||
*/
|
||||
"aria-labelledby"?: string;
|
||||
/**
|
||||
* String value that labels the modal.
|
||||
* No need to set this if the `header` prop is used.
|
||||
* @warning If not using `header`, you should set either `aria-labelledby` or `aria-label`.
|
||||
*
|
||||
* **NB:** If not using `header`, you should set either `aria-labelledby` or `aria-label`.
|
||||
*/
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
SortDownIcon,
|
||||
SortUpIcon,
|
||||
} from "@navikt/aksel-icons";
|
||||
import { consoleWarning } from "../utils/helpers/consoleWarning";
|
||||
import HeaderCell, { HeaderCellProps } from "./HeaderCell";
|
||||
import { TableContext } from "./context";
|
||||
|
||||
@@ -28,7 +29,7 @@ export const ColumnHeader: ColumnHeaderType = forwardRef(
|
||||
const context = useContext(TableContext);
|
||||
|
||||
if (sortable && !sortKey) {
|
||||
console.warn("ColumnHeader with `sortable=true` must have a sortKey.");
|
||||
consoleWarning("ColumnHeader with `sortable=true` must have a sortKey.");
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,7 +19,7 @@ function useGlobalId(idOverride?: string): string | undefined {
|
||||
// We can't use it server-side.
|
||||
// If you want to use random values please consider the Birthday Problem: https://en.wikipedia.org/wiki/Birthday_problem
|
||||
globalId += 1;
|
||||
setDefaultId(`aksel-id-${globalId}`);
|
||||
setDefaultId(`aksel-id-${globalId}`); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
}
|
||||
}, [defaultId]);
|
||||
return id;
|
||||
|
||||
+28
-28
@@ -215,40 +215,40 @@ export const DisableOutsidePointerEventsWhileHidden = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const Layer = ({
|
||||
disableOutsidePointerEvents,
|
||||
children,
|
||||
}: {
|
||||
disableOutsidePointerEvents?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const style = {
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "red",
|
||||
};
|
||||
|
||||
return (
|
||||
<DismissableLayer
|
||||
disableOutsidePointerEvents={disableOutsidePointerEvents}
|
||||
style={style}
|
||||
>
|
||||
<button onClick={() => setOpen(false)}>Close me</button>
|
||||
{children}
|
||||
</DismissableLayer>
|
||||
);
|
||||
};
|
||||
|
||||
export const ParallelDismissableLayer = () => {
|
||||
const [single, setSingle] = useState(false);
|
||||
const [double, setDouble] = useState(false);
|
||||
const [nestedSingle, setNestedSingle] = useState(false);
|
||||
const [nestedDouble, setNestedDouble] = useState(false);
|
||||
|
||||
const Layer = ({
|
||||
disableOutsidePointerEvents,
|
||||
children,
|
||||
}: {
|
||||
disableOutsidePointerEvents?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const style = {
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "red",
|
||||
};
|
||||
|
||||
return (
|
||||
<DismissableLayer
|
||||
disableOutsidePointerEvents={disableOutsidePointerEvents}
|
||||
style={style}
|
||||
>
|
||||
<button onClick={() => setOpen(false)}>Close me</button>
|
||||
{children}
|
||||
</DismissableLayer>
|
||||
);
|
||||
};
|
||||
|
||||
const state = (_state: boolean) => (_state ? "open" : "closed");
|
||||
|
||||
return (
|
||||
|
||||
@@ -342,7 +342,7 @@ const DismissableLayer = forwardRef<HTMLDivElement, DismissableLayerProps>(
|
||||
if (disableOutsidePointerEvents) {
|
||||
if (context.layersWithOutsidePointerEventsDisabled.size === 0) {
|
||||
originalBodyPointerEvents = ownerDoc.body.style.pointerEvents;
|
||||
ownerDoc.body.style.pointerEvents = "none"; // eslint-disable-line react-hooks/immutability
|
||||
ownerDoc.body.style.pointerEvents = "none";
|
||||
}
|
||||
context.layersWithOutsidePointerEventsDisabled.add(node);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useMergeRefs,
|
||||
} from "../../hooks";
|
||||
import { DismissableLayer } from "../dismissablelayer/DismissableLayer";
|
||||
import { Floating } from "../floating/Floating";
|
||||
import { Floating, MENU_COLLISION_AVOIDANCE } from "../floating/Floating";
|
||||
import { FocusBoundary } from "../focus-boundary/FocusBoundary";
|
||||
import { RovingFocus, RovingFocusProps } from "./parts/RovingFocus";
|
||||
import {
|
||||
@@ -328,6 +328,9 @@ const MenuContentInternal = forwardRef<
|
||||
data-state={getOpenState(context.open)}
|
||||
data-aksel-menu-content=""
|
||||
dir="ltr"
|
||||
fallbackAxisSideDirection={
|
||||
MENU_COLLISION_AVOIDANCE.fallbackAxisSide
|
||||
}
|
||||
{...rest}
|
||||
ref={composedRefs}
|
||||
style={{ outline: "none", ...rest.style }}
|
||||
|
||||
@@ -36,6 +36,22 @@ import {
|
||||
transformOrigin,
|
||||
} from "./Floating.utils";
|
||||
|
||||
/**
|
||||
* Used for menus that prefer top/bottom placements and
|
||||
* use height-var to limit their height.
|
||||
*/
|
||||
const MENU_COLLISION_AVOIDANCE = {
|
||||
fallbackAxisSide: "none",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Used by regular popovers that usually aren't scrollable and are allowed to
|
||||
* freely flip to any axis of placement.
|
||||
*/
|
||||
const POPOVER_COLLISION_AVOIDANCE = {
|
||||
fallbackAxisSide: "end",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Floating Root
|
||||
*/
|
||||
@@ -189,6 +205,7 @@ interface FloatingContentProps extends HTMLAttributes<HTMLDivElement> {
|
||||
hideWhenDetached?: boolean;
|
||||
updatePositionStrategy?: "optimized" | "always";
|
||||
fallbackPlacements?: FlipOptions["fallbackPlacements"];
|
||||
fallbackAxisSideDirection?: FlipOptions["fallbackAxisSideDirection"];
|
||||
onPlaced?: () => void;
|
||||
/**
|
||||
* @default true
|
||||
@@ -225,6 +242,7 @@ const FloatingContent = forwardRef<HTMLDivElement, FloatingContentProps>(
|
||||
fallbackPlacements,
|
||||
enabled = true,
|
||||
autoUpdateWhileMounted = true,
|
||||
fallbackAxisSideDirection = POPOVER_COLLISION_AVOIDANCE.fallbackAxisSide,
|
||||
...contentProps
|
||||
}: FloatingContentProps,
|
||||
forwardedRef,
|
||||
@@ -245,10 +263,31 @@ const FloatingContent = forwardRef<HTMLDivElement, FloatingContentProps>(
|
||||
const desiredPlacement = (side +
|
||||
(align !== "center" ? "-" + align : "")) as Placement;
|
||||
|
||||
/**
|
||||
* Create a bias to the preferred side.
|
||||
* On iOS, when the mobile software keyboard opens, the input is exactly centered
|
||||
* in the viewport, but this can cause it to flip to the top undesirably.
|
||||
*/
|
||||
const bias = 1;
|
||||
const biasTop = side === "bottom" ? bias : 0;
|
||||
const biasBottom = side === "top" ? bias : 0;
|
||||
const biasLeft = side === "right" ? bias : 0;
|
||||
const biasRight = side === "left" ? bias : 0;
|
||||
|
||||
const collisionPadding =
|
||||
typeof collisionPaddingProp === "number"
|
||||
? collisionPaddingProp
|
||||
: { top: 0, right: 0, bottom: 0, left: 0, ...collisionPaddingProp };
|
||||
? {
|
||||
top: collisionPaddingProp + biasTop,
|
||||
right: collisionPaddingProp + biasRight,
|
||||
bottom: collisionPaddingProp + biasBottom,
|
||||
left: collisionPaddingProp + biasLeft,
|
||||
}
|
||||
: {
|
||||
top: (collisionPaddingProp.top || 0) + biasTop,
|
||||
right: (collisionPaddingProp.right || 0) + biasRight,
|
||||
bottom: (collisionPaddingProp.bottom || 0) + biasBottom,
|
||||
left: (collisionPaddingProp.left || 0) + biasLeft,
|
||||
};
|
||||
|
||||
const boundary = Array.isArray(collisionBoundary)
|
||||
? collisionBoundary
|
||||
@@ -256,20 +295,13 @@ const FloatingContent = forwardRef<HTMLDivElement, FloatingContentProps>(
|
||||
|
||||
const hasExplicitBoundaries = boundary.length > 0;
|
||||
|
||||
/**
|
||||
* .filter(x => x !== null) does not narrow the type of the array enough.
|
||||
*/
|
||||
function isNotNull<T>(value: T | null): value is T {
|
||||
return value !== null;
|
||||
}
|
||||
|
||||
/* https://floating-ui.com/docs/detectOverflow#boundary */
|
||||
const detectOverflowOptions: FlipOptions = {
|
||||
padding: collisionPadding,
|
||||
boundary: boundary.filter(isNotNull),
|
||||
// with `strategy: 'fixed'`, this is the only way to get it to respect boundaries
|
||||
altBoundary: hasExplicitBoundaries,
|
||||
/* https://floating-ui.com/docs/flip#fallbackaxissidedirection */
|
||||
fallbackAxisSideDirection: "end",
|
||||
fallbackAxisSideDirection,
|
||||
fallbackPlacements,
|
||||
};
|
||||
|
||||
@@ -308,7 +340,11 @@ const FloatingContent = forwardRef<HTMLDivElement, FloatingContentProps>(
|
||||
crossAxis: false,
|
||||
limiter: limitShift(),
|
||||
}),
|
||||
avoidCollisions && flip({ ...detectOverflowOptions }),
|
||||
avoidCollisions &&
|
||||
flip({
|
||||
...detectOverflowOptions,
|
||||
padding: collisionPadding,
|
||||
}),
|
||||
size({
|
||||
...detectOverflowOptions,
|
||||
apply: ({ elements, rects, availableWidth, availableHeight }) => {
|
||||
@@ -437,7 +473,14 @@ const FloatingContent = forwardRef<HTMLDivElement, FloatingContentProps>(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* .filter(x => x !== null) does not narrow the type of the array enough.
|
||||
*/
|
||||
function isNotNull<T>(value: T | null): value is T {
|
||||
return value !== null;
|
||||
}
|
||||
|
||||
Floating.Anchor = FloatingAnchor;
|
||||
Floating.Content = FloatingContent;
|
||||
|
||||
export { Floating };
|
||||
export { Floating, MENU_COLLISION_AVOIDANCE, POPOVER_COLLISION_AVOIDANCE };
|
||||
@@ -65,7 +65,7 @@ function createStrictContext<T>(options: {
|
||||
|
||||
function Provider({ children, ...context }: ProviderProps<T>) {
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Object.values(context) includes all dependencies.
|
||||
const value = React.useMemo(() => context, Object.values(context)) as T; // eslint-disable-line react-hooks/exhaustive-deps, react-hooks/use-memo
|
||||
const value = React.useMemo(() => context, Object.values(context)) as T; // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return <Context.Provider value={value}>{children}</Context.Provider>;
|
||||
}
|
||||
|
||||
@@ -2,28 +2,31 @@
|
||||
import { useState } from "react";
|
||||
import { useEventCallback } from "./useEventCallback";
|
||||
|
||||
export interface UseControllableStateProps<T> {
|
||||
value?: T;
|
||||
defaultValue: T | (() => T);
|
||||
onChange?: (value: T) => void;
|
||||
export interface UseControllableStateProps<
|
||||
StateT,
|
||||
ChangeT extends StateT = StateT,
|
||||
> {
|
||||
value?: StateT;
|
||||
defaultValue: StateT | (() => StateT);
|
||||
onChange?: (value: ChangeT) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* `useControllableState` returns the state and function that updates the state, just like React.useState does.
|
||||
*/
|
||||
export function useControllableState<T>({
|
||||
export function useControllableState<StateT, ChangeT extends StateT = StateT>({
|
||||
value: valueProp,
|
||||
defaultValue,
|
||||
onChange,
|
||||
}: UseControllableStateProps<T>) {
|
||||
}: UseControllableStateProps<StateT, ChangeT>) {
|
||||
const onChangeProp = useEventCallback(onChange);
|
||||
|
||||
const [uncontrolledState, setUncontrolledState] = useState(defaultValue);
|
||||
const controlled = valueProp !== undefined;
|
||||
const value = controlled ? valueProp : uncontrolledState;
|
||||
|
||||
const setValue = useEventCallback((next: React.SetStateAction<T>) => {
|
||||
const setter = next as (prevState?: T) => T;
|
||||
const setValue = useEventCallback((next: React.SetStateAction<ChangeT>) => {
|
||||
const setter = next as (prevState?: StateT) => ChangeT;
|
||||
const nextValue = typeof next === "function" ? setter(value) : next;
|
||||
|
||||
if (!controlled) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useRefWithInit } from "./useRefWithInit";
|
||||
function useValueAsRef<T>(value: T) {
|
||||
const latest = useRefWithInit(createLatestRef, value).current!;
|
||||
|
||||
latest.next = value;
|
||||
latest.next = value; // eslint-disable-line react-hooks/immutability
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useClientLayoutEffect(latest.effect);
|
||||
|
||||
@@ -7,218 +7,139 @@ interface TranslationMap {
|
||||
export default {
|
||||
global: {
|
||||
dateLocale: nb,
|
||||
/** @default "Vis mer" */
|
||||
showMore: "Vis mer",
|
||||
/** @default "Vis mindre" */
|
||||
showLess: "Vis mindre",
|
||||
/** @default "Skrivebeskyttet" */
|
||||
readOnly: "Skrivebeskyttet",
|
||||
/** @default "Lukk" */
|
||||
close: "Lukk",
|
||||
/** @default "Feil" */
|
||||
error: "Feil",
|
||||
/** @default "Informasjon" */
|
||||
info: "Informasjon",
|
||||
/** @default "Suksess" */
|
||||
success: "Suksess",
|
||||
/** @default "Advarsel" */
|
||||
warning: "Advarsel",
|
||||
/** @default "Kunngjøring" */
|
||||
announcement: "Kunngjøring",
|
||||
},
|
||||
Chips: {
|
||||
Removable: {
|
||||
/** Will be appended to the accessible name for the button.
|
||||
* @default "slett" */
|
||||
/** Will be appended to the accessible name for the button. */
|
||||
labelSuffix: "slett",
|
||||
},
|
||||
},
|
||||
Combobox: {
|
||||
/** The input value will be appended to the end of this text, e.g. `Legg til "input value"`.
|
||||
* @default "Legg til" */
|
||||
/** The input value will be appended to the end of this text, e.g. `Legg til "input value"`. */
|
||||
addOption: "Legg til",
|
||||
/** @default "Ingen søketreff" */
|
||||
noMatches: "Ingen søketreff",
|
||||
/** Loader title
|
||||
* @default "Søker…" */
|
||||
/** Loader title */
|
||||
loading: "Søker…",
|
||||
/** @default "{selected} av maks {limit} er valgt." */
|
||||
maxSelected: "{selected} av maks {limit} er valgt.",
|
||||
},
|
||||
CopyButton: {
|
||||
/** @default "Kopier" */
|
||||
title: "Kopier",
|
||||
/** @default "Kopiert!" */
|
||||
activeText: "Kopiert!",
|
||||
},
|
||||
DatePicker: {
|
||||
/** @default "Velg dato" */
|
||||
chooseDate: "Velg dato",
|
||||
/** @default "Velg datoer" */
|
||||
chooseDates: "Velg datoer",
|
||||
/** @default "Velg start- og sluttdato" */
|
||||
chooseDateRange: "Velg start- og sluttdato",
|
||||
/** @default "Velg måned" */
|
||||
chooseMonth: "Velg måned",
|
||||
/** @default "Uke" */
|
||||
week: "Uke",
|
||||
/** @default "Uke {week}" */
|
||||
weekNumber: "Uke {week}",
|
||||
/** @default "Velg uke {week}" */
|
||||
selectWeekNumber: "Velg uke {week}",
|
||||
/** @default "Måned" */
|
||||
month: "Måned",
|
||||
/** @default "Gå til neste måned" */
|
||||
goToNextMonth: "Gå til neste måned",
|
||||
/** @default "Gå til forrige måned" */
|
||||
goToPreviousMonth: "Gå til forrige måned",
|
||||
/** @default "År" */
|
||||
year: "År",
|
||||
/** @default "Gå til neste år" */
|
||||
goToNextYear: "Gå til neste år",
|
||||
/** @default "Gå til forrige år" */
|
||||
goToPreviousYear: "Gå til forrige år",
|
||||
/** @default "Åpne datovelger" */
|
||||
openDatePicker: "Åpne datovelger",
|
||||
/** @default "Åpne månedsvelger" */
|
||||
openMonthPicker: "Åpne månedsvelger",
|
||||
/** @default "Lukk datovelger" */
|
||||
closeDatePicker: "Lukk datovelger",
|
||||
/** @default "Lukk månedsvelger" */
|
||||
closeMonthPicker: "Lukk månedsvelger",
|
||||
},
|
||||
ErrorSummary: {
|
||||
/** @default "Du må rette disse feilene før du kan fortsette:" */
|
||||
heading: "Du må rette disse feilene før du kan fortsette:",
|
||||
},
|
||||
FileUpload: {
|
||||
dropzone: {
|
||||
/** @default "Velg fil" */
|
||||
button: "Velg fil",
|
||||
/** @default "Velg filer" */
|
||||
buttonMultiple: "Velg filer",
|
||||
/** @default "Dra og slipp filen her" */
|
||||
dragAndDrop: "Dra og slipp filen her",
|
||||
/** @default "Dra og slipp filer her" */
|
||||
dragAndDropMultiple: "Dra og slipp filer her",
|
||||
/** @default "Slipp" */
|
||||
drop: "Slipp",
|
||||
/** @default "eller" */
|
||||
or: "eller",
|
||||
/** @default "Filopplasting er deaktivert" */
|
||||
disabled: "Filopplasting er deaktivert",
|
||||
/** @default "Du kan ikke laste opp flere filer" */
|
||||
disabledFilelimit: "Du kan ikke laste opp flere filer",
|
||||
},
|
||||
item: {
|
||||
/** @default "Prøv å laste opp filen på nytt" */
|
||||
retryButtonTitle: "Prøv å laste opp filen på nytt",
|
||||
/** @default "Slett filen" */
|
||||
deleteButtonTitle: "Slett filen",
|
||||
/** @default "Laster opp…" */
|
||||
uploading: "Laster opp…",
|
||||
/** @default "Laster ned…" */
|
||||
downloading: "Laster ned…",
|
||||
},
|
||||
},
|
||||
FormProgress: {
|
||||
/** @default "Steg {activeStep} av {totalSteps}" */
|
||||
step: "Steg {activeStep} av {totalSteps}",
|
||||
/** @default "Vis alle steg" */
|
||||
showAllSteps: "Vis alle steg",
|
||||
/** @default "Skjul alle steg" */
|
||||
hideAllSteps: "Skjul alle steg",
|
||||
},
|
||||
FormSummary: {
|
||||
/** @default "Endre svar" */
|
||||
editAnswer: "Endre svar",
|
||||
},
|
||||
GuidePanel: {
|
||||
/** @default "Illustrasjon av veileder" */
|
||||
illustrationLabel: "Illustrasjon av veileder",
|
||||
},
|
||||
HelpText: {
|
||||
/** @default "Mer informasjon" */
|
||||
title: "Mer informasjon",
|
||||
},
|
||||
Loader: {
|
||||
/** @default "Venter…" */
|
||||
title: "Venter…",
|
||||
},
|
||||
Pagination: {
|
||||
/** @default "Forrige" */
|
||||
previous: "Forrige",
|
||||
/** @default "Neste" */
|
||||
next: "Neste",
|
||||
},
|
||||
Process: {
|
||||
/** @default "Aktiv" */
|
||||
active: "Aktiv",
|
||||
},
|
||||
ProgressBar: {
|
||||
/** @default "{current} av {max}" */
|
||||
progress: "{current} av {max}",
|
||||
/** @default "Fremdrift kan ikke beregnes, antatt tid er {seconds} sekunder." */
|
||||
progressUnknown:
|
||||
"Fremdrift kan ikke beregnes, antatt tid er {seconds} sekunder.",
|
||||
},
|
||||
Search: {
|
||||
/** @default "Tøm feltet" */
|
||||
clear: "Tøm feltet",
|
||||
/** @default "Søk" */
|
||||
search: "Søk",
|
||||
},
|
||||
Textarea: {
|
||||
/** Screen readers only
|
||||
* @default "Tekstområde med plass til {maxLength} tegn." */
|
||||
/** Screen readers only */
|
||||
maxLength: "Tekstområde med plass til {maxLength} tegn.",
|
||||
/** @default "{chars} tegn for mye" */
|
||||
charsTooMany: "{chars} tegn for mye",
|
||||
/** @default "{chars} tegn igjen" */
|
||||
charsLeft: "{chars} tegn igjen",
|
||||
},
|
||||
Timeline: {
|
||||
/** @default "dd.MM.yyyy" */
|
||||
dateFormat: "dd.MM.yyyy",
|
||||
/** @default "dd.MM" */
|
||||
dayFormat: "dd.MM",
|
||||
/** @default "MMM yy" */
|
||||
monthFormat: "MMM yy",
|
||||
/** @default "yyyy" */
|
||||
yearFormat: "yyyy",
|
||||
Row: {
|
||||
/** @default "Ingen perioder" */
|
||||
noPeriods: "Ingen perioder",
|
||||
/** @default "{start} til {end}" */
|
||||
period: "{start} til {end}",
|
||||
},
|
||||
Period: {
|
||||
/** @default "Suksess" */
|
||||
success: "Suksess",
|
||||
/** @default "Advarsel" */
|
||||
warning: "Advarsel",
|
||||
/** @default "Fare" */
|
||||
danger: "Fare",
|
||||
/** @default "Info" */
|
||||
info: "Info",
|
||||
/** @default "Nøytral" */
|
||||
neutral: "Nøytral",
|
||||
/** @default "{status} fra {start} til {end}" */
|
||||
period: "{status} fra {start} til {end}",
|
||||
},
|
||||
Pin: {
|
||||
/** @default "Pin: {date}" */
|
||||
pin: "Pin: {date}",
|
||||
},
|
||||
Zoom: {
|
||||
/** @default "Zoom tidslinjen {start} til {end}" */
|
||||
zoom: "Zoom tidslinjen {start} til {end}",
|
||||
/** @default "Tilbakestill tidsperspektiv" */
|
||||
reset: "Tilbakestill tidsperspektiv",
|
||||
},
|
||||
},
|
||||
Tooltip: {
|
||||
/** @default "eller" */
|
||||
shortcutSeparator: "eller",
|
||||
},
|
||||
} satisfies TranslationMap;
|
||||
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user