Compare commits

..
Author SHA1 Message Date
github-actions[bot] f7c9bde6d2 Version Packages (#4852)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-04 14:06:08 +02:00
Halvor Haugan 66d7a204cd ⬆️ Upgrade some dependencies (#4845) 2026-05-04 12:48:20 +02:00
Ken 6d56ec8f51 [ActionMenu] Now biases towards top/bottom placement on all screens (#4843)
* 🐛 Bias menus using floating util to stay locked to top/bottom side

* 📝 Changeset

* 📝 Changeset update

* ♻️ Improved collisionpadding syntax

* 📝 Updated changeset
2026-05-04 12:40:15 +02:00
dependabot[bot] 2ecf0ee03c Bump @dnd-kit/react from 0.3.2 to 0.4.0 (#4848)
Bumps [@dnd-kit/react](https://github.com/clauderic/dnd-kit) from 0.3.2 to 0.4.0.
- [Release notes](https://github.com/clauderic/dnd-kit/releases)
- [Commits](https://github.com/clauderic/dnd-kit/compare/@dnd-kit/react@0.3.2...@dnd-kit/react@0.4.0)

---
updated-dependencies:
- dependency-name: "@dnd-kit/react"
  dependency-version: 0.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-04 12:19:54 +02:00
Halvor Haugan ee8a3be487 DataTable: Story: KitchenSinkAdv: label as label on column switches 2026-05-04 09:41:59 +02:00
Halvor Haugan 4a4100d0b2 Exclude some more files from css package (#4844) 2026-05-04 08:45:12 +02:00
Halvor Haugan 1da8a06783 DataTable: useTableColumnResize: Rearrange props 2026-04-30 15:18:05 +02:00
Halvor HauganandCopilot 88f96906c7 DataTable: label prop in DataTableColumnHeader
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 14:52:42 +02:00
Ken a8ae8d2c54 [DataTable] Fixed bugged story 2026-04-30 14:18:54 +02:00
Ken 045dbda89a [DataTable] Added scroll shadows with scroll-timeline 2026-04-30 13:10:59 +02:00
Ken 193b23151e [DataTable] Add resize-observer to sticky shadow 2026-04-30 13:10:58 +02:00
Halvor Haugan b54380304d DataTable: Test data: Remove redundant date formattting 2026-04-30 12:44:42 +02:00
Ken 5706c959b2 [DataTable] Update kitchen-sink demo 2026-04-30 11:50:13 +02:00
Ken 9442ac558b [DataTable] Dynamically show/hide shadow for sticky columns 2026-04-30 11:06:57 +02:00
Ken e8080c269b [DataTable] Added more auto-sizing to kitchen-sink demo 2026-04-30 10:01:43 +02:00
Ken c35a9cd6a2 [DataTable] Added shadow to sticky columns 2026-04-30 10:00:10 +02:00
Halvor Haugan 0d0beae02d DataTable: fix issue with autoResize 2026-04-30 09:40:32 +02:00
Ken 2eacd71805 [DataTable] Added demo-stories for presentation, stickyheader now default 2026-04-30 09:35:49 +02:00
Halvor Haugan be22673419 DataTable: CSS fixes 2026-04-29 19:23:51 +02:00
Ken b2f7dd4e52 [DataTable] Temp optimization for auto-version 2026-04-29 15:13:40 +02:00
Ken 3284cee8a9 [DataTable] Memorize columnoptions 2026-04-29 15:07:46 +02:00
Ken 34fbdc9cd3 [DataTable] Fixed selection state for sticky columns 2026-04-29 14:46:30 +02:00
Ken 2f1aa0988c [DataTable] Details panel now has border 2026-04-29 14:43:50 +02:00
Ken 7bfacc698d [DataTable] Updated advanced kitchen-sink demo 2026-04-29 14:41:11 +02:00
Ken d02df8313c [DataTable] Create updated test-data for kitchen sink setup 2026-04-29 12:29:44 +02:00
Ken 5c94d6ec7f [DataTable] Fix type import/export 2026-04-29 11:20:22 +02:00
KenandHalvor Haugan 3c9d24ba87 [Website] 🎉 Added API-route for fetching component props based on route (#4833)
* 🎉 Added API-route for fetching component props based on route

* 📝 Added docs

* ⚡ Update caching strategy

* ⚡ Update caching strategy

* 🎉 Added example, params and return fields to props

* 🔒 Better slug parsing

* Update aksel.nav.no/website/app/api/component-props/route.ts

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>

* ⚡ Remove redundant string check

* 🔒 Guard against long paths

* ⚡ Validate segments before parsing

* ⚡ better regex for exaxt path segments

---------

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2026-04-29 10:22:21 +02:00
Ken 5e426e9d64 [DataTable] Added non-working tests for cache 2026-04-28 18:06:27 +02:00
Ken 7a85f0d27f [DataTable] Refactor keyboard-nav stories to use new API 2026-04-28 17:58:25 +02:00
Ken 8f75f4426a [DataTable] Replace root component with auto version, stories not synced 2026-04-28 15:56:12 +02:00
Ken 71c16eb6d8 [DataTable] Exctact util components from auto render 2026-04-28 15:46:00 +02:00
Ken 776aa84fa8 [DataTable] Default to keyboard nav true 2026-04-28 15:31:26 +02:00
Ken f985e1e83f [DataTable] Refactor prop-names for subRow and detailsPanel props 2026-04-28 14:50:43 +02:00
Halvor Haugan 48c7794cac useControllableState: The setter can have narrower type than the state (#4842) 2026-04-28 14:45:43 +02:00
dependabot[bot] 10b1892f54 Bump @vitejs/plugin-react from 5.1.4 to 6.0.1 (#4757)
Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 5.1.4 to 6.0.1.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.1/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-28 14:16:47 +02:00
Halvor HauganandCopilot 193200ec9c DataTable: resize adjustments
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 11:56:13 +02:00
Ken 01fc444219 [DataTable] Updated play-tests for auto 2026-04-28 11:41:07 +02:00
Ken cb5b73c1d5 📝 Re-order hooks 2026-04-28 11:23:51 +02:00
Ken 077eae7365 [DataTable]🏷️ Rename expansioncontext -> detailspanelcontext 2026-04-28 11:23:51 +02:00
Ken aa3f58c00d [DataTable] Simplify prop-drilling into hooks and providers 2026-04-28 11:23:51 +02:00
Ken 4368a14896 [DataTable] 🏷️ Grouped related props 2026-04-28 11:23:51 +02:00
dependabot[bot] 45b0939c52 Bump dompurify from 3.3.2 to 3.4.0 (#4829)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.3.2 to 3.4.0.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.3.2...3.4.0)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-28 11:20:13 +02:00
dependabot[bot] d2fb1d8bc1 Bump follow-redirects from 1.15.11 to 1.16.0 (#4830)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-version: 1.16.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-28 11:19:08 +02:00
dependabot[bot]andKen 363ee8e483 Bump @vitest/eslint-plugin from 1.6.14 to 1.6.16 in the vitest group (#4835)
Bumps the vitest group with 1 update: [@vitest/eslint-plugin](https://github.com/vitest-dev/eslint-plugin-vitest).


Updates `@vitest/eslint-plugin` from 1.6.14 to 1.6.16
- [Release notes](https://github.com/vitest-dev/eslint-plugin-vitest/releases)
- [Commits](https://github.com/vitest-dev/eslint-plugin-vitest/compare/v1.6.14...v1.6.16)

---
updated-dependencies:
- dependency-name: "@vitest/eslint-plugin"
  dependency-version: 1.6.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: vitest
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>
2026-04-28 11:18:37 +02:00
github-actions[bot] ff454dbc06 Version Packages (#4840)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-28 11:07:06 +02:00
Halvor Haugan 92f6d9e93b Add module decleration for ds-css (#4838) 2026-04-27 18:42:22 +02:00
Halvor HauganandCopilot 516634b852 [website] Improvements in props-section (#4839)
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 18:12:34 +02:00
Halvor Haugan 48939bcee0 Merge branch 'main' of github.com:navikt/aksel 2026-04-27 18:11:27 +02:00
KenandCopilot 5ea6069010 🐛 Stop className from being fowarded to input-element in radio/checkbox (#4837)
* 🐛 Stop className from being fowarded to input-element in radio/checkbox

* Update .changeset/cuddly-queens-roll.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-27 18:10:05 +02:00
Halvor Haugan 5eee3136a0 Adjust JSDoc in ModalProps 2026-04-27 18:10:04 +02:00
Ken 21521b892e [DataTable] Default to sticky header always 2026-04-27 14:05:06 +02:00
Ken f563c18a59 [DataTable] Move selection-props to single prop 2026-04-27 13:32:26 +02:00
Ken ca1bd3f5c0 [DataTable] Added 'align'-prop to columnDef 2026-04-27 13:20:54 +02:00
Ken a71f326426 [DataTable] Removed untrue comment 2026-04-27 13:01:23 +02:00
Ken fb79fd775c [DataTable] Fix scroll when defining detailsPanel height 2026-04-27 13:01:23 +02:00
Ken 8a0afad10c [DataTable] Only render loading-label once 2026-04-27 13:01:22 +02:00
Halvor HauganandCopilot 92e6128cc9 DataTable: Add resizable prop + fix resizing issue
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 10:04:30 +02:00
Ken ff0221736c Squashed commit of the following:
commit 82656da435
Author: Ken <ken.aleksander@gmail.com>
Date:   Mon Apr 27 09:57:48 2026 +0200

    📝 Added comments

commit ab9932ed8f
Author: Ken <ken.aleksander@gmail.com>
Date:   Mon Apr 27 09:55:47 2026 +0200

    📝 Added comments

commit 180ca25861
Author: Ken <ken.aleksander@gmail.com>
Date:   Fri Apr 24 13:38:51 2026 +0200

    ⚡ Add cached parser for optimization

commit cbac535fcb
Author: Ken <ken.aleksander@gmail.com>
Date:   Fri Apr 24 13:10:18 2026 +0200

    📝 Add docs

commit 296049b8c7
Author: Ken <ken.aleksander@gmail.com>
Date:   Fri Apr 24 13:00:21 2026 +0200

    [DataTable] Optimize recursive parsing

commit 073d811b3f
Author: Ken <ken.aleksander@gmail.com>
Date:   Fri Apr 24 12:45:15 2026 +0200

    [DataTable] Now supports branch selection

commit da1374e833
Author: Ken <ken.aleksander@gmail.com>
Date:   Fri Apr 24 12:22:41 2026 +0200

    [DataTable] Updated selection
2026-04-27 09:58:45 +02:00
Ken abe4a768e1 🐛 Revert nb locale update 2026-04-24 15:06:30 +02:00
Ken c94d801b45 [DataTable] removed unused tests 2026-04-24 13:42:08 +02:00
81 changed files with 6977 additions and 5929 deletions

No files matched your search

+1 -1
View File
@@ -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 }}
+1 -1
View File
@@ -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
+4
View File
@@ -1,5 +1,9 @@
# @navikt/aksel-icons
## 8.10.4
## 8.10.3
## 8.10.2
## 8.10.1
@@ -23,7 +23,7 @@
"@types/node": "^24.10.9",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "^5.1.0",
"@vitejs/plugin-react": "^6.0.1",
"typescript": "6.0.2",
"vite": "^7.3.2",
"vite-plugin-singlefile": "^2.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-icons",
"version": "8.10.2",
"version": "8.10.4",
"author": "Aksel | Nav",
"homepage": "https://aksel.nav.no/ikoner",
"repository": {
+4
View File
@@ -1,5 +1,9 @@
# @navikt/aksel-stylelint
## 8.10.4
## 8.10.3
## 8.10.2
## 8.10.1
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-stylelint",
"version": "8.10.2",
"version": "8.10.4",
"type": "module",
"engines": {
"node": ">=18.12.0"
@@ -42,8 +42,8 @@
"dev": "yarn watch:lint"
},
"devDependencies": {
"@navikt/ds-css": "^8.10.2",
"@navikt/ds-tokens": "^8.10.2",
"@navikt/ds-css": "^8.10.4",
"@navikt/ds-tokens": "^8.10.4",
"concurrently": "9.2.1",
"postcss-selector-parser": "^7.1.0",
"postcss-value-parser": "^4.2.0",
+16
View File
@@ -1,5 +1,21 @@
# @navikt/aksel
## 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
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel",
"version": "8.10.2",
"version": "8.10.4",
"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.4",
"@navikt/ds-tokens": "^8.10.4",
"chalk": "5.6.2",
"cli-progress": "3.12.0",
"clipboardy": "5.0.1",
+8
View File
@@ -1,5 +1,13 @@
# @navikt/ds-css
## 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
+13 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-css",
"version": "8.10.2",
"version": "8.10.4",
"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,7 +32,7 @@
"css:get-version": "node config/get-version.js"
},
"devDependencies": {
"@navikt/ds-tokens": "^8.10.2",
"@navikt/ds-tokens": "^8.10.4",
"browserslist": "^4.25.0",
"esbuild": "^0.27.4",
"fast-glob": "3.3.3",
@@ -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/*"
}
}
}
+72 -4
View File
@@ -11,6 +11,7 @@
overflow: auto;
width: 100%;
position: relative;
scroll-timeline: --horizontal-table-scroll inline;
}
.aksel-data-table {
@@ -104,15 +105,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 +149,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);
@@ -195,6 +219,24 @@
&[data-sticky="start"] {
left: 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 -24px 0 0);
opacity: 0;
}
.aksel-data-table[data-scroll] &::before {
opacity: 1;
animation: scroll-shadow-fade-start 1s linear;
animation-timeline: --horizontal-table-scroll;
}
&::after {
inset-inline: 0 -1px;
border-right-width: 1px;
@@ -203,10 +245,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);
}
@@ -234,11 +298,11 @@
&:not([data-selectable="true"]) > div {
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;
}
}
}
@@ -444,9 +508,9 @@
}
.aksel-data-table__filler-cell {
width: auto;
display: table-cell;
padding: 0;
width: auto;
}
.aksel-data-drag-and-drop__drag-handler__button {
@@ -593,3 +657,7 @@ 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);
}
+1
View File
@@ -0,0 +1 @@
declare module "@navikt/ds-css" {}
+20
View File
@@ -1,5 +1,25 @@
# @navikt/ds-react
## 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
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-react",
"version": "8.10.2",
"version": "8.10.4",
"description": "React components from the Norwegian Labour and Welfare Administration.",
"author": "Aksel, a team part of the Norwegian Labour and Welfare Administration.",
"license": "MIT",
@@ -706,8 +706,8 @@
"dependencies": {
"@floating-ui/react": "0.27.8",
"@floating-ui/react-dom": "^2.1.8",
"@navikt/aksel-icons": "^8.10.2",
"@navikt/ds-tokens": "^8.10.2",
"@navikt/aksel-icons": "^8.10.4",
"@navikt/ds-tokens": "^8.10.4",
"date-fns": "^4.0.0",
"react-day-picker": "9.14.0"
},
@@ -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,
@@ -9,7 +9,6 @@ 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 +28,9 @@ type Story = StoryObj<typeof DataTable>;
const selectionControls = {
args: {
selectionMode: "multiple",
selection: {
selectionMode: "multiple",
},
},
parameters: {
controls: {
@@ -37,9 +38,11 @@ const selectionControls = {
},
},
argTypes: {
selectionMode: {
control: { type: "select" },
options: ["none", "single", "multiple"],
selection: {
selectionMode: {
control: { type: "select" },
options: ["none", "single", "multiple"],
},
},
},
} as const;
@@ -58,7 +61,7 @@ const userColumnDef: ColumnDefinitions<UserDataTest> = [
header: "Id",
label: "Id",
cell: ({ id }) => id,
type: "number",
align: "right",
defaultWidth: "100px",
},
{
@@ -110,18 +113,20 @@ const userData = generateUserData(4);
export const ItemsAsData: Story = {
render: () => {
return <DataTableAuto columnDefinitions={userColumnDef} data={userData} />;
return <DataTable columnDefinitions={userColumnDef} data={userData} />;
},
};
export const ItemsAsDataWithCustomRowId: Story = {
render: () => {
return (
<DataTableAuto
<DataTable
columnDefinitions={userColumnDef}
data={userData}
selectionMode="multiple"
onSelectionChange={console.info}
selection={{
selectionMode: "multiple",
onSelectionChange: console.info,
}}
getRowId={(row) => row.foo + row.bar}
/>
);
@@ -131,11 +136,13 @@ export const ItemsAsDataWithCustomRowId: Story = {
export const SelectionModeMultiple: Story = {
render: () => {
return (
<DataTableAuto
<DataTable
columnDefinitions={userColumnDef}
data={userData}
selectionMode="multiple"
onSelectionChange={console.info}
selection={{
selectionMode: "multiple",
onSelectionChange: console.info,
}}
getRowId={(row) => row.foo + row.bar}
withKeyboardNav
/>
@@ -146,11 +153,13 @@ export const SelectionModeMultiple: Story = {
export const SelectionModeSingle: Story = {
render: () => {
return (
<DataTableAuto
<DataTable
columnDefinitions={userColumnDef}
data={userData}
selectionMode="single"
onSelectionChange={console.info}
selection={{
selectionMode: "multiple",
onSelectionChange: console.info,
}}
getRowId={(row) => row.foo + row.bar}
withKeyboardNav
/>
@@ -161,11 +170,13 @@ export const SelectionModeSingle: Story = {
export const SelectionModeSingleWithoutKeyboardNav: Story = {
render: () => {
return (
<DataTableAuto
<DataTable
columnDefinitions={userColumnDef}
data={userData}
selectionMode="single"
onSelectionChange={console.info}
selection={{
selectionMode: "multiple",
onSelectionChange: console.info,
}}
getRowId={(row) => row.foo + row.bar}
/>
);
@@ -175,14 +186,16 @@ export const SelectionModeSingleWithoutKeyboardNav: Story = {
export const SelectionWithDisabledRows: Story = {
render: () => {
return (
<DataTableAuto
<DataTable
columnDefinitions={userColumnDef}
data={userData}
selectionMode="multiple"
onSelectionChange={console.info}
selection={{
selectionMode: "multiple",
onSelectionChange: console.info,
disabledSelectionKeys: [1, 2],
}}
getRowId={(row) => row.id}
withKeyboardNav
disabledSelectionKeys={[1, 2]}
/>
);
},
@@ -202,12 +215,14 @@ export const ControlledSelection: Story = {
Clear selection
</Button>
</div>
<DataTableAuto
<DataTable
columnDefinitions={userColumnDef}
data={userData}
selectionMode="multiple"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
selection={{
selectionMode: "multiple",
onSelectionChange: setSelectedKeys,
selectedKeys,
}}
getRowId={(row) => row.id}
/>
</div>
@@ -218,12 +233,14 @@ export const ControlledSelection: Story = {
export const DefaultSelectedKeys: Story = {
render: () => {
return (
<DataTableAuto
<DataTable
columnDefinitions={userColumnDef}
data={userData}
selectionMode="multiple"
defaultSelectedKeys={[1, 3]}
onSelectionChange={console.info}
selection={{
selectionMode: "multiple",
onSelectionChange: console.info,
defaultSelectedKeys: [1, 3],
}}
getRowId={(row) => row.id}
/>
);
@@ -233,13 +250,15 @@ export const DefaultSelectedKeys: Story = {
export const SingleSelectionWithDisabledRows: Story = {
render: () => {
return (
<DataTableAuto
<DataTable
columnDefinitions={userColumnDef}
data={userData}
selectionMode="single"
onSelectionChange={console.info}
selection={{
selectionMode: "multiple",
onSelectionChange: console.info,
disabledSelectionKeys: [2],
}}
getRowId={(row) => row.id}
disabledSelectionKeys={[2]}
/>
);
},
@@ -248,10 +267,12 @@ export const SingleSelectionWithDisabledRows: Story = {
export const EmptyData: Story = {
render: () => {
return (
<DataTableAuto
<DataTable
columnDefinitions={userColumnDef}
data={[]}
selectionMode="multiple"
selection={{
selectionMode: "multiple",
}}
/>
);
},
@@ -260,7 +281,7 @@ export const EmptyData: Story = {
export const EmptyDataWithEmptyState: Story = {
render: () => {
return (
<DataTableAuto
<DataTable
columnDefinitions={userColumnDef}
data={[]}
emptyState="Ingen data å vise"
@@ -277,11 +298,10 @@ export const LoadingWithSkeletonRows: Story = {
<Button onClick={() => setIsLoading((prev) => !prev)}>
Toggle loading
</Button>
<DataTableAuto
<DataTable
columnDefinitions={userColumnDef}
data={[]}
isLoading={isLoading}
loadingRows={4}
loading={{ isLoading, loadingRows: 4 }}
/>
</VStack>
);
@@ -296,11 +316,10 @@ export const LoadingWithLoadingState: Story = {
<Button onClick={() => setIsLoading((prev) => !prev)}>
Toggle loading
</Button>
<DataTableAuto
<DataTable
columnDefinitions={userColumnDef}
data={[]}
isLoading={isLoading}
loadingState="Laster data..."
loading={{ isLoading, loadingState: "Laster data..." }}
/>
</VStack>
);
@@ -315,11 +334,10 @@ export const LoadingWhileKeepingData: Story = {
<Button onClick={() => setIsLoading((prev) => !prev)}>
Toggle loading
</Button>
<DataTableAuto
<DataTable
columnDefinitions={userColumnDef}
data={userData}
isLoading={isLoading}
loadingRows={4}
loading={{ isLoading, loadingRows: 4 }}
/>
</VStack>
);
@@ -334,14 +352,20 @@ export const LoadingWhileKeepingDataNoPlaceholders: Story = {
<Button onClick={() => setIsLoading((prev) => !prev)}>
Toggle loading
</Button>
<DataTableAuto
<DataTable
columnDefinitions={userColumnDef}
data={userData}
isLoading={isLoading}
loading={{ isLoading, loadingLabel: "Laster innhold for tabell" }}
/>
</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,13 +381,15 @@ export const SelectionPagination: Story = {
<div>
<button onClick={() => setPage("0")}>Page 1</button>
<button onClick={() => setPage("1")}>Page 2</button>
<DataTableAuto
<DataTable
columnDefinitions={userColumnDef}
data={dataToShow}
selectionMode="multiple"
onSelectionChange={(keys) => {
console.info({ keys });
selectionPaginationSpy(keys);
selection={{
selectionMode: "multiple",
onSelectionChange: (keys) => {
console.info({ keys });
selectionPaginationSpy(keys);
},
}}
getRowId={(row) => row.foo + row.bar}
/>
@@ -446,14 +472,16 @@ export const SelectionPagination: Story = {
export const StickySelection: Story = {
render: () => {
return (
<DataTableAuto
<DataTable
columnDefinitions={userColumnDef.map((col) => ({
...col,
defaultWidth: "350px",
}))}
data={userData}
selectionMode="multiple"
onSelectionChange={console.info}
selection={{
selectionMode: "multiple",
onSelectionChange: console.info,
}}
getRowId={(row) => row.foo + row.bar}
withKeyboardNav
stickyColumns={{
@@ -467,7 +495,7 @@ export const StickySelection: Story = {
export const StickyLeftOne: Story = {
render: () => {
return (
<DataTableAuto
<DataTable
columnDefinitions={userColumnDef.map((col) => ({
...col,
defaultWidth: "350px",
@@ -486,7 +514,7 @@ export const StickyLeftOne: Story = {
export const StickyRightOne: Story = {
render: () => {
return (
<DataTableAuto
<DataTable
columnDefinitions={userColumnDef.map((col) => ({
...col,
defaultWidth: "350px",
@@ -505,7 +533,7 @@ export const StickyRightOne: Story = {
export const StickyBothOne: Story = {
render: () => {
return (
<DataTableAuto
<DataTable
columnDefinitions={userColumnDef.map((col) => ({
...col,
defaultWidth: "250px",
@@ -526,7 +554,7 @@ export const StickyHeader: Story = {
render: () => {
return (
<div style={{ height: "300px" }}>
<DataTableAuto
<DataTable
columnDefinitions={userColumnDef}
data={generateUserData(20)}
getRowId={(row) => row.foo + row.bar}
@@ -542,7 +570,7 @@ export const StickyHeaderAndColumns: Story = {
render: () => {
return (
<div style={{ height: "300px" }}>
<DataTableAuto
<DataTable
columnDefinitions={userColumnDef.map((col) => ({
...col,
defaultWidth: "250px",
@@ -603,7 +631,7 @@ const sortableColumnDef: ColumnDefinitions<SortableUserDataTest> = [
id: "id",
header: "Id",
cell: ({ id }) => id,
type: "number",
align: "right",
sortable: true,
label: "Id",
},
@@ -646,7 +674,7 @@ export const SortableColumns: Story = {
const sortedData = applySortEntries(sortableUserData, sort);
return (
<DataTableAuto
<DataTable
columnDefinitions={sortableColumnDef}
data={sortedData}
getRowId={(row) => row.id}
@@ -669,7 +697,7 @@ export const SortableColumnsUncontrolled: Story = {
{loggedDetail && (
<pre style={{ fontSize: "0.75rem" }}>{loggedDetail}</pre>
)}
<DataTableAuto
<DataTable
columnDefinitions={sortableColumnDef}
data={sortableUserData}
getRowId={(row) => row.id}
@@ -686,7 +714,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,12 +742,14 @@ const rowClickColumnDef: ColumnDefinitions<UserDataTest> = [
export const RowClick: Story = {
render: () => (
<DataTableAuto
<DataTable
columnDefinitions={rowClickColumnDef}
data={userData}
getRowId={(row) => row.id}
onRowClick={() => console.info("Row clicked!")}
selectionMode="multiple"
selection={{
selectionMode: "multiple",
}}
withKeyboardNav
/>
),
@@ -727,12 +757,14 @@ export const RowClick: Story = {
export const RowClickTest: Story = {
render: () => (
<DataTableAuto
<DataTable
columnDefinitions={rowClickColumnDef}
data={userData}
getRowId={(row) => row.id}
onRowClick={rowClickSpy}
selectionMode="multiple"
selection={{
selectionMode: "multiple",
}}
/>
),
play: async ({ canvasElement }) => {
@@ -764,15 +796,19 @@ export const RowClickTest: Story = {
export const RowExpansion: Story = {
render: (args) => (
<DataTableAuto
<DataTable
columnDefinitions={rowClickColumnDef}
data={userData}
getRowId={(row) => row.id}
onRowClick={() => console.info("Row clicked!")}
selectionMode={args.selectionMode}
selection={{
selectionMode: args.selection?.selectionMode,
}}
withKeyboardNav
getDetailsPanelContent={(rowData) => {
return <div>{`Details for ${rowData.foo} (id: ${rowData.id})`}</div>;
detailsPanel={{
getContent: (rowData) => {
return <div>{`Details for ${rowData.foo} (id: ${rowData.id})`}</div>;
},
}}
/>
),
@@ -781,17 +817,21 @@ export const RowExpansion: Story = {
export const RowExpansionAll: Story = {
render: (args) => (
<DataTableAuto
<DataTable
columnDefinitions={rowClickColumnDef}
data={userData}
getRowId={(row) => row.id}
onRowClick={() => console.info("Row clicked!")}
selectionMode={args.selectionMode}
selection={{
selectionMode: args.selection?.selectionMode,
}}
withKeyboardNav
getDetailsPanelContent={(rowData) => {
return <div>{`Details for ${rowData.foo} (id: ${rowData.id})`}</div>;
detailsPanel={{
getContent: (rowData) => {
return <div>{`Details for ${rowData.foo} (id: ${rowData.id})`}</div>;
},
showExpandAll: true,
}}
showExpandAll
/>
),
...selectionControls,
@@ -810,14 +850,18 @@ const nestedRowData = userData.map((user) => ({
export const NestedRows: Story = {
render: (args) => (
<DataTableAuto
<DataTable
columnDefinitions={rowClickColumnDef}
data={nestedRowData}
getRowId={(row) => row.id}
selectionMode={args.selectionMode}
selection={{
selectionMode: args.selection?.selectionMode,
onSelectionChange: console.info,
}}
withKeyboardNav
getSubRows={(row) => row.children}
onSelectionChange={console.info}
subRows={{
getRows: (row) => row.children,
}}
/>
),
play: async ({ canvasElement }) => {
@@ -861,14 +905,18 @@ export const NestedRows: Story = {
export const NestedLeftAlignedContentRows: Story = {
render: (args) => (
<DataTableAuto
<DataTable
/* Removes right aligned id column */
columnDefinitions={rowClickColumnDef.slice(1)}
data={nestedRowData}
getRowId={(row) => row.id}
selectionMode={args.selectionMode}
selection={{
selectionMode: args.selection?.selectionMode,
}}
withKeyboardNav
getSubRows={(row) => row.children}
subRows={{
getRows: (row) => row.children,
}}
/>
),
...selectionControls,
@@ -876,7 +924,7 @@ export const NestedLeftAlignedContentRows: Story = {
export const NestedOneLevelLeftAlignedContentRows: Story = {
render: (args) => (
<DataTableAuto
<DataTable
/* Removes right aligned id column */
columnDefinitions={rowClickColumnDef.slice(1)}
data={userData.map((user) => ({
@@ -887,9 +935,13 @@ export const NestedOneLevelLeftAlignedContentRows: Story = {
})),
}))}
getRowId={(row) => row.id}
selectionMode={args.selectionMode}
selection={{
selectionMode: args.selection?.selectionMode,
}}
withKeyboardNav
getSubRows={(row) => row.children}
subRows={{
getRows: (row) => row.children,
}}
/>
),
...selectionControls,
@@ -897,7 +949,7 @@ export const NestedOneLevelLeftAlignedContentRows: Story = {
export const NestedRowsWithMasterDetail: Story = {
render: (args) => (
<DataTableAuto
<DataTable
/* Removes right aligned id column */
columnDefinitions={rowClickColumnDef.slice(1)}
data={userData.map((user) => ({
@@ -908,11 +960,23 @@ export const NestedRowsWithMasterDetail: Story = {
})),
}))}
getRowId={(row) => row.id}
selectionMode={args.selectionMode}
selection={{
selectionMode: args.selection?.selectionMode,
}}
withKeyboardNav
getSubRows={(row) => row.children}
getDetailsPanelContent={() => <div>Placeholder content</div>}
getDetailsPanelHeight={() => 100}
subRows={{
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,
}}
/>
),
/* play: async ({ canvasElement }) => {
@@ -0,0 +1,490 @@
import type { Meta } from "@storybook/react-vite";
import React, { useState } from "react";
import { ChevronDownIcon } from "@navikt/aksel-icons";
import { Button } from "../../button";
import { Checkbox } from "../../form/checkbox";
import { HStack, Spacer } from "../../primitives/stack";
import { Table } from "../../table";
import { Tag } from "../../tag";
import { DataTable } from "../table";
import type { ColumnDefinitions } from "../table/root/DataTable.types";
const meta: Meta<typeof DataTable> = {
title: "ds-react/Data/Compare",
component: DataTable,
parameters: {
chromatic: { disable: true },
layout: "padded",
a11y: { disable: true },
controls: { disable: true },
docs: { disable: true },
},
};
export default meta;
const generateUserData = (
count: number,
addNested: boolean = true,
): UserDataTest[] => {
const num = (index: number) => index + 1;
return Array.from({ length: count }, (_, i) => ({
id: num(i) + (addNested ? 0 : 100),
foo: `foo${num(i)}`,
bar: `bar${num(i)}`,
baz: num(i) % 2 === 0,
time: new Date(Date.now() - Math.floor(Math.random() * 10000000000)),
nestedRows: addNested ? generateUserData(Math.floor(count / 2), false) : [],
}));
};
const data = generateUserData(10);
type UserDataTest = {
id: number;
foo: string;
bar: string;
baz: boolean;
time: Date;
nestedRows: UserDataTest[];
};
const columns: ColumnDefinitions<UserDataTest> = [
{
id: "id",
label: "Id",
cell: ({ id }) => id,
align: "right",
},
{
id: "foo",
label: "Foo",
cell: ({ foo }) => foo,
isRowHeader: true,
},
{
id: "bar",
label: "Bar",
cell: ({ bar }) => (
<Tag variant="strong" size="xsmall">
{bar}
</Tag>
),
},
{
id: "baz",
label: "Baz",
cell: ({ baz }) => (baz ? "Yes" : "No"),
},
{
id: "time",
label: "Time",
align: "right",
cell: ({ time }) => time.toLocaleTimeString("no"),
},
];
export function DataTableOne() {
return <DataTable columnDefinitions={columns} data={data} layout="auto" />;
}
export function OriginalOne() {
return (
<Table>
<Table.Header>
<Table.Row>
{columns.map((column) => (
<Table.ColumnHeader
scope="col"
key={column.id}
align={column.align}
>
{column.label}
</Table.ColumnHeader>
))}
</Table.Row>
</Table.Header>
<Table.Body>
{data.map((row) => {
return (
<Table.Row key={row.id}>
{columns.map((column) =>
column.isRowHeader ? (
<Table.HeaderCell
key={column.id}
scope="row"
align={column.align}
>
{column.cell(row)}
</Table.HeaderCell>
) : (
<Table.DataCell key={column.id} align={column.align}>
{column.cell(row)}
</Table.DataCell>
),
)}
</Table.Row>
);
})}
</Table.Body>
</Table>
);
}
export function DataTableTwo() {
return (
<DataTable
columnDefinitions={columns}
data={data}
layout="auto"
getRowId={(row) => row.id}
selection={{
selectionMode: "multiple",
onSelectionChange: console.info,
}}
/>
);
}
export function OriginalTwo() {
const [selectedRows, setSelectedRows] = useState<number[]>([]);
const toggleSelectedRow = (value) =>
setSelectedRows((list) =>
list.includes(value)
? list.filter((id) => id !== value)
: [...list, value],
);
return (
<Table>
<Table.Header>
<Table.Row>
<Table.DataCell>
<Checkbox
checked={selectedRows.length === data.length}
indeterminate={
selectedRows.length > 0 && selectedRows.length !== data.length
}
onChange={() => {
selectedRows.length
? setSelectedRows([])
: setSelectedRows(data.map(({ id }) => id));
}}
hideLabel
>
Velg alle rader
</Checkbox>
</Table.DataCell>
{columns.map((column) => (
<Table.ColumnHeader
scope="col"
key={column.id}
align={column.align}
>
{column.label}
</Table.ColumnHeader>
))}
</Table.Row>
</Table.Header>
<Table.Body>
{data.map((row) => {
return (
<Table.Row key={row.id} selected={selectedRows.includes(row.id)}>
<Table.DataCell>
<Checkbox
hideLabel
checked={selectedRows.includes(row.id)}
onChange={() => toggleSelectedRow(row.id)}
aria-labelledby={`id-${row.id}`}
>
{" "}
</Checkbox>
</Table.DataCell>
{columns.map((column) =>
column.isRowHeader ? (
<Table.HeaderCell
key={column.id}
scope="row"
align={column.align}
>
{column.cell(row)}
</Table.HeaderCell>
) : (
<Table.DataCell key={column.id} align={column.align}>
{column.cell(row)}
</Table.DataCell>
),
)}
</Table.Row>
);
})}
</Table.Body>
</Table>
);
}
function DetailsPanel() {
return <div>Placeholder</div>;
}
export function DataTableThree() {
return (
<DataTable
columnDefinitions={columns}
data={data}
layout="auto"
getRowId={(row) => row.id}
selection={{
selectionMode: "multiple",
onSelectionChange: console.info,
}}
detailsPanel={{
getContent: DetailsPanel,
}}
/>
);
}
export function OriginalThree() {
const [selectedRows, setSelectedRows] = useState<number[]>([]);
const toggleSelectedRow = (value) =>
setSelectedRows((list) =>
list.includes(value)
? list.filter((id) => id !== value)
: [...list, value],
);
return (
<Table>
<Table.Header>
<Table.Row>
<Table.DataCell aria-hidden />
<Table.DataCell>
<Checkbox
checked={selectedRows.length === data.length}
indeterminate={
selectedRows.length > 0 && selectedRows.length !== data.length
}
onChange={() => {
selectedRows.length
? setSelectedRows([])
: setSelectedRows(data.map(({ id }) => id));
}}
hideLabel
>
Velg alle rader
</Checkbox>
</Table.DataCell>
{columns.map((column) => (
<Table.ColumnHeader
scope="col"
key={column.id}
align={column.align}
>
{column.label}
</Table.ColumnHeader>
))}
</Table.Row>
</Table.Header>
<Table.Body>
{data.map((row) => {
return (
<Table.ExpandableRow
key={row.id}
selected={selectedRows.includes(row.id)}
content={<DetailsPanel />}
>
<Table.DataCell>
<Checkbox
hideLabel
checked={selectedRows.includes(row.id)}
onChange={() => toggleSelectedRow(row.id)}
aria-labelledby={`id-${row.id}`}
>
{" "}
</Checkbox>
</Table.DataCell>
{columns.map((column) =>
column.isRowHeader ? (
<Table.HeaderCell
key={column.id}
scope="row"
align={column.align}
>
{column.cell(row)}
</Table.HeaderCell>
) : (
<Table.DataCell key={column.id} align={column.align}>
{column.cell(row)}
</Table.DataCell>
),
)}
</Table.ExpandableRow>
);
})}
</Table.Body>
</Table>
);
}
export function DataTableFour() {
return (
<DataTable
columnDefinitions={columns}
data={data}
layout="auto"
getRowId={(row) => row.id}
selection={{
selectionMode: "multiple",
onSelectionChange: console.info,
}}
detailsPanel={{
getContent: DetailsPanel,
}}
subRows={{
getRows: (row) => row.nestedRows,
}}
/>
);
}
export function OriginalFour() {
const [selectedRows, setSelectedRows] = useState<number[]>([]);
const [expandedRows, setExpandedRows] = useState<number[]>([]);
const toggleSelectedRow = (value) =>
setSelectedRows((list) =>
list.includes(value)
? list.filter((id) => id !== value)
: [...list, value],
);
const RowComponent = ({
row,
nested = false,
}: {
row: UserDataTest;
nested: boolean;
}) => {
const WrapperComponent = ({
children,
selected,
}: {
children: React.ReactNode;
selected: boolean;
}) => {
if (nested) {
return (
<Table.Row selected={selected}>
<Table.DataCell aria-hidden />
{children}
</Table.Row>
);
}
return (
<Table.ExpandableRow content={<DetailsPanel />} selected={selected}>
{children}
</Table.ExpandableRow>
);
};
return (
<WrapperComponent key={row.id} selected={selectedRows.includes(row.id)}>
<Table.DataCell>
<Checkbox
hideLabel
checked={selectedRows.includes(row.id)}
onChange={() => toggleSelectedRow(row.id)}
aria-labelledby={`id-${row.id}`}
>
{" "}
</Checkbox>
</Table.DataCell>
{columns.map((column, index) =>
column.isRowHeader ? (
<Table.HeaderCell key={column.id} scope="row" align={column.align}>
{column.cell(row)}
</Table.HeaderCell>
) : (
<Table.DataCell key={column.id} align={column.align}>
<HStack align="center" gap="space-4" justify="space-between">
{index === 0 && row.nestedRows.length > 0 && (
<Button
icon={<ChevronDownIcon />}
size="small"
variant="tertiary"
data-color="neutral"
onClick={() => {
setExpandedRows((list) =>
list.includes(row.id)
? list.filter((id) => id !== row.id)
: [...list, row.id],
);
}}
/>
)}
<Spacer />
<span>{column.cell(row)}</span>
</HStack>
</Table.DataCell>
),
)}
</WrapperComponent>
);
};
return (
<Table>
<Table.Header>
<Table.Row>
<Table.DataCell aria-hidden />
<Table.DataCell>
<Checkbox
checked={selectedRows.length === data.length}
indeterminate={
selectedRows.length > 0 && selectedRows.length !== data.length
}
onChange={() => {
selectedRows.length
? setSelectedRows([])
: setSelectedRows(data.map(({ id }) => id));
}}
hideLabel
>
Velg alle rader
</Checkbox>
</Table.DataCell>
{columns.map((column) => (
<Table.ColumnHeader
scope="col"
key={column.id}
align={column.align}
>
{column.label}
</Table.ColumnHeader>
))}
</Table.Row>
</Table.Header>
<Table.Body>
{data.map((row) => {
if (row.nestedRows.length > 0) {
return (
<React.Fragment key={row.id}>
<RowComponent row={row} nested={false} />
{expandedRows.includes(row.id) &&
row.nestedRows.map((nestedRow) => (
<RowComponent key={nestedRow.id} row={nestedRow} nested />
))}
</React.Fragment>
);
}
return <RowComponent row={row} nested={false} key={row.id} />;
})}
</Table.Body>
</Table>
);
}
@@ -1,19 +1,8 @@
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 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 +11,82 @@ 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>
<DataTable
style={{ width: "100%" }}
withKeyboardNav
layout="auto"
columnDefinitions={inputsColumnDef}
data={inputsData}
getRowId={(row) => row.id}
/>
),
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>
<DataTable
withKeyboardNav
layout="auto"
columnDefinitions={disabledColumnDef}
data={disabledData}
getRowId={(row) => row.id}
/>
),
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,111 @@ 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>
<DataTable
withKeyboardNav
layout="auto"
columnDefinitions={cacheColumnDef}
data={data}
getRowId={(row) => row.id}
/>
</div>
);
},
/* TODO: Does not work now, cache does not update correctly */
/* play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const { down, 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 1");
await down();
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: number };
<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: () => (
<DataTable
withKeyboardNav
layout="auto"
columnDefinitions={focusColumnDef}
data={focusData}
getRowId={(row) => row.id}
/>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -515,29 +401,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,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";
import { sampleData } from "./dummy-data";
const meta: Meta<typeof DataTable> = {
@@ -71,9 +71,15 @@ export const AutoLayoutMinimal: Story = {
<DataTable layout="auto">
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
<DataTableColumnHeader label="Column 1">
Column 1
</DataTableColumnHeader>
<DataTableColumnHeader label="Column 2">
Column 2
</DataTableColumnHeader>
<DataTableColumnHeader label="Column 3">
Column 3
</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
@@ -101,7 +107,10 @@ export const AutoLayoutOverflowX: Story = {
<DataTable.Tr>
{columns.map((column) => {
return (
<DataTableColumnHeader key={column.header}>
<DataTableColumnHeader
key={column.header}
label={column.header}
>
{column.header}
</DataTableColumnHeader>
);
@@ -134,7 +143,7 @@ export const AutoLayoutNoCellTruncation: Story = {
<DataTable.Thead>
<DataTable.Tr>
{columns.map((column) => (
<DataTableColumnHeader key={column.header}>
<DataTableColumnHeader key={column.header} label={column.header}>
{column.header}
</DataTableColumnHeader>
))}
@@ -164,9 +173,15 @@ export const AutoLayoutSortable: Story = {
<DataTable layout="auto">
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader sortable>Left</DataTableColumnHeader>
<DataTableColumnHeader sortable>Center</DataTableColumnHeader>
<DataTableColumnHeader sortable>Right</DataTableColumnHeader>
<DataTableColumnHeader label="Left" sortable>
Left
</DataTableColumnHeader>
<DataTableColumnHeader label="Center" sortable>
Center
</DataTableColumnHeader>
<DataTableColumnHeader label="Right" sortable>
Right
</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
@@ -196,9 +211,15 @@ export const FixedLayoutMinimal: Story = {
<DataTable layout="fixed">
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
<DataTableColumnHeader label="Column 1">
Column 1
</DataTableColumnHeader>
<DataTableColumnHeader label="Column 2">
Column 2
</DataTableColumnHeader>
<DataTableColumnHeader label="Column 3">
Column 3
</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
@@ -224,9 +245,15 @@ export const FixedLayoutDynamicWidth: Story = {
<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>
<DataTableColumnHeader label="Column 1" defaultWidth="100%">
Column 1
</DataTableColumnHeader>
<DataTableColumnHeader label="Column 2" defaultWidth="100%">
Column 2
</DataTableColumnHeader>
<DataTableColumnHeader label="Column 3" defaultWidth="100%">
Column 3
</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
@@ -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";
@@ -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,6 +85,9 @@ export const NoPageScroll: Story = {
return (
<DataTableColumnHeader
key={header.id}
label={(
header.column.columnDef.header || ""
).toString()}
defaultWidth={header.getSize()}
sortable
sortDirection={header.column.getIsSorted() || "none"}
@@ -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/Resize",
@@ -21,11 +21,15 @@ export const Resize: Story = {
<DataTable withKeyboardNav>
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader textAlign="left">Left</DataTableColumnHeader>
<DataTableColumnHeader textAlign="center">
<DataTableColumnHeader label="Left" textAlign="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>
@@ -55,22 +59,25 @@ export const ResizeMinMax: Story = {
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader
maxWidth="400px"
minWidth="100px"
label="Left"
maxWidth={400}
minWidth={100}
textAlign="left"
>
Left
</DataTableColumnHeader>
<DataTableColumnHeader
maxWidth="400px"
minWidth="50px"
label="Center"
maxWidth={400}
minWidth={50}
textAlign="center"
>
Center
</DataTableColumnHeader>
<DataTableColumnHeader
maxWidth="400px"
minWidth="200px"
label="Right"
maxWidth={400}
minWidth={200}
textAlign="right"
>
Right
@@ -103,13 +110,25 @@ export const ResizeDefaultStaticWidth: Story = {
<DataTable withKeyboardNav>
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader defaultWidth="300px" textAlign="left">
<DataTableColumnHeader
label="Left"
defaultWidth="300px"
textAlign="left"
>
Left
</DataTableColumnHeader>
<DataTableColumnHeader defaultWidth="300px" textAlign="center">
<DataTableColumnHeader
label="Center"
defaultWidth="300px"
textAlign="center"
>
Center
</DataTableColumnHeader>
<DataTableColumnHeader defaultWidth="300px" textAlign="right">
<DataTableColumnHeader
label="Right"
defaultWidth="300px"
textAlign="right"
>
Right
</DataTableColumnHeader>
</DataTable.Tr>
@@ -140,13 +159,25 @@ export const ResizeDefaultDynamicWidth: Story = {
<DataTable withKeyboardNav>
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader defaultWidth="100%" textAlign="left">
<DataTableColumnHeader
label="Left"
defaultWidth="100%"
textAlign="left"
>
Left
</DataTableColumnHeader>
<DataTableColumnHeader defaultWidth="100%" textAlign="center">
<DataTableColumnHeader
label="Center"
defaultWidth="100%"
textAlign="center"
>
Center
</DataTableColumnHeader>
<DataTableColumnHeader defaultWidth="100%" textAlign="right">
<DataTableColumnHeader
label="Right"
defaultWidth="100%"
textAlign="right"
>
Right
</DataTableColumnHeader>
</DataTable.Tr>
@@ -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"
defaultWidth="100%"
colSpan={2}
>
Group A
</DataTableColumnHeader>
<DataTableColumnHeader width="100%" colSpan={2}>
<DataTableColumnHeader
label="Group B"
defaultWidth="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"
defaultWidth="100%"
colSpan={2}
sortable
>
Group A
</DataTableColumnHeader>
<DataTableColumnHeader width="100%" colSpan={2} sortable>
<DataTableColumnHeader
label="Group B"
defaultWidth="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,8 +1,8 @@
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";
import { DataTable } from "../table/root/DataTableRoot.legacy";
const meta: Meta<typeof DataTable> = {
title: "ds-react/Data/States",
@@ -22,9 +22,15 @@ export const EmptyStateAuto: Story = {
<DataTable layout="auto" withKeyboardNav>
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
<DataTableColumnHeader label="Column 1">
Column 1
</DataTableColumnHeader>
<DataTableColumnHeader label="Column 2">
Column 2
</DataTableColumnHeader>
<DataTableColumnHeader label="Column 3">
Column 3
</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
@@ -42,9 +48,15 @@ export const EmptyStateFixed: Story = {
<DataTable layout="fixed" withKeyboardNav>
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
<DataTableColumnHeader label="Column 1">
Column 1
</DataTableColumnHeader>
<DataTableColumnHeader label="Column 2">
Column 2
</DataTableColumnHeader>
<DataTableColumnHeader label="Column 3">
Column 3
</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
@@ -62,9 +74,15 @@ export const LoadingStateAuto: Story = {
<DataTable layout="auto" withKeyboardNav>
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
<DataTableColumnHeader label="Column 1">
Column 1
</DataTableColumnHeader>
<DataTableColumnHeader label="Column 2">
Column 2
</DataTableColumnHeader>
<DataTableColumnHeader label="Column 3">
Column 3
</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
@@ -79,9 +97,15 @@ export const LoadingStateFixed: Story = {
<DataTable layout="fixed" withKeyboardNav>
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
<DataTableColumnHeader label="Column 1">
Column 1
</DataTableColumnHeader>
<DataTableColumnHeader label="Column 2">
Column 2
</DataTableColumnHeader>
<DataTableColumnHeader label="Column 3">
Column 3
</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
@@ -1,6 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
type RowSelectionState,
Table,
flexRender,
getCoreRowModel,
@@ -17,19 +16,29 @@ import {
} from "@navikt/aksel-icons";
import { Button } from "../../button";
import { Dialog } from "../../dialog";
import { Checkbox } from "../../form/checkbox";
import { CheckboxInput } from "../../form/checkbox/checkbox-input/CheckboxInput";
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 { BodyShort, Heading } from "../../typography";
import DataDragAndDrop from "../drag-and-drop-old/root/DataDragAndDropRoot";
import { DataTable } from "../table";
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
import type { SelectionProps } from "../table/hooks/useTableSelection";
import { DataTable } from "../table/root/DataTableRoot";
import { DataTable as DataTableLegacy } from "../table/root/DataTableRoot.legacy";
import { TokenFilter } from "../token-filter/TokenFilter";
import type { ExternalQuery } from "../token-filter/TokenFilter.types";
import { DataToolbar } from "../toolbar";
import {
TEST_DATA,
TEST_DATA_NESTED,
columnDef_TEST_DATA,
columnDef_TEST_DATA_NESTED,
} from "./Data.test-data";
import {
PersonInfo,
columns,
@@ -216,16 +225,16 @@ 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
@@ -245,6 +254,9 @@ export const KitchenSink: Story = {
header.column.getToggleSortingHandler();
handler?.(event);
}}
label={(
header.column.columnDef.header || ""
).toString()}
>
{header.isPlaceholder
? null
@@ -255,32 +267,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>
);
},
@@ -344,19 +356,19 @@ 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,
})),
@@ -369,28 +381,31 @@ export const KitchenSinkAdvancedFilter: Story = {
>("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 [columnView, setColumnView] = React.useState(columnDef_TEST_DATA);
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 +441,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 +469,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 +491,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 +528,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}
@@ -538,35 +555,103 @@ 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={columnView.every(
(col) => col.details?.visible,
)}
size="small"
onChange={(event) => {
const handler =
table.getToggleAllColumnsVisibilityHandler();
handler?.(event);
onChange={() => {
const allVisible = columnView.every(
(col) => col.details?.visible,
);
const newColumnView = columnView.map((col) => ({
...col,
details: {
...col.details,
visible: !allVisible,
},
}));
setColumnView(newColumnView);
}}
>
Velg alle
</Switch>
<DataDragAndDrop setItems={setColumnOrder}>
{table.getAllLeafColumns().map((column, index) => {
<DataDragAndDrop setItems={setColumnView}>
{columnView.map((column, index) => {
return (
<DataDragAndDrop.Item
id={column.id}
@@ -576,14 +661,20 @@ export const KitchenSinkAdvancedFilter: Story = {
<Switch
key={column.id}
size="small"
checked={column.getIsVisible()}
checked={column.details?.visible}
onChange={(event) => {
const handler =
column.getToggleVisibilityHandler();
handler(event);
const newColumnView = [...columnView];
newColumnView[index] = {
...column,
details: {
...column.details,
visible: event.target.checked,
},
};
setColumnView(newColumnView);
}}
>
{column.id}
{column.label}
</Switch>
</DataDragAndDrop.Item>
);
@@ -599,84 +690,33 @@ export const KitchenSinkAdvancedFilter: Story = {
/>
<DataTable
getRowId={(row) => row.name}
columnDefinitions={columnView.filter((col) => col.details?.visible)}
data={pagedData.paginatedData}
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>
)}
</DataTable>
selection={{
selectionMode,
}}
stickyHeader
stickyColumns={{
first: stickyColumns.first === "first" ? "1" : undefined,
last: stickyColumns.last === "last" ? "1" : undefined,
}}
detailsPanel={{
getContent: showDetailsPanel
? (rowData) => <DetailsPanel row={rowData} />
: undefined,
}}
subRows={{
/* @ts-expect-error Test-data just hacked together now */
getRows: showNestedRows
? (rowData) => rowData.nestedRows
: undefined,
}}
/>
</VStack>
);
},
@@ -684,31 +724,69 @@ export const KitchenSinkAdvancedFilter: Story = {
a11y: { disable: true },
controls: { disable: true },
docs: { disable: true },
layout: "fullscreen",
},
};
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" UNSAFE_isSelection>
{multiRowSelection ? (
<CheckboxInput
compact
@@ -726,19 +804,19 @@ 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>
);
};
File diff suppressed because it is too large. Load diff
@@ -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>
);
},
};
@@ -17,11 +17,10 @@ import { type ResizeProps, useTableColumnResize } from "./useTableColumnResize";
interface DataTableColumnHeaderProps
extends ResizeProps, DataTableBaseCellProps {
resizeHandler?: (
event:
| React.MouseEvent<HTMLButtonElement>
| React.TouchEvent<HTMLButtonElement>,
) => void;
/**
* Accessible name of the column.
*/
label: string;
/**
* Makes the column header sortable. The entire header cell content becomes
* a clickable button when true.
@@ -59,9 +58,11 @@ const DataTableColumnHeader = forwardRef<
{
className,
children,
label,
sortable = false,
sortDirection = "none",
onSortClick,
resizable = true,
style,
width,
defaultWidth,
@@ -81,6 +82,7 @@ const DataTableColumnHeader = forwardRef<
const mergedRef = useMergeRefs(forwardedRef, thRef);
const resizeResult = useTableColumnResize({
resizable,
thRef,
width,
defaultWidth,
@@ -142,7 +144,7 @@ const DataTableColumnHeader = forwardRef<
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}
@@ -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.**
*
@@ -33,21 +29,36 @@ type ResizeProps = {
*/
autoWidth?: boolean;
/**
* Minimum 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 `autoWidth` is enabled.
* @default 40
*/
minWidth?: ColumnWidth;
minWidth?: number;
/**
* Maximum width of the column.
*
* Should be used in conjunction with `width` or `defaultWidth` to set limits when resizing.
* Maximum width of the column when resizing. Only used when `resizable` or `autoWidth` is enabled.
*/
maxWidth?: ColumnWidth;
maxWidth?: 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 `minWidth` and `maxWidth`.
*
* Should only be used to fully control column width state. Otherwise, use `defaultWidth` and let the component handle resizing.
*
* **NB:** Percentage as initial width does not work well with resizing.
*/
width?: number | string;
/**
* Initial width of the column. Only used when `width` is not set and `resizable` is true.
* Does not respect `minWidth` and `maxWidth`.
*
* **NB:** Percentage as initial width does not work well with resizing.
* @default 140px
*/
defaultWidth?: number | string;
/**
* Called when the column width changes.
* @param width New width in pixels.
*/
onWidthChange?: (width: ColumnWidth) => void;
onWidthChange?: (width: number) => void;
/**
* Forwarded styles
*/
@@ -96,6 +107,7 @@ function useTableColumnResize(
args: TableColumnResizeArgs,
): TableColumnResizeResult {
const {
resizable,
thRef,
width: userWidth,
defaultWidth,
@@ -112,7 +124,7 @@ function useTableColumnResize(
const [isResizingWithKeyboard, setIsResizingWithKeyboard] = useState(false);
const ignoreNextOnClick = useRef(false);
const [width, _setWidth] = useControllableState({
const [width, setWidth] = useControllableState({
value: userWidth,
defaultValue: defaultWidth ?? (colSpan ?? 1) * 140,
/**
@@ -123,14 +135,11 @@ function useTableColumnResize(
onChange: onWidthChange,
});
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, minWidth), maxWidth));
},
[minWidth, maxWidth, _setWidth],
[minWidth, maxWidth, setWidth],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: We only want to run this on mount and when autoWidth changes
@@ -142,7 +151,7 @@ function useTableColumnResize(
const newColumnWidth = getAutoColumnWidth(thRef);
if (newColumnWidth) {
setWidth(newColumnWidth);
setClampedWidth(newColumnWidth);
}
},
[autoWidth], // eslint-disable-line react-hooks/exhaustive-deps
@@ -173,19 +182,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 +202,7 @@ function useTableColumnResize(
setIsResizingWithKeyboard(false);
}
},
[isResizingWithKeyboard, setWidth, thRef],
[isResizingWithKeyboard, setClampedWidth, thRef],
);
const startResize = useCallback(
@@ -204,19 +213,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 > maxWidth) {
setWidth(newWidth < currentWidth ? newWidth : currentWidth);
return;
}
if (newWidth < min) {
if (newWidth < minWidth) {
setWidth(newWidth > currentWidth ? newWidth : currentWidth);
return;
}
setWidth(newWidth);
setClampedWidth(newWidth);
}
function onMouseMove(e: MouseEvent) {
@@ -244,7 +250,7 @@ function useTableColumnResize(
document.addEventListener("touchend", cleanup, { once: true });
document.addEventListener("touchcancel", cleanup, { once: true });
},
[maxWidth, minWidth, setWidth, thRef],
[maxWidth, minWidth, setWidth, setClampedWidth, thRef],
);
const handleMouseDown: DOMAttributes<HTMLButtonElement>["onMouseDown"] =
@@ -268,9 +274,9 @@ function useTableColumnResize(
useCallback(() => {
const newColumnWidth = getAutoColumnWidth(thRef);
if (newColumnWidth) {
setWidth(newColumnWidth);
setClampedWidth(newColumnWidth);
}
}, [setWidth, thRef]);
}, [setClampedWidth, thRef]);
if (tableContext.layout !== "fixed") {
return {
@@ -279,6 +285,16 @@ function useTableColumnResize(
};
}
if (!resizable) {
return {
style: {
...style,
width,
},
enabled: false,
};
}
return {
style: {
...style,
@@ -297,20 +313,6 @@ 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;
}
function getAutoColumnWidth(
thRef: React.RefObject<HTMLTableCellElement | null>,
) {
@@ -323,7 +325,9 @@ function getAutoColumnWidth(
}
// 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 +343,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
@@ -0,0 +1,53 @@
import React from "react";
import {
getDataTableDetailsPanelId,
useDataTableDetailsPanel,
} from "../hooks/useTableDetailsPanel";
import { useDataTableContext } from "../root/DataTableRoot.context";
function DataTableDetailsPanelRow<T>({
rowId,
rowData,
}: {
rowId: string | number;
rowData: T;
}) {
const { tableId, fullWidthColSpan } = 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={fullWidthColSpan}>
<div style={style}>{content}</div>
</td>
</tr>
);
}
export { DataTableDetailsPanelRow };
@@ -2,9 +2,9 @@ type TableRowEntryId = string | number;
type CollectTableRowEntriesArgs<T> = {
items: T[];
getRowId?: (rowData: T, index: number) => TableRowEntryId;
getSubRows?: (rowData: T) => T[];
isSubRowExpandable?: (rowData: T) => boolean;
getRowId: (rowData: T, index: number) => TableRowEntryId;
getRows?: (rowData: T) => T[];
isRowExpandable?: (rowData: T) => boolean;
};
interface ItemDetail<T> {
@@ -26,8 +26,8 @@ type CollectTableRowEntriesReturn<T> = {
function collectTableRowEntries<T>({
items,
getRowId,
getSubRows,
isSubRowExpandable,
getRows,
isRowExpandable,
}: CollectTableRowEntriesArgs<T>): CollectTableRowEntriesReturn<T> {
const itemDetailsMap = new Map<T, ItemDetail<T>>();
const childRowIdsById = new Map<TableRowEntryId, TableRowEntryId[]>();
@@ -37,13 +37,11 @@ function collectTableRowEntries<T>({
rowIndex: number,
level: number,
parent: T | null,
parentId?: TableRowEntryId,
): TableRowEntryId => {
const rowId =
getRowId?.(rowData, rowIndex) ??
(parentId == null ? rowIndex : `${parentId}-${rowIndex}`);
const isRowExpandable = isSubRowExpandable?.(rowData) ?? true;
const children = (isRowExpandable ? getSubRows?.(rowData) : []) ?? [];
const rowId = getRowId(rowData, rowIndex);
const children =
((isRowExpandable?.(rowData) ?? true) ? getRows?.(rowData) : []) ?? [];
itemDetailsMap.set(rowData, {
id: rowId,
@@ -56,13 +54,7 @@ function collectTableRowEntries<T>({
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, rowData);
childRowIds.push(childRowId);
}
@@ -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]);
});
});
@@ -78,8 +78,10 @@ describe("useTableItems", () => {
useTableItems({
items: nestedRows,
getRowId: (row) => row.id,
getSubRows,
defaultExpandedSubRowIds: ["a"],
subRows: {
getRows: getSubRows,
defaultExpandedRowIds: ["a"],
},
}),
);
@@ -91,7 +93,7 @@ describe("useTableItems", () => {
useTableItems({
items: nestedRows,
getRowId: (row) => row.id,
getSubRows,
subRows: { getRows: getSubRows },
}),
);
@@ -104,8 +106,11 @@ describe("useTableItems", () => {
const { result } = renderHook(() =>
useTableItems({
items: fallbackRows,
getSubRows: (row) => row.subRows ?? [],
defaultExpandedSubRowIds: [0],
getRowId: (_, index) => index,
subRows: {
getRows: (row: any) => row.subRows ?? [],
defaultExpandedRowIds: [0],
},
}),
);
@@ -121,8 +126,10 @@ describe("useTableItems", () => {
useTableItems({
items: nestedRows,
getRowId: (row) => row.id,
getSubRows,
expandedSubRowIds: expandedIds,
subRows: {
getRows: getSubRows,
expandedRowIds: expandedIds,
},
}),
{
initialProps: { expandedIds: [] as (string | number)[] },
@@ -42,7 +42,7 @@ describe("useTableSelection", () => {
test("returns empty selectedKeys and no prop getters", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "none",
selection: { selectionMode: "none" },
visibleRowIds,
}),
);
@@ -56,7 +56,7 @@ describe("useTableSelection", () => {
test("returns getRowRadioProps", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "single",
selection: { selectionMode: "single" },
visibleRowIds,
}),
);
@@ -69,9 +69,8 @@ describe("useTableSelection", () => {
const onChange = vi.fn();
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "single",
selection: { selectionMode: "single", onSelectionChange: onChange },
visibleRowIds,
onSelectionChange: onChange,
}),
);
@@ -88,9 +87,8 @@ describe("useTableSelection", () => {
test("toggling the same row keeps it selected", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "single",
selection: { selectionMode: "single", defaultSelectedKeys: ["a"] },
visibleRowIds,
defaultSelectedKeys: ["a"],
}),
);
@@ -106,9 +104,8 @@ describe("useTableSelection", () => {
test("selecting a new row replaces the previous", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "single",
selection: { selectionMode: "single", defaultSelectedKeys: ["a"] },
visibleRowIds,
defaultSelectedKeys: ["a"],
}),
);
@@ -124,9 +121,8 @@ describe("useTableSelection", () => {
test("disabled rows have disabled prop", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "single",
selection: { selectionMode: "single", disabledSelectionKeys: ["b"] },
visibleRowIds,
disabledSelectionKeys: ["b"],
}),
);
@@ -138,9 +134,8 @@ describe("useTableSelection", () => {
const { result, rerender } = renderHook(
({ selectedKeys }) =>
useTableSelection({
selectionMode: "single",
selection: { selectionMode: "single", selectedKeys },
visibleRowIds,
selectedKeys,
}),
{ initialProps: { selectedKeys: ["a"] as (string | number)[] } },
);
@@ -156,7 +151,7 @@ describe("useTableSelection", () => {
test("returns getTheadCheckboxProps and getRowCheckboxProps", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
selection: { selectionMode: "multiple" },
visibleRowIds,
}),
);
@@ -169,7 +164,7 @@ describe("useTableSelection", () => {
test("selecting individual rows", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
selection: { selectionMode: "multiple" },
visibleRowIds,
}),
);
@@ -194,9 +189,11 @@ describe("useTableSelection", () => {
test("deselecting a row", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
selection: {
selectionMode: "multiple",
defaultSelectedKeys: ["a", "b"],
},
visibleRowIds,
defaultSelectedKeys: ["a", "b"],
}),
);
@@ -212,7 +209,7 @@ describe("useTableSelection", () => {
test("select all via thead checkbox", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
selection: { selectionMode: "multiple" },
visibleRowIds,
}),
);
@@ -229,7 +226,7 @@ describe("useTableSelection", () => {
test("select all via thead includes hidden descendants for visible parents", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
selection: { selectionMode: "multiple" },
visibleRowIds: ["a"],
childRowIdsById,
}),
@@ -247,9 +244,11 @@ describe("useTableSelection", () => {
test("deselect all when all are selected", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
selection: {
selectionMode: "multiple",
defaultSelectedKeys: ["a", "b", "c"],
},
visibleRowIds,
defaultSelectedKeys: ["a", "b", "c"],
}),
);
@@ -265,10 +264,12 @@ describe("useTableSelection", () => {
test("deselect all clears hidden descendants for visible parents but preserves unrelated keys", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
selection: {
selectionMode: "multiple",
defaultSelectedKeys: ["a", "a1", "a2", "a2a", "external"],
},
visibleRowIds: ["a"],
childRowIdsById,
defaultSelectedKeys: ["a", "a1", "a2", "a2a", "external"],
}),
);
@@ -284,9 +285,11 @@ describe("useTableSelection", () => {
test("select all skips disabled keys", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
selection: {
selectionMode: "multiple",
disabledSelectionKeys: ["b"],
},
visibleRowIds,
disabledSelectionKeys: ["b"],
}),
);
@@ -302,10 +305,12 @@ describe("useTableSelection", () => {
test("deselect all preserves disabled-but-selected rows", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
selection: {
selectionMode: "multiple",
defaultSelectedKeys: ["a", "b", "c"],
disabledSelectionKeys: ["b"],
},
visibleRowIds,
defaultSelectedKeys: ["a", "b", "c"],
disabledSelectionKeys: ["b"],
}),
);
@@ -321,9 +326,8 @@ describe("useTableSelection", () => {
test("thead checkbox shows indeterminate when partially selected", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
selection: { selectionMode: "multiple", defaultSelectedKeys: ["a"] },
visibleRowIds,
defaultSelectedKeys: ["a"],
}),
);
@@ -335,9 +339,11 @@ describe("useTableSelection", () => {
test("thead checkbox shows checked when all selected", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
selection: {
selectionMode: "multiple",
defaultSelectedKeys: ["a", "b", "c"],
},
visibleRowIds,
defaultSelectedKeys: ["a", "b", "c"],
}),
);
@@ -349,10 +355,12 @@ describe("useTableSelection", () => {
test("thead checkbox shows checked when all selectable rows are selected", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
selection: {
selectionMode: "multiple",
defaultSelectedKeys: ["a", "c"],
disabledSelectionKeys: ["b"],
},
visibleRowIds,
defaultSelectedKeys: ["a", "c"],
disabledSelectionKeys: ["b"],
}),
);
@@ -364,9 +372,11 @@ describe("useTableSelection", () => {
test("deselecting one row when all rows are selected", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
selection: {
selectionMode: "multiple",
defaultSelectedKeys: ["a", "b", "c"],
},
visibleRowIds,
defaultSelectedKeys: ["a", "b", "c"],
}),
);
@@ -382,9 +392,11 @@ describe("useTableSelection", () => {
test("disabled rows have disabled prop", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
selection: {
selectionMode: "multiple",
disabledSelectionKeys: ["b"],
},
visibleRowIds,
disabledSelectionKeys: ["b"],
}),
);
@@ -395,9 +407,11 @@ describe("useTableSelection", () => {
test("thead checkbox disabled when all rows disabled", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
selection: {
selectionMode: "multiple",
disabledSelectionKeys: ["a", "b", "c"],
},
visibleRowIds,
disabledSelectionKeys: ["a", "b", "c"],
}),
);
@@ -407,10 +421,9 @@ describe("useTableSelection", () => {
test("parent rows show indeterminate when visible descendants are partially selected", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
selection: { selectionMode: "multiple", defaultSelectedKeys: ["a1"] },
visibleRowIds: ["a", "a1", "a2"],
childRowIdsById,
defaultSelectedKeys: ["a1"],
}),
);
@@ -423,7 +436,7 @@ describe("useTableSelection", () => {
test("toggling a parent row selects and deselects its descendants", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
selection: { selectionMode: "multiple" },
visibleRowIds: ["a", "a1", "a2"],
childRowIdsById,
}),
@@ -449,7 +462,7 @@ describe("useTableSelection", () => {
test("toggling a collapsed parent selects and deselects hidden descendants", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
selection: { selectionMode: "multiple" },
visibleRowIds: ["a"],
childRowIdsById,
}),
@@ -1,3 +1,4 @@
import { useMemo } from "react";
import type {
ColumnDefinition,
ColumnDefinitions,
@@ -28,19 +29,32 @@ function useColumnOptions<T>(
const hasSelection = selectionMode !== "none";
return {
stickySelection: selectionMode !== "none" && stickyColumns?.first === "1",
columns: columnDefinitions.map((colDef, index) => {
const columns = useMemo(() => {
return columnDefinitions.map((colDef, index) => {
const isFirstSticky =
stickyColumns?.first === "1" && index === 0 && !hasSelection;
const isLastSticky =
stickyColumns?.last === "1" && index === columnDefinitions.length - 1;
return {
isSticky: isFirstSticky ? "start" : isLastSticky ? "end" : false,
isSticky: isFirstSticky
? ("start" as const)
: isLastSticky
? ("end" as const)
: (false as const),
colDef,
};
}),
});
}, [
columnDefinitions,
hasSelection,
stickyColumns?.first,
stickyColumns?.last,
]);
return {
stickySelection: selectionMode !== "none" && stickyColumns?.first === "1",
columns,
};
}
@@ -3,7 +3,49 @@ import { createStrictContext } from "../../../utils/helpers";
import { useControllableState } from "../../../utils/hooks";
import { useTableItemsContext } from "./useTableItems";
type DataTableExpansionContextT = {
type DetailsPanelProps<T> = {
/**
* Renders a details panel below the row when expanded.
* 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?: (string | number)[];
/**
* Initial list of expanded row IDs for uncontrolled usage.
* @default []
*/
defaultExpandedRowIds?: (string | number)[];
/**
* Called when the list of expanded row IDs changes.
*
*
* TODO:
* - Docs: This pattern is called "Master / Detail" in general terms
*/
onExpandedRowIdsChange?: (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"
*/
getHeight?: (rowData: T) => number | "auto";
/**
* Shows an expand-all toggle button in the expand column header.
* @default false
*/
showExpandAll?: boolean;
};
type DataTableDetailsPanelContextT = {
isExpanded: (id: string | number) => boolean;
isDetailsPanelExpandable: (id: string | number) => boolean;
toggleExpansion: (id: string | number) => void;
@@ -16,42 +58,32 @@ type DataTableExpansionContextT = {
};
const {
Provider: DataTableExpansionContextProvider,
useContext: useDataTableExpansion,
} = createStrictContext<DataTableExpansionContextT>({
name: "DataTableExpansionContext",
Provider: DataTableDetailsPanelContextProvider,
useContext: useDataTableDetailsPanel,
} = createStrictContext<DataTableDetailsPanelContextT>({
name: "DataTableDetailsPanelContext",
errorMessage:
"useDataTableExpansion must be used within a DataTableExpansionProvider.",
"useDataTableDetailsPanel must be used within a DataTableDetailsPanelProvider.",
});
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>({
function DataTableDetailsPanelProvider<T>({
children,
detailsPanelRowIds,
defaultDetailsPanelRowIds = [],
onDetailsPanelChange,
getDetailsPanelContent,
isDetailsPanelExpandable,
getDetailsPanelHeight,
showExpandAll = false,
}: TableExpansionOptions<T> & { children: React.ReactNode }) {
detailsPanel = {},
}: { detailsPanel?: DetailsPanelProps<T> } & { children: React.ReactNode }) {
const {
expandedRowIds,
defaultExpandedRowIds = [],
onExpandedRowIdsChange,
getContent,
isRowExpandable,
getHeight,
showExpandAll = false,
} = detailsPanel;
const [expandedIds, setExpandedIds] = useControllableState({
value: detailsPanelRowIds,
defaultValue: defaultDetailsPanelRowIds,
onChange: onDetailsPanelChange,
value: expandedRowIds,
defaultValue: defaultExpandedRowIds,
onChange: onExpandedRowIdsChange,
});
/* TODO: False is just fallback until auto and root is merged */
@@ -62,7 +94,7 @@ function DataTableExpansionProvider<T>({
};
const expandableIds = React.useMemo(() => {
if (!getDetailsPanelContent) {
if (!getContent) {
return new Set<string | number>();
}
@@ -74,13 +106,13 @@ function DataTableExpansionProvider<T>({
continue;
}
if (!isDetailsPanelExpandable || isDetailsPanelExpandable(rowData)) {
if (!isRowExpandable || isRowExpandable(rowData)) {
ids.add(id);
}
}
return ids;
}, [getDetailsPanelContent, isDetailsPanelExpandable, itemDetails]);
}, [getContent, isRowExpandable, itemDetails]);
const isDetailsPanelExpandableById = useCallback(
(id: string | number) => expandableIds.has(id),
@@ -117,30 +149,34 @@ function DataTableExpansionProvider<T>({
}, [expandableIds, isAllExpanded, setExpandedIds]);
return (
<DataTableExpansionContextProvider
<DataTableDetailsPanelContextProvider
isExpanded={isExpanded}
isDetailsPanelExpandable={isDetailsPanelExpandableById}
toggleExpansion={toggleExpansion}
toggleAll={toggleAll}
isAllExpanded={isAllExpanded}
getDetailsPanelContent={
getDetailsPanelContent as
| ((row: unknown) => React.ReactNode)
| undefined
getContent as ((row: unknown) => React.ReactNode) | undefined
}
getDetailsPanelHeight={
getDetailsPanelHeight as ((row: unknown) => number | "auto") | undefined
getHeight as ((row: unknown) => number | "auto") | undefined
}
showExpandAll={showExpandAll}
enableDetailsPanel={!!getDetailsPanelContent}
enableDetailsPanel={!!getContent}
>
{children}
</DataTableExpansionContextProvider>
</DataTableDetailsPanelContextProvider>
);
}
function getDataTableDetailsPanelId(tableId: string, rowId: string | number) {
return `${tableId}-expansion-${rowId}`;
}
export {
DataTableExpansionProvider,
getDataTableExpansionId,
useDataTableExpansion,
DataTableDetailsPanelProvider,
getDataTableDetailsPanelId,
useDataTableDetailsPanel,
};
export type { DetailsPanelProps };
@@ -7,27 +7,18 @@ import {
collectTableRowEntries,
} from "../helpers/collectTableRowEntries";
type SubRowsProps<T> = {
getRows?: (rowData: T) => T[];
expandedRowIds?: (string | number)[];
defaultExpandedRowIds?: (string | number)[];
isRowExpandable?: (rowData: T) => boolean;
onExpandedRowIdsChange?: (ids: (string | number)[]) => void;
};
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, index: number) => TableRowEntryId;
subRows?: SubRowsProps<T>;
};
type useTableItemsReturn<T> = {
@@ -37,26 +28,26 @@ type useTableItemsReturn<T> = {
visibleRowIds: TableRowEntryId[];
/** Direct child ids for each row, used to traverse selection groups lazily. */
childRowIdsById: Map<TableRowEntryId, TableRowEntryId[]>;
onExpandedSubRowIdsChange: (id: string | number) => void;
onExpandedRowIdsChange: (id: string | number) => void;
isSubRowExpanded: (id: string | number) => boolean;
};
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(
@@ -70,8 +61,8 @@ function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
collectTableRowEntries({
items,
getRowId,
getSubRows,
isSubRowExpandable,
getRows,
isRowExpandable,
});
const localVisibleItems: T[] = [];
@@ -109,7 +100,7 @@ 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) => {
@@ -127,7 +118,7 @@ function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
itemDetails,
visibleRowIds,
childRowIdsById,
onExpandedSubRowIdsChange: handleExpandedSubRowIdChange,
onExpandedRowIdsChange: handleExpandedSubRowIdChange,
isSubRowExpanded: (id: string | number) => expandedIdsSet.has(id),
};
}
@@ -143,4 +134,4 @@ const { Provider: TableItemsProvider, useContext: useTableItemsContext } =
});
export { useTableItems, TableItemsProvider, useTableItemsContext };
export type { ItemDetail };
export type { ItemDetail, SubRowsProps };
@@ -9,7 +9,8 @@ import type {
TableSelection,
} from "../helpers/selection/selection.types";
type UseTableSelectionArgs = SelectionProps & {
type UseTableSelectionArgs = {
selection?: SelectionProps;
/* Visible rows manage the header checkbox state and render selection cells. */
visibleRowIds: (string | number)[];
/* Direct child ids let selection walk nested rows lazily. */
@@ -19,17 +20,23 @@ type UseTableSelectionArgs = SelectionProps & {
type UseTableSelectionReturn = {
selection: TableSelection;
renderSelection: boolean;
disableRowSelectionOnClick: boolean;
};
function useTableSelection({
selectionMode = "none",
defaultSelectedKeys,
selectedKeys: selectedKeysProp,
onSelectionChange,
disabledSelectionKeys = [],
selection = {},
visibleRowIds = [],
childRowIdsById,
}: UseTableSelectionArgs): UseTableSelectionReturn {
const {
selectionMode = "none",
defaultSelectedKeys,
selectedKeys: selectedKeysProp,
onSelectionChange,
disabledSelectionKeys = [],
disableRowSelectionOnClick = false,
} = selection;
const radioGroupName = useId();
const [selectedKeys, setSelectedKeys] = useControllableState<SelectedKeysT>({
@@ -59,6 +66,7 @@ function useTableSelection({
...baseSelection,
selectedKeys: [],
},
disableRowSelectionOnClick,
renderSelection: false,
};
}
@@ -75,6 +83,7 @@ function useTableSelection({
name: radioGroupName,
}),
},
disableRowSelectionOnClick,
renderSelection: visibleRowIds.length !== 0,
};
}
@@ -92,6 +101,7 @@ function useTableSelection({
childRowIdsById,
}),
},
disableRowSelectionOnClick,
renderSelection: visibleRowIds.length !== 0,
};
}
@@ -106,6 +116,7 @@ const noSelectionState: UseTableSelectionReturn = {
disabledSelectionKeys: [],
isRowSelected: () => false,
},
disableRowSelectionOnClick: false,
renderSelection: false,
};
+5 -3
View File
@@ -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 { ResizeProps } from "../column-header/useTableColumnResize";
type SortDirection = "asc" | "desc" | "none";
/**
@@ -5,18 +7,24 @@ type SortDirection = "asc" | "desc" | "none";
* - Consider "accessorKey" or similar to allow simple column definitions without a cell function.
* - Add "align" property for better control over text alignment in cells.
*/
type ColumnDefinition<T> = {
type ColumnDefinition<T, DetailsT = Record<string, any>> = Pick<
ResizeProps,
| "resizable"
| "width"
| "defaultWidth"
| "autoWidth"
| "minWidth"
| "maxWidth"
| "onWidthChange"
> & {
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";
/**
* Assigned to the cell's `th` element instead of `td` if true.
*
@@ -43,9 +51,16 @@ type ColumnDefinition<T> = {
* Use `sort` and `onSortChange` on the root component to control sort state.
*/
sortable?: boolean;
/**
* Additional metadata that can be used for filtering or other purposes. Not used by the table itself.
*/
details?: DetailsT;
};
type ColumnDefinitions<T> = ColumnDefinition<T>[];
type ColumnDefinitions<T, DetailsT = Record<string, any>> = ColumnDefinition<
T,
DetailsT
>[];
/**
* A single sort entry representing a column's current sort state.
@@ -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,258 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import React from "react";
import { expect, userEvent, within } from "storybook/test";
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: () => (
<DataTable
columnDefinitions={columns}
data={data}
getRowId={(row) => row.id}
subRows={{ getRows: getSubRows }}
selection={{ selectionMode: "multiple" }}
/>
),
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: () => (
<DataTable
columnDefinitions={columns}
data={fallbackIdData}
subRows={{
getRows: getSubRows,
defaultExpandedRowIds: [0],
}}
getRowId={(row) => row.id}
selection={{ selectionMode: "multiple" }}
/>
),
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 ParentSelectionFollowsVisibleNestedRows: Story = {
render: () => (
<DataTable
columnDefinitions={columns}
data={data}
getRowId={(row) => row.id}
subRows={{ getRows: getSubRows }}
selection={{ selectionMode: "multiple" }}
/>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: "Vis under-rader" }),
);
await userEvent.click(getCheckboxes(canvasElement)[2]);
expect(getCheckboxes(canvasElement)[1].checked).toBe(false);
expect(getCheckboxes(canvasElement)[1].indeterminate).toBe(true);
await userEvent.click(getCheckboxes(canvasElement)[1]);
expect(getCheckboxes(canvasElement)[1].checked).toBe(true);
expect(getCheckboxes(canvasElement)[2].checked).toBe(true);
await userEvent.click(getCheckboxes(canvasElement)[1]);
expect(getCheckboxes(canvasElement)[1].checked).toBe(false);
expect(getCheckboxes(canvasElement)[2].checked).toBe(false);
},
};
export const CollapsedParentSelectionIncludesHiddenDescendants: Story = {
render: () => (
<DataTable
columnDefinitions={columns}
data={deepNestedData}
getRowId={(row) => row.id}
subRows={{ getRows: getSubRows }}
selection={{ selectionMode: "multiple" }}
/>
),
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: () => (
<DataTable
columnDefinitions={columns}
data={deepNestedData}
getRowId={(row) => row.id}
subRows={{ getRows: getSubRows }}
selection={{ selectionMode: "multiple" }}
/>
),
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;
@@ -14,10 +14,14 @@ type DataTableContextProps<T> = {
rowId: string | number,
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.
*/
fullWidthColSpan: number;
};
const { Provider: DataTableContextProvider, useContext: useDataTableContext } =
@@ -0,0 +1,297 @@
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>, SelectionProps {
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;
/**
* 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";
}
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,
shouldBlockNavigation,
layout = "fixed",
...rest
},
forwardedRef,
) => {
const { tabIndex, setTableRef } = useTableKeyboardNav({
enabled: withKeyboardNav,
shouldBlockNavigation,
});
const mergedRef = useMergeRefs(forwardedRef, setTableRef);
return (
<DataTableContextProvider
layout={layout}
withKeyboardNav={withKeyboardNav}
selectionState={noSelectionState}
stickySelection={false}
stickyHeader={true}
tableId={useId()}
showLoadingSkeletons={false}
onRowClick={undefined}
showLoadingOverlay={false}
columns={[]}
fullWidthColSpan={9999}
>
<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,52 @@
import React, { forwardRef } from "react";
/** biome-ignore-all lint/correctness/useHookAtTopLevel: False positive because of the way forwardRef() is added */
import React, {
forwardRef,
useCallback,
useEffect,
useMemo,
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 { 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";
DataTableDetailsPanelProvider,
type DetailsPanelProps,
} from "../hooks/useTableDetailsPanel";
import {
DataTableEmptyState,
type DataTableEmptyStateProps,
} from "../empty-state/DataTableEmptyState";
import { DataTableExpansionProvider } from "../hooks/useTableExpansion";
type SubRowsProps,
TableItemsProvider,
useTableItems,
useTableItemsContext,
} 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 { ColumnDefinitions } 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;
interface DataTableProps<T>
extends React.HTMLAttributes<HTMLTableElement>, TableSortOptions {
children?: never;
/**
* Controls vertical cell padding.
* @default "normal"
@@ -59,7 +66,7 @@ interface DataTableProps
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
* @default true
*/
withKeyboardNav?: boolean;
/**
@@ -83,215 +90,473 @@ interface DataTableProps
* @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 true
*/
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;
loading?: {
/**
* 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;
};
/**
* Function to get sub-rows for a given row, used for nested rows.
* When provided, an expand toggle column is added automatically.
*
*
* TODO:
* - Table might need to be implemented with role="treegrid" for a11y when having nested rows.
*/
selection?: SelectionProps;
subRows?: SubRowsProps<T>;
detailsPanel?: DetailsPanelProps<T>;
}
interface DataTableRootComponent extends React.ForwardRefExoticComponent<
DataTableProps & React.RefAttributes<HTMLTableElement>
> {
function DataTableInner<T>(
{
className,
id,
rowDensity = "normal",
withKeyboardNav = true,
zebraStripes = false,
truncateContent = true,
shouldBlockNavigation,
layout = "fixed",
selection,
data,
columnDefinitions,
getRowId,
stickyColumns,
stickyHeader = true,
sort: sortProp,
defaultSort = [],
onSortChange,
onRowClick,
emptyState,
loading,
detailsPanel,
subRows,
...rest
}: DataTableProps<T>,
forwardedRef: React.ForwardedRef<HTMLTableElement>,
) {
const { sortState, onSortClick } = useTableSort({
defaultSort,
onSortChange,
sort: sortProp,
});
/**
* @see 🏷️ {@link DataTableCaptionProps}
* @example
* ```jsx
* <DataTable>
* <DataTable.Caption>
* Lorem ipsum
* </DataTable.Caption
* </DataTable>
* ```
* TODO:
* - If user currently does not give a getRowsId function, and data is nested (getSubRows)
* we end up in an infinite loop since the index based ids repeat for children and causes chaos.
*/
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;
const tableItems = useTableItems({
items: data,
getRowId: getRowId ?? ((_, index) => index),
subRows,
});
const tableSelectionState = useTableSelection({
selection,
visibleRowIds: tableItems.visibleRowIds,
childRowIdsById: tableItems.childRowIdsById,
});
const { columns, stickySelection } = useColumnOptions<T>(columnDefinitions, {
stickyColumns,
selectionMode: tableSelectionState.selection.selectionMode,
});
const {
isLoading = false,
loadingState,
loadingRows,
loadingLabel = "Laster innhold",
} = loading || {};
const fullWidthColSpan = useMemo(() => {
return (
columns.length +
(layout === "fixed" ? 1 : 0) +
(tableSelectionState.selection.selectionMode !== "none" ? 1 : 0) +
(detailsPanel?.getContent ? 1 : 0)
);
}, [
columns,
layout,
tableSelectionState.selection.selectionMode,
detailsPanel,
]);
const tableId = useId(id);
return (
<DataTableContextProvider
layout={layout}
withKeyboardNav={withKeyboardNav}
selectionState={tableSelectionState}
stickySelection={stickySelection}
stickyHeader={stickyHeader}
tableId={tableId}
showLoadingSkeletons={isLoading && loadingState == null}
onRowClick={onRowClick}
isLoading={isLoading}
showLoadingOverlay={isLoading && !loadingState && !loadingRows}
columns={columns}
fullWidthColSpan={fullWidthColSpan}
>
<TableItemsProvider
itemDetails={tableItems.itemDetails}
items={tableItems.items}
onExpandedRowIdsChange={tableItems.onExpandedRowIdsChange}
isSubRowExpanded={tableItems.isSubRowExpanded}
>
<DataTableDetailsPanelProvider detailsPanel={detailsPanel}>
<TableElementWrapper
shouldBlockNavigation={shouldBlockNavigation}
enabled={withKeyboardNav}
>
<table
{...rest}
ref={forwardedRef}
className={cl("aksel-data-table", className)}
data-zebra-stripes={zebraStripes}
data-truncate-content={truncateContent}
data-density={rowDensity}
data-layout={layout}
data-loading={isLoading || undefined}
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
resizable={colDef.resizable}
width={colDef.width}
defaultWidth={colDef.defaultWidth}
autoWidth={colDef.autoWidth}
minWidth={colDef.minWidth}
maxWidth={colDef.maxWidth}
onWidthChange={colDef.onWidthChange}
textAlign={colDef.align ?? "left"}
key={colDef.id}
isSticky={isSticky}
sortable={colDef.sortable}
sortDirection={sortDirection}
onSortClick={(event) => onSortClick(colDef.id, event)}
label={colDef.label}
>
{colDef.header ?? colDef.label}
</DataTableColumnHeader>
);
})}
</DataTableTr>
</DataTableThead>
<DataTableTbody>
<DataTableTBodyContent
loadingState={loadingState}
loadingRows={loadingRows}
loadingLabel={loadingLabel}
emptyState={emptyState}
/>
</DataTableTbody>
</table>
</TableElementWrapper>
</DataTableDetailsPanelProvider>
</TableItemsProvider>
</DataTableContextProvider>
);
}
/**
* 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
* Temp optimization to avoid re-renders on every keyboard-move, selection change etc
*/
const DataTable = forwardRef<HTMLTableElement, DataTableProps>(
(
{
className,
rowDensity = "normal",
withKeyboardNav = false,
zebraStripes = false,
truncateContent = true,
shouldBlockNavigation,
layout = "fixed",
...rest
},
forwardedRef,
) => {
const { tabIndex, setTableRef } = useTableKeyboardNav({
enabled: withKeyboardNav,
shouldBlockNavigation,
function TableElementWrapper({
children,
enabled,
shouldBlockNavigation,
}: {
children: React.ReactNode;
shouldBlockNavigation?: (event: KeyboardEvent) => boolean;
enabled: boolean;
}) {
const [applyStickyStyles, setApplyStickyStyles] = useState<boolean>(false);
const tableWrapperRef = useRef<HTMLDivElement>(null);
const tableRef = useRef<HTMLTableElement>(null);
const rafRef = useRef<number | null>(null);
const { tabIndex, setTableRef } = useTableKeyboardNav({
enabled,
shouldBlockNavigation,
});
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]);
const mergedRef = useMergeRefs(forwardedRef, setTableRef);
useEffect(() => {
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 {
loadingState: React.ReactNode;
loadingLabel: string;
loadingRows?: number;
emptyState: React.ReactNode;
}
function DataTableTBodyContent({
loadingState,
loadingRows,
loadingLabel,
emptyState,
}: DataTableTBodyContentProps) {
const { items, itemDetails } = useTableItemsContext();
const { columns, isLoading, fullWidthColSpan } = useDataTableContext();
if (isLoading && loadingState != null) {
return (
<DataTableContextProvider
layout={layout}
withKeyboardNav={withKeyboardNav}
selectionState={noSelectionState}
stickySelection={false}
stickyHeader={false}
tableId={useId()}
showLoadingSkeletons={false}
onRowClick={undefined}
disableRowSelectionOnClick={false}
showLoadingOverlay={false}
columns={[]}
>
<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>
</DataTableContextProvider>
<DataTableLoadingState colSpan={fullWidthColSpan}>
{loadingState}
</DataTableLoadingState>
);
},
) 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;
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.align ?? "left"}
key={colDef.id || colDefIndex}
as={colDef.isRowHeader ? "th" : "td"}
isSticky={isSticky}
>
<Skeleton variant="text" />
</DataTableBaseCell>
))}
</DataTableTr>
))}
</>
);
}
export {
DataTable,
DataTableCaption,
DataTableEmptyState,
DataTableLoadingState,
DataTableTbody,
DataTableTd,
DataTableTfoot,
DataTableTh,
DataTableThead,
DataTableTr,
};
if (items.length === 0 && emptyState !== undefined) {
return (
<DataTableEmptyState colSpan={fullWidthColSpan}>
{emptyState}
</DataTableEmptyState>
);
}
const renderLoadingAnnouncement = isLoading && !loadingState && !loadingRows;
return (
<>
{renderLoadingAnnouncement && (
<tr>
<td colSpan={fullWidthColSpan} className="aksel-sr-only">
{loadingLabel}
</td>
</tr>
)}
{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}>
<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
textAlign={colDef.align ?? "left"}
key={colDef.id || colDefIndex}
as={colDef.isRowHeader ? "th" : "td"}
isSticky={isSticky}
data-nested={renderNestedIndent || undefined}
style={style}
>
{renderNestedToggle && (
<DataTableSubRowToggle details={details} />
)}
{colDef.cell(rowData)}
</DataTableBaseCell>
);
})}
</DataTableTr>
<DataTableDetailsPanelRow rowId={details.id} rowData={rowData} />
</React.Fragment>
);
})}
</>
);
}
const DataTable = forwardRef(DataTableInner) as <T>(
props: DataTableProps<T> & React.RefAttributes<HTMLTableElement>,
) => React.ReactElement | null;
export { DataTable };
export type { DataTableProps };
export default DataTable;
export type {
DataTableCaptionProps,
DataTableEmptyStateProps,
DataTableLoadingStateProps,
DataTableProps,
DataTableTbodyProps,
DataTableTdProps,
DataTableTfootProps,
DataTableTheadProps,
DataTableThProps,
DataTableTrProps,
};
@@ -0,0 +1,39 @@
import React from "react";
import { ChevronDownIcon, ChevronRightIcon } from "@navikt/aksel-icons";
import { Button } from "../../../button";
import { type ItemDetail, useTableItemsContext } from "../hooks/useTableItems";
function DataTableSubRowToggle({ details }: { details: ItemDetail<any> }) {
const { isSubRowExpanded, onExpandedRowIdsChange } = 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();
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 };
@@ -15,9 +15,9 @@ import { cl, composeEventHandlers } from "../../../utils/helpers";
import { DataTableBaseCell } from "../base-cell/DataTableBaseCell";
import { DataTableColumnHeader } from "../column-header/DataTableColumnHeader";
import {
getDataTableExpansionId,
useDataTableExpansion,
} from "../hooks/useTableExpansion";
getDataTableDetailsPanelId,
useDataTableDetailsPanel,
} from "../hooks/useTableDetailsPanel";
import {
useDataTableContext,
useDataTableLocation,
@@ -46,13 +46,8 @@ const DataTableTr = forwardRef<HTMLTableRowElement, DataTableTrProps>(
},
forwardedRef,
) => {
const {
layout,
stickyHeader,
selectionState,
onRowClick,
disableRowSelectionOnClick,
} = useDataTableContext();
const { layout, stickyHeader, selectionState, onRowClick } =
useDataTableContext();
const { location } = useDataTableLocation();
const renderFillerCell = layout === "fixed" && children;
@@ -81,7 +76,7 @@ const DataTableTr = forwardRef<HTMLTableRowElement, DataTableTrProps>(
}
if (
!disableRowSelectionOnClick &&
!selectionState.disableRowSelectionOnClick &&
selectionState.selection.selectionMode !== "none"
) {
selectionState.selection.toggleSelection(rowId);
@@ -130,7 +125,7 @@ function RowExpansionCell({ rowId }: { rowId?: string | number }) {
isAllExpanded,
toggleAll,
showExpandAll,
} = useDataTableExpansion();
} = useDataTableDetailsPanel();
if (!enableDetailsPanel) {
return null;
@@ -143,6 +138,7 @@ function RowExpansionCell({ rowId }: { rowId?: string | number }) {
width={SELECTION_CELL_WIDTH}
UNSAFE_isSelection
data-block-keyboard-nav
label=""
/* isSticky={stickySelection && "start"} */
/>
);
@@ -160,6 +156,7 @@ function RowExpansionCell({ rowId }: { rowId?: string | number }) {
width={SELECTION_CELL_WIDTH}
UNSAFE_isSelection
data-block-keyboard-nav
label=""
/* isSticky={stickySelection && "start"} */
/>
);
@@ -171,6 +168,7 @@ function RowExpansionCell({ rowId }: { rowId?: string | number }) {
textAlign="center"
width={SELECTION_CELL_WIDTH}
UNSAFE_isSelection
label=""
/* isSticky={stickySelection && "start"} */
>
<Button
@@ -198,7 +196,7 @@ 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 />;
@@ -247,6 +245,7 @@ function RowSelectionCell({ rowId }: { rowId?: string | number }) {
<DataTableColumnHeader
width={SELECTION_CELL_WIDTH}
UNSAFE_isSelection
label=""
data-block-keyboard-nav
isSticky={stickySelection && "start"}
/>
@@ -274,6 +273,7 @@ function RowSelectionCell({ rowId }: { rowId?: string | number }) {
textAlign="center"
width={SELECTION_CELL_WIDTH}
UNSAFE_isSelection
label=""
isSticky={stickySelection && "start"}
>
<Label htmlFor={inputId} visuallyHidden>
@@ -289,6 +289,7 @@ function RowSelectionCell({ rowId }: { rowId?: string | number }) {
<DataTableColumnHeader
width={SELECTION_CELL_WIDTH}
UNSAFE_isSelection
label=""
data-block-keyboard-nav
isSticky={stickySelection && "start"}
/>
@@ -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={
+7 -1
View File
@@ -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"], {
+8 -4
View File
@@ -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;
}
@@ -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;
@@ -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;
+4
View File
@@ -1,5 +1,9 @@
# @navikt/ds-tailwind
## 8.10.4
## 8.10.3
## 8.10.2
## 8.10.1
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tailwind",
"version": "8.10.2",
"version": "8.10.4",
"description": "Tailwind config based on @navikt/ds-tokens",
"author": "Aksel | Nav designsystem team",
"keywords": [
@@ -27,7 +27,7 @@
},
"homepage": "https://aksel.nav.no/grunnleggende/kode/tailwind/",
"devDependencies": {
"@navikt/ds-tokens": "^8.10.2",
"@navikt/ds-tokens": "^8.10.4",
"tailwindcss": "^3.3.3",
"tsx": "^4.20.6",
"typescript": "6.0.2",
+4
View File
@@ -1,5 +1,9 @@
# @navikt/ds-tokens
## 8.10.4
## 8.10.3
## 8.10.2
## 8.10.1
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tokens",
"version": "8.10.2",
"version": "8.10.4",
"description": "Design-tokens for Nav designsystem",
"author": "Aksel | Nav designsystem team",
"keywords": [
+16
View File
@@ -1,5 +1,21 @@
# Changelog
## 8.10.4
### @navikt/ds-react
- ActionMenu: Now locked to bottom/top side placement, left/right for sub-menus. ([#4843](https://github.com/navikt/aksel/pull/4843))
## 8.10.3
### @navikt/ds-react
- Radio, Checkbox: Fix regression where `className` was mistakenly passed to `input`-element ([#4837](https://github.com/navikt/aksel/pull/4837))
### @navikt/ds-css
- Add module declaration to avoid TypeScript error when importing ([`92f6d9e`](https://github.com/navikt/aksel/commit/92f6d9e93b879962df8415f7fad22203174dd3a8))
## 8.10.2
### @navikt/ds-css
+40
View File
@@ -0,0 +1,40 @@
### Quick Assessment
This is a compact, task-oriented page. The before/after example in `App Router` does the most important job well: it shows the exact import pattern readers need to change. The biggest opportunities are stronger scope-signalling and more precise framework wording, so readers can tell what is required, what is optional, and what only applies in App Router server components.
### Missing Expected Documentation
- A short purpose-and-scope intro is missing under the H1. Why it matters: the page starts directly with implementation details, so readers cannot tell whether this is baseline setup for all Next.js projects or a small set of Aksel-specific caveats. Where to add it: directly under `# Next.js`, before `## Bundle-optimalisering`.
- Explicit applicability cues are missing for each section. Why it matters: `Bundle-optimalisering` reads like general Next.js setup, while `App Router` only applies to App Router server components. Without a `Gjelder:` or equivalent label, both humans and LLMs can overgeneralize the workaround. Where to add it: the first line of each section.
- A symptom-oriented note is missing in `## App Router`. Why it matters: the page gives the fix, but not how the problem typically presents itself, which makes the guidance harder to discover when someone is debugging. Where to add it: the opening paragraph of `## App Router`, before the explanation of the cause.
### Things To Change
- [Should fix] In `## Bundle-optimalisering`, the sentence `Nextjs vil ikke automatisk tree-shake Aksel sin React- og ikonpakke` and the claim about `flere hundre kb ekstra bundle-size` are more specific than the linked Next.js guidance. Risk: readers and LLMs can walk away with the wrong mental model that this is always a browser bundle-size problem, while the linked docs describe package-import optimization more broadly across dev/build/cold-start performance. Rephrase this around barrel-import optimization and remove the size claim unless there is Aksel-specific evidence for it.
- [Should fix] In `## App Router`, the sentence about `Nextjs sin webpack-bundler` is too tied to an internal implementation detail. Risk: the explanation will age badly and is harder to trust as Next.js changes tooling and internals. Prefer a stable user-facing rule over an internal-cause explanation unless you plan to maintain that claim.
- [Should fix] The exception `Dette er bare et problem ved direkte bruk i server-components` comes after the long before/after example. Risk: readers may over-apply the workaround and rewrite imports in client code even when they do not need to. Move that exception into a short note before the example or directly under the section heading.
- [Should fix] Terminology is inconsistent: the page title uses `Next.js`, while body text uses `Nextjs`, and English terms such as `path`, `bundle-size`, `server-components`, and `client` are mixed unevenly into Norwegian prose. Risk: weaker scanability, weaker search matching, and noisier extraction for LLMs. Standardize the naming and format code-like terms consistently.
- [Nice to have] `Relatert innhold` lists relevant sources, but not why each one matters. Risk: readers need to infer whether a link is required configuration, deeper background, or optional reading. Add a short label after each link to explain its purpose.
### Optimizations For Humans And LLMs
- Add a two-rule summary block near the top with the canonical answer from this page: `1. Legg til optimizePackageImports for @navikt/ds-react og @navikt/aksel-icons.` `2. I App Router server components: unngå dot-notation og importer fra @navikt/ds-react/<Komponentnavn>.`
- Add `Gjelder:` and `Anbefalt/Påkrevd:` labels at the top of each section. This makes the page much easier to skim and much easier for machine consumers to extract correctly.
- Restructure `## App Router` into a clearer sequence such as `Problem`, `Løsning`, `Når dette ikke gjelder`, even if that is only done with bold lead-ins instead of new headings.
- Turn `Relatert innhold` into labeled bullets with explicit intent, for example `Offisiell Next.js-konfigurasjon` and `Bakgrunn om Server/Client Components`.
- If the team knows the current verification point, add a small version cue such as `Sist verifisert mot Next.js ...`; otherwise avoid low-level implementation-cause wording that will become stale.
### Suggested Edits
- Under the H1, add: `Denne siden samler det som er spesielt for bruk av Aksel i Next.js: anbefalt bundle-optimalisering og en kjent begrensning i App Router server components.`
- At the top of `## Bundle-optimalisering`, add: `Gjelder: Next.js-prosjekter som bruker @navikt/ds-react eller @navikt/aksel-icons.`
- Replace the opening of `## Bundle-optimalisering` with: `Aksel-pakkene eksporteres via mange re-exports. I Next.js bør du derfor legge til optimizePackageImports for @navikt/ds-react og @navikt/aksel-icons, slik at unødvendig kostnad ved barrel-importer reduseres.`
- At the top of `## App Router`, add: `Gjelder: bare App Router og direkte bruk i server components.`
- Replace the cause sentence in `## App Router` with: `I App Router server components fungerer ikke ds-react-komponenter med dot-notation stabilt. Bruk derfor komponentens subpath-import i stedet.`
- Move the current `"use client"` exception into a short note before the code example: `Dette gjelder ikke i filer som allerede er client components med "use client".`
### Open Questions
- Finnes det en upstream-issue eller intern issue for dot-notation-begrensningen som bør lenkes fra `## App Router`?
- Skal siden dekke bare App Router, eller bør den uttrykkelig si at den andre seksjonen ikke er relevant for Pages Router-prosjekter?
- Er påstanden om `flere hundre kb ekstra bundle-size` basert på en konkret Aksel-måling? Hvis ikke bør den mykes opp.
+6 -6
View File
@@ -15,11 +15,11 @@
"playroom"
],
"dependencies": {
"@navikt/aksel": "^8.10.2",
"@navikt/aksel-icons": "^8.10.2",
"@navikt/ds-css": "^8.10.2",
"@navikt/ds-react": "^8.10.2",
"@navikt/ds-tailwind": "^8.10.2",
"@navikt/ds-tokens": "^8.10.2"
"@navikt/aksel": "^8.10.4",
"@navikt/aksel-icons": "^8.10.4",
"@navikt/ds-css": "^8.10.4",
"@navikt/ds-react": "^8.10.4",
"@navikt/ds-tailwind": "^8.10.4",
"@navikt/ds-tokens": "^8.10.4"
}
}
@@ -152,7 +152,6 @@ export const ShowMore = ({
behavior: "instant",
block: "start",
});
// eslint-disable-next-line react-hooks/set-state-in-effect
setShouldScroll(false);
}
}
@@ -16294,6 +16294,26 @@ export type ALL_MARKDOWN_ARTICLES_INDEX_QUERY_RESULT = Array<
}
>;
// Source: ../app/api/component-props/route.ts
// Variable: COMPONENT_PROPS_QUERY
// Query: *[_type == "komponent_artikkel" && slug.current == $slug][0] { "title": heading, "propSections": content[_type == "props_seksjon"][].komponenter[] { title, overridable, "props": propref->proplist[] { name, type, unpackedType, required, description, defaultValue, deprecated } } }
export type COMPONENT_PROPS_QUERY_RESULT = {
title: string | null;
propSections: Array<{
title: string | null;
overridable: boolean | null;
props: Array<{
name: string | null;
type: string | null;
unpackedType: string | null;
required: boolean | null;
description: string | null;
defaultValue: string | null;
deprecated: string | null;
}> | null;
} | null> | null;
} | null;
declare module "@sanity/client" {
interface SanityQueries {
'*[_type == "aksel_ds_forside"][0] {\n ds_forside_title,\n ds_forside_ingress,\n ds_getting_started[]{ description, icon, link, title },\n ds_layers_overview,\n ds_changelog { title, ingress },\n ds_aksel_in_numbers { ingress, statistics[]{number, title, unit}, title},\n ds_support[]{description, link, title},\n seo { image, meta }\n }': DS_FRONT_PAGE_QUERY_RESULT;
@@ -16342,5 +16362,6 @@ declare module "@sanity/client" {
'*[_type == "ds_artikkel" && slug.current == $slug][0]{\n ...,\n content[]{\n ...,\n \n\n_type == "language" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n_type == "alert" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n_type == "attachment" =>{\n ...,\n "downloadLink": asset->url,\n "size": asset->size,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n_type == "tips" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n_type == "token_ref"=>@->,\n\nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n},\n_type == "intro_komponent" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n_type == "relatert_innhold" =>{\n title,\n lenker[]{\n ...,\n "intern_lenke": intern_lenke->slug.current,\n }\n},\n_type == "live_demo" =>{\n ...,\n "sandbox_ref": sandbox_ref->{...},\n},\n_type == "installasjon_seksjon" =>{\n ...,\n "code_ref": code_ref->{...},\n},\n_type == "accordion"=>{\n ...,\n list[]{\n ...,\n content[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n},\n \n _type == "bilde" =>{\n ...,\n floating_text[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "video" =>{\n ...,\n "webm": {\n "url": webm.asset->url,\n "extension": webm.asset->extension\n },\n "fallback": {\n "url": fallback.asset->url,\n "extension": fallback.asset->extension\n },\n "track": track.asset->url\n },\n _type == "alert" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "kode" =>{\n ...,\n "ref": ref->{...},\n },\n _type == "kode_eksempler" =>{\n ...,\n dir->,\n },\n _type == "kode_ref" => @->,\n _type == "tips" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n _type == "relatert_innhold" =>{\n title,\n lenker[]{\n ...,\n "intern_lenke": intern_lenke->slug.current,\n }\n}\n\n }\n }\n}\n,\n_type == "expansioncard"=>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n},\n \n _type == "bilde" =>{\n ...,\n floating_text[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "video" =>{\n ...,\n "webm": {\n "url": webm.asset->url,\n "extension": webm.asset->extension\n },\n "fallback": {\n "url": fallback.asset->url,\n "extension": fallback.asset->extension\n },\n "track": track.asset->url\n },\n _type == "alert" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "kode" =>{\n ...,\n "ref": ref->{...},\n },\n _type == "kode_eksempler" =>{\n ...,\n dir->,\n },\n _type == "kode_ref" => @->,\n _type == "tips" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n _type == "relatert_innhold" =>{\n title,\n lenker[]{\n ...,\n "intern_lenke": intern_lenke->slug.current,\n }\n}\n\n }\n},\n\n _type == "bilde" =>{\n ...,\n floating_text[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "video" =>{\n ...,\n "webm": {\n "url": webm.asset->url,\n "extension": webm.asset->extension\n },\n "fallback": {\n "url": fallback.asset->url,\n "extension": fallback.asset->extension\n },\n "track": track.asset->url\n },\n _type == "alert" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "kode" =>{\n ...,\n "ref": ref->{...},\n },\n _type == "kode_eksempler" =>{\n ...,\n dir->,\n },\n _type == "kode_ref" => @->,\n _type == "tips" =>{\Line truncated
'*[_type == "templates_artikkel" && slug.current == $slug][0]{\n ...,\n content[]{\n ...,\n \n\n_type == "language" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n_type == "alert" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n_type == "attachment" =>{\n ...,\n "downloadLink": asset->url,\n "size": asset->size,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n_type == "tips" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n_type == "token_ref"=>@->,\n\nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n},\n_type == "intro_komponent" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n_type == "relatert_innhold" =>{\n title,\n lenker[]{\n ...,\n "intern_lenke": intern_lenke->slug.current,\n }\n},\n_type == "live_demo" =>{\n ...,\n "sandbox_ref": sandbox_ref->{...},\n},\n_type == "installasjon_seksjon" =>{\n ...,\n "code_ref": code_ref->{...},\n},\n_type == "accordion"=>{\n ...,\n list[]{\n ...,\n content[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n},\n \n _type == "bilde" =>{\n ...,\n floating_text[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "video" =>{\n ...,\n "webm": {\n "url": webm.asset->url,\n "extension": webm.asset->extension\n },\n "fallback": {\n "url": fallback.asset->url,\n "extension": fallback.asset->extension\n },\n "track": track.asset->url\n },\n _type == "alert" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "kode" =>{\n ...,\n "ref": ref->{...},\n },\n _type == "kode_eksempler" =>{\n ...,\n dir->,\n },\n _type == "kode_ref" => @->,\n _type == "tips" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n _type == "relatert_innhold" =>{\n title,\n lenker[]{\n ...,\n "intern_lenke": intern_lenke->slug.current,\n }\n}\n\n }\n }\n}\n,\n_type == "expansioncard"=>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n},\n \n _type == "bilde" =>{\n ...,\n floating_text[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "video" =>{\n ...,\n "webm": {\n "url": webm.asset->url,\n "extension": webm.asset->extension\n },\n "fallback": {\n "url": fallback.asset->url,\n "extension": fallback.asset->extension\n },\n "track": track.asset->url\n },\n _type == "alert" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "kode" =>{\n ...,\n "ref": ref->{...},\n },\n _type == "kode_eksempler" =>{\n ...,\n dir->,\n },\n _type == "kode_ref" => @->,\n _type == "tips" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n _type == "relatert_innhold" =>{\n title,\n lenker[]{\n ...,\n "intern_lenke": intern_lenke->slug.current,\n }\n}\n\n }\n},\n\n _type == "bilde" =>{\n ...,\n floating_text[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "video" =>{\n ...,\n "webm": {\n "url": webm.asset->url,\n "extension": webm.asset->extension\n },\n "fallback": {\n "url": fallback.asset->url,\n "extension": fallback.asset->extension\n },\n "track": track.asset->url\n },\n _type == "alert" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "kode" =>{\n ...,\n "ref": ref->{...},\n },\n _type == "kode_eksempler" =>{\n ...,\n dir->,\n },\n _type == "kode_ref" => @->,\n _type == "tipLine truncated
'*[_type in ["komponent_artikkel", "ds_artikkel", "templates_artikkel"] && defined(slug.current)]{\n _type,\n heading,\n "slug": slug.current,\n kategori,\n "tag": status.tag,\n sidebarindex\n }': ALL_MARKDOWN_ARTICLES_INDEX_QUERY_RESULT;
'*[_type == "komponent_artikkel" && slug.current == $slug][0] {\n "title": heading,\n "propSections": content[_type == "props_seksjon"][].komponenter[] {\n title,\n overridable,\n "props": propref->proplist[] {\n name,\n type,\n unpackedType,\n required,\n description,\n defaultValue,\n deprecated\n }\n }\n }': COMPONENT_PROPS_QUERY_RESULT;
}
}
@@ -61,7 +61,7 @@ function KodeEksemplerIFrame(props: {
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
setHasMounted(true); // eslint-disable-line react-hooks/set-state-in-effect
}, []);
return (
@@ -7,9 +7,11 @@ import styles from "./PropsSeksjon.module.css";
function PropsSeksjonDescription({
description,
params,
returnVal,
}: {
description?: string;
params?: string[];
description: string | undefined;
params: string[] | undefined;
returnVal: string | undefined;
}) {
if (!description) {
return null;
@@ -30,20 +32,26 @@ function PropsSeksjonDescription({
>
{description}
</Markdown>
{params && (
<ul>
{params.map((param) => {
const [name, ...desc] = param.split(" ");
return (
<li key={param}>
<strong>{name}: </strong>
{desc.join(" ")}
</li>
);
})}
</ul>
)}
{returnVal && (
<div>
<strong>Return: </strong>
{returnVal}
</div>
)}
</VStack>
{params && (
<ul>
{params.map((param) => {
const [name, ...desc] = param.split(" ");
return (
<li key={param}>
<strong>{name}: </strong>
{desc.join(" ")}
</li>
);
})}
</ul>
)}
</div>
</li>
);
@@ -86,55 +86,22 @@ const PropEntry = ({
NonNullable<PropsSeksjonComponentT["propref"]>["proplist"]
>[0];
}) => {
if (prop.deprecated) {
return (
<>
<Box
as="dt"
paddingBlock="space-8 space-0"
paddingInline="space-8"
className="inline-block"
>
<Code as="h4" className="mr-2" strikethrough highlighted>{`${
prop.name
}${prop?.required ? "" : "?"}`}</Code>
</Box>
<BodyShort as="dd">
<Box as="ul" overflowX="auto">
<PropsSeksjonDeprecation text={prop.deprecated} />
<PropsSeksjonCode code={prop.type} title="Type" wrap />
<PropsSeksjonCode
/* We assume that if type starts with ", its an union-type */
code={prop.defaultValue}
title="Default"
wrap
/>
<PropsSeksjonDescription
description={prop.description}
params={prop.params}
/>
<PropsSeksjonCode code={prop.example} title="Example" />
</Box>
</BodyShort>
</>
);
}
let type = prop.type;
if (prop.type === "AkselColor") {
type = unpackAkselColorType();
}
const type = prop.type === "AkselColor" ? unpackedAkselColorType : prop.type;
return (
<>
<Box as="dt" paddingBlock="space-8 space-0" paddingInline="space-8">
<Code as="h4" highlighted>{`${prop.name}${
prop?.required ? "" : "?"
}`}</Code>
<Code
as="h4"
highlighted
strikethrough={!!prop.deprecated}
>{`${prop.name}${prop?.required ? "" : "?"}`}</Code>
</Box>
<BodyShort as="dd">
<Box as="ul" overflowX="auto">
{prop.deprecated && (
<PropsSeksjonDeprecation text={prop.deprecated} />
)}
<PropsSeksjonCode code={type} title="Type" wrap />
<PropsSeksjonCode
/* We assume that if type starts with ", its an union-type */
@@ -145,6 +112,7 @@ const PropEntry = ({
<PropsSeksjonDescription
description={prop.description}
params={prop.params}
returnVal={prop.return}
/>
<PropsSeksjonCode code={prop.example} title="Example" />
</Box>
@@ -167,10 +135,8 @@ const colors: Record<AkselColorRole, boolean> = {
"brand-magenta": true,
};
function unpackAkselColorType() {
return Object.keys(colors)
.map((key) => `"${key}"`)
.join(" | ");
}
const unpackedAkselColorType = Object.keys(colors)
.map((key) => `"${key}"`)
.join(" | ");
export { PropsSeksjon };
@@ -0,0 +1,144 @@
import { defineQuery } from "next-sanity";
import { type NextRequest, NextResponse } from "next/server";
import { sanityMarkdownFetch } from "@/app/_sanity/live";
export const revalidate = 7200;
const COMPONENT_PROPS_QUERY = defineQuery(
`*[_type == "komponent_artikkel" && slug.current == $slug][0] {
"title": heading,
"propSections": content[_type == "props_seksjon"][].komponenter[] {
title,
overridable,
"props": propref->proplist[] {
name,
type,
unpackedType,
required,
description,
defaultValue,
deprecated,
example,
params,
return
}
}
}`,
);
/**
* Route allows external tools (Aksel-mcp) to fetch props related to a specific component:
* Example: https://aksel.nav.no/api/component-props?slug=komponenter/core/button
*/
export async function GET(request: NextRequest) {
const rawSlug = request.nextUrl.searchParams.get("slug");
if (!rawSlug) {
return NextResponse.json(
{ error: "Missing required query parameter: slug" },
{ status: 400 },
);
}
const slug = normalizeComponentSlug(rawSlug);
if (!slug) {
return NextResponse.json(
{
error:
"Invalid slug. Expected a component slug starting with 'komponenter/'.",
},
{ status: 400 },
);
}
try {
const { data } = await sanityMarkdownFetch({
query: COMPONENT_PROPS_QUERY,
params: { slug },
});
if (!data?.propSections || data.propSections.length === 0) {
return NextResponse.json(
{ error: "Component not found or has no props documentation", slug },
{ status: 404 },
);
}
const parts: {
title: string | null;
props: {
type: string | null;
name: string | null;
required: boolean | null;
description: string | null;
defaultValue: string | null;
deprecated: string | null;
example: string | null;
params: string | null;
return: string | null;
}[];
}[] = [];
for (const section of data.propSections) {
if (!section?.title) {
continue;
}
const props = (section?.props ?? [])
.filter((prop) => !prop.description?.includes("@private"))
.sort((a, b) => {
if (a.deprecated && !b.deprecated) return 1;
if (!a.deprecated && b.deprecated) return -1;
return 0;
})
.map(({ unpackedType, type, ...rest }) => ({
...rest,
type: unpackedType ?? type,
}));
if (section.overridable) {
props.push({
name: "as",
type: "React.ElementType",
required: false,
description: "Override the root element (OverridableComponent API).",
defaultValue: null,
deprecated: null,
example: null,
params: null,
return: null,
});
}
parts.push({ title: section.title, props });
}
return NextResponse.json(
{ title: data.title, slug, parts },
{
headers: {
"Cache-Control": `public, max-age=${revalidate}`,
},
},
);
} catch (error) {
console.error("Failed to fetch component props", { slug, error });
return NextResponse.json(
{ error: "Failed to fetch component props", slug },
{ status: 500 },
);
}
}
const COMPONENT_SLUG_PATTERN = /^komponenter(?:\/[a-z0-9-]+){2}$/;
function normalizeComponentSlug(rawSlug: string) {
const normalizedSlug = rawSlug.trim().replace(/^\/+|\/+$/g, "");
if (!COMPONENT_SLUG_PATTERN.test(normalizedSlug)) {
return null;
}
return normalizedSlug;
}
+12 -12
View File
@@ -35,15 +35,15 @@
"@navikt/next-logger": "4.4.1",
"@navikt/oasis": "3.2.1",
"@neshca/cache-handler": "1.9.0",
"@portabletext/react": "^6.0.2",
"@sanity/client": "7.20.0",
"@portabletext/react": "^6.0.3",
"@sanity/client": "7.21.0",
"@sanity/code-input": "7.1.0",
"@sanity/color-input": "6.0.4",
"@sanity/image-url": "2.1.1",
"@sanity/locale-nb-no": "1.1.33",
"@sanity/preview-url-secret": "4.0.4",
"@sanity/preview-url-secret": "4.0.5",
"@sanity/table": "2.0.1",
"@sanity/vision": "5.20.0",
"@sanity/vision": "5.22.0",
"@slack/web-api": "^7.8.0",
"codesandbox-import-utils": "^2.2.3",
"date-fns": "^4.0.0",
@@ -54,7 +54,7 @@
"lz-string": "^1.5.0",
"next": "16.2.3",
"next-logger": "^5.0.2",
"next-sanity": "12.2.2",
"next-sanity": "12.3.0",
"next-themes": "^0.4.6",
"node-cache": "^5.1.2",
"pino": "^8.21.0",
@@ -68,7 +68,7 @@
"react-snowfall": "^2.4.0",
"remark-gfm": "^4.0.0",
"rxjs": "^7.8.2",
"sanity": "5.20.0",
"sanity": "5.22.0",
"sanity-plugin-iframe-pane": "5.0.11",
"sanity-plugin-media": "4.1.1",
"sanity-plugin-references": "1.0.1",
@@ -88,13 +88,13 @@
"@babel/traverse": "^7.27.4",
"@next/bundle-analyzer": "16.2.3",
"@next/eslint-plugin-next": "16.2.3",
"@playwright/test": "^1.57.0",
"@portabletext/block-tools": "^5.0.1",
"@portabletext/markdown": "^1.1.2",
"@sanity/blueprints": "0.15.0",
"@playwright/test": "1.59.1",
"@portabletext/block-tools": "^5.1.1",
"@portabletext/markdown": "^1.2.0",
"@sanity/blueprints": "0.16.0",
"@sanity/export": "6.1.0",
"@sanity/functions": "1.2.1",
"@sanity/schema": "5.20.0",
"@sanity/functions": "1.3.1",
"@sanity/schema": "5.22.0",
"@types/jscodeshift": "17.3.0",
"@types/jsdom": "^27.0.0",
"@types/lodash": "4.17.20",
+3 -3
View File
@@ -80,7 +80,7 @@
"devDependencies": {
"@biomejs/biome": "2.4.10",
"@changesets/cli": "2.29.7",
"@dnd-kit/react": "0.3.2",
"@dnd-kit/react": "0.4.0",
"@eslint/js": "^10.0.1",
"@storybook/addon-a11y": "^10.3.5",
"@storybook/addon-docs": "^10.3.5",
@@ -96,7 +96,7 @@
"@vitest/browser": "4.1.4",
"@vitest/browser-playwright": "4.1.4",
"@vitest/coverage-v8": "4.1.4",
"@vitest/eslint-plugin": "1.6.14",
"@vitest/eslint-plugin": "1.6.16",
"chromatic": "15.1.0",
"concurrently": "9.2.1",
"cross-env": "^10.0.0",
@@ -112,7 +112,7 @@
"globals": "^17.4.0",
"husky": "^8.0.0",
"lint-staged": "16.4.0",
"playwright": "^1.57.0",
"playwright": "1.59.1",
"prettier": "3.8.1",
"react": "19.2.5",
"react-docgen-typescript": "2.2.2",
+4 -2
View File
@@ -10,9 +10,11 @@ function analyzeCss(tarLocation: string): number {
const cssPackageDir = unpackTar(tarLocation);
const cssPackageExports = getPackageExports(cssPackageDir);
const exportPath = cssPackageExports["."];
const exportEntry = cssPackageExports["."];
const exportPath =
typeof exportEntry === "string" ? exportEntry : exportEntry?.default;
/* We assume key-value to be string for css-package */
/* We assume max one level of nesting for css-package exports */
assert(
exportPath && typeof exportPath === "string",
"Package export '.' not found for css-package",
+1923 -3304
View File
File diff suppressed because it is too large. Load diff