Compare commits

..
Author SHA1 Message Date
github-actions[bot] ed6c7e999d Version Packages (#4853)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-07 13:23:04 +02:00
Anniken Granberg cf9992ca3e DataTable / dnd: fix types 2026-05-07 12:57:24 +02:00
Anniken Granberg 4db5cbfe54 DataTable: failed if i didnt remove comment??? 2026-05-07 12:20:39 +02:00
Anniken Granberg 54a077fbb7 DataTable / dnd: Fix type and other small stuff 2026-05-07 12:16:04 +02:00
Anniken Granberg a6e2a2f4d4 DataTable / dnd: Background color only on overlay, removed default padding and margin on ul 2026-05-07 12:16:04 +02:00
Anniken GranbergandCopilot 7e31ee6822 DataTable / dnd: Rename old to legacy
Co-authored-by: Copilot <copilot@github.com>
2026-05-07 12:16:04 +02:00
Halvor Haugan 35295e7c01 Pagination: Fix selected-styling in forced-colors mode (#4858) 2026-05-07 11:31:22 +02:00
Halvor Haugan 0522dfae59 Button: Fix issue where secondary and tertiary variants did not have correct text color is certain cases (#4857) 2026-05-07 08:31:50 +02:00
Halvor Haugan 53d40657bb [Sanity] Add component titles to preview of Props section (#4856) 2026-05-06 12:32:32 +02:00
Halvor Haugan 334770de25 Fix running tests in react package on Windows (#4855) 2026-05-06 09:57:10 +02:00
Anniken Granberg faec264163 DataTable / DND : Improved announcer 2026-05-05 22:31:32 +02:00
Anniken Granberg fbfdeba70b DataTable / DND: Better label, handle reset on esc 2026-05-05 21:14:17 +02:00
Halvor Haugan 76c3143d68 ⬆️ Bump typescript to 6.0.3 to fix build issue 2026-05-05 14:55:47 +02:00
Ken bec61c3743 [DataTable] Update feature gap md 2026-05-05 14:45:24 +02:00
Ken 3fbba87f06 [DataTable] Update kitchensink demo 2026-05-05 14:45:24 +02:00
Ken 62197a107b [DataTable] Refactor scroll-calculation 2026-05-05 14:45:24 +02:00
Ken 4f55967cf8 [DataTable] Dynamically add scroll-margin to focused cell 2026-05-05 14:45:23 +02:00
Halvor Haugan 335b7b7af2 [website] Add field for heading-level in PropsSeksjon (#4854) 2026-05-05 13:57:26 +02:00
Ken 1073f5d502 [DataTable] Keyboard-nav now acts like rowing tabindex pattern 2026-05-05 10:28:25 +02:00
Ken ffab045fcb [DataTable] Fixed issue with keyboard-nav not updating cache when expanding/collapsing rows 2026-05-05 10:08:47 +02:00
Ken 8bb8083be3 [DataTable] Remove index from getRowId callbacks 2026-05-05 10:00:10 +02:00
Ken ba7d2be630 [DataTable] Refactor object reference to unique ID 2026-05-05 09:41:24 +02:00
Ken 4d3839e9b5 [DataTable] TODO 2026-05-04 16:35:17 +02:00
Ken 3ff4d17fa0 [DataTable] Remove legacy todo 2026-05-04 16:32:37 +02:00
Ken 53497224fa [DataTable] Type button for buttons in columnHeader 2026-05-04 16:31:07 +02:00
Ken 1284fefa7b [DataTable] Named useEffect 2026-05-04 16:13:07 +02:00
Ken 2f264b7cb7 [DataTable] getRowId is now optional, falling back to unique ids, even for nested items 2026-05-04 15:23:42 +02:00
dependabot[bot]andKen 4359fb3aba Bump @floating-ui/react from 0.27.8 to 0.27.19 (#4851)
* Bump @floating-ui/react from 0.27.8 to 0.27.19

Bumps [@floating-ui/react](https://github.com/floating-ui/floating-ui/tree/HEAD/packages/react) from 0.27.8 to 0.27.19.
- [Release notes](https://github.com/floating-ui/floating-ui/releases)
- [Changelog](https://github.com/floating-ui/floating-ui/blob/master/packages/react/CHANGELOG.md)
- [Commits](https://github.com/floating-ui/floating-ui/commits/@floating-ui/react@0.27.19/packages/react)

---
updated-dependencies:
- dependency-name: "@floating-ui/react"
  dependency-version: 0.27.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* 📝 Remove floating ui from website

* 📝 Changeset

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ken <ken.aleksander@gmail.com>
2026-05-04 14:41:18 +02:00
dependabot[bot]andKen 632fcff376 Bump the vitest group with 4 updates (#4847)
* Bump the vitest group with 4 updates

Bumps the vitest group with 4 updates: [@vitest/browser](https://github.com/vitest-dev/vitest/tree/HEAD/packages/browser), [@vitest/browser-playwright](https://github.com/vitest-dev/vitest/tree/HEAD/packages/browser-playwright), [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


Updates `@vitest/browser` from 4.1.4 to 4.1.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/browser)

Updates `@vitest/browser-playwright` from 4.1.4 to 4.1.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/browser-playwright)

Updates `@vitest/coverage-v8` from 4.1.4 to 4.1.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/coverage-v8)

Updates `vitest` from 4.1.4 to 4.1.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/vitest)

---
updated-dependencies:
- dependency-name: "@vitest/browser"
  dependency-version: 4.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: vitest
- dependency-name: "@vitest/browser-playwright"
  dependency-version: 4.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: vitest
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: vitest
- dependency-name: vitest
  dependency-version: 4.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: vitest
...

Signed-off-by: dependabot[bot] <support@github.com>

* 📝 Sync versions

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ken <ken.aleksander@gmail.com>
2026-05-04 14:29:34 +02:00
dependabot[bot]andKen 08b1ef6d72 Bump fuse.js from 7.1.0 to 7.3.0 (#4849)
* Bump fuse.js from 7.1.0 to 7.3.0

Bumps [fuse.js](https://github.com/krisk/Fuse) from 7.1.0 to 7.3.0.
- [Release notes](https://github.com/krisk/Fuse/releases)
- [Changelog](https://github.com/krisk/Fuse/blob/main/CHANGELOG.md)
- [Commits](https://github.com/krisk/Fuse/compare/v7.1.0...v7.3.0)

---
updated-dependencies:
- dependency-name: fuse.js
  dependency-version: 7.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* 🎉 Improve fuse-search

* 📝 yarn lock

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ken <ken.aleksander@gmail.com>
2026-05-04 14:29:14 +02:00
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
106 changed files with 7812 additions and 6494 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
+6
View File
@@ -1,5 +1,11 @@
# @navikt/aksel-icons
## 8.10.5
## 8.10.4
## 8.10.3
## 8.10.2
## 8.10.1
@@ -13,7 +13,7 @@
"dependencies": {
"@navikt/ds-css": "*",
"@navikt/ds-react": "*",
"fuse.js": "^7.0.0",
"fuse.js": "^7.3.0",
"react": "19.2.5",
"react-dom": "19.2.5"
},
@@ -23,8 +23,8 @@
"@types/node": "^24.10.9",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "^5.1.0",
"typescript": "6.0.2",
"@vitejs/plugin-react": "^6.0.1",
"typescript": "6.0.3",
"vite": "^7.3.2",
"vite-plugin-singlefile": "^2.3.0",
"vite-plugin-svgr": "^4.2.0"
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-icons",
"version": "8.10.2",
"version": "8.10.5",
"author": "Aksel | Nav",
"homepage": "https://aksel.nav.no/ikoner",
"repository": {
@@ -88,8 +88,8 @@
"rehype-parse": "9.0.1",
"tsc-alias": "1.8.16",
"tsx": "^4.20.6",
"typescript": "6.0.2",
"typescript": "6.0.3",
"unified": "11.0.5",
"vitest": "4.1.4"
"vitest": "4.1.5"
}
}
+6
View File
@@ -1,5 +1,11 @@
# @navikt/aksel-stylelint
## 8.10.5
## 8.10.4
## 8.10.3
## 8.10.2
## 8.10.1
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-stylelint",
"version": "8.10.2",
"version": "8.10.5",
"type": "module",
"engines": {
"node": ">=18.12.0"
@@ -42,14 +42,14 @@
"dev": "yarn watch:lint"
},
"devDependencies": {
"@navikt/ds-css": "^8.10.2",
"@navikt/ds-tokens": "^8.10.2",
"@navikt/ds-css": "^8.10.5",
"@navikt/ds-tokens": "^8.10.5",
"concurrently": "9.2.1",
"postcss-selector-parser": "^7.1.0",
"postcss-value-parser": "^4.2.0",
"stylelint": "^17.2.0",
"stylelint-test-rule-node": "^1.0.0",
"tsx": "^4.20.6",
"typescript": "6.0.2"
"typescript": "6.0.3"
}
}
+24
View File
@@ -1,5 +1,29 @@
# @navikt/aksel
## 8.10.5
### Patch Changes
- Updated dependencies [[`35295e7`](https://github.com/navikt/aksel/commit/35295e7c010bd4866c3cd4ae9ff72641a234b089), [`0522dfa`](https://github.com/navikt/aksel/commit/0522dfae59320477b10cab2941d0b004d280d79a)]:
- @navikt/ds-css@8.10.5
- @navikt/ds-tokens@8.10.5
## 8.10.4
### Patch Changes
- Updated dependencies []:
- @navikt/ds-css@8.10.4
- @navikt/ds-tokens@8.10.4
## 8.10.3
### Patch Changes
- Updated dependencies [[`92f6d9e`](https://github.com/navikt/aksel/commit/92f6d9e93b879962df8415f7fad22203174dd3a8)]:
- @navikt/ds-css@8.10.3
- @navikt/ds-tokens@8.10.3
## 8.10.2
### Patch Changes
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel",
"version": "8.10.2",
"version": "8.10.5",
"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.5",
"@navikt/ds-tokens": "^8.10.5",
"chalk": "5.6.2",
"cli-progress": "3.12.0",
"clipboardy": "5.0.1",
@@ -59,8 +59,8 @@
"@types/is-git-clean": "1.1.2",
"@types/jscodeshift": "17.3.0",
"rimraf": "6.1.3",
"typescript": "6.0.2",
"vitest": "4.1.4"
"typescript": "6.0.3",
"vitest": "4.1.5"
},
"sideEffects": false,
"engines": {
+16
View File
@@ -1,5 +1,21 @@
# @navikt/ds-css
## 8.10.5
### Patch Changes
- Pagination: Fix selected-styling in forced-colors mode ([#4858](https://github.com/navikt/aksel/pull/4858))
- Button: Fix issue where secondary and tertiary variants did not have correct text color is certain cases ([#4857](https://github.com/navikt/aksel/pull/4857))
## 8.10.4
## 8.10.3
### Patch Changes
- Add module declaration to avoid TypeScript error when importing ([`92f6d9e`](https://github.com/navikt/aksel/commit/92f6d9e93b879962df8415f7fad22203174dd3a8))
## 8.10.2
### Patch Changes
+13 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-css",
"version": "8.10.2",
"version": "8.10.5",
"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.5",
"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/*"
}
}
}
+31 -6
View File
@@ -42,14 +42,22 @@
--__axc-button-border-color: var(--ax-border-default);
background-color: transparent;
color: var(--ax-text-subtle);
/* secondary + neutral should use "default" text color.
Check for data-color=neutral on both the button itself and the parents. */
&[data-color="neutral"],
[data-color="neutral"] > &:not([data-color]),
[data-color="neutral"] :not([data-color]) &:not([data-color]) {
[data-color="neutral"] &:not([data-color]) {
color: var(--ax-text-default);
}
/* secondary + NOT neutral should use "subtle" text color.
We need to explicitly target cases where a parent has data-color=neutral but
a closer parent has a different data-color (overriding the selector above). */
&,
[data-color="neutral"] [data-color]:not([data-color="neutral"]) & {
color: var(--ax-text-subtle);
}
&:hover {
--__axc-button-border-color: var(--ax-border-strong);
@@ -73,14 +81,22 @@
&[data-variant="tertiary"] {
background-color: transparent;
color: var(--ax-text-subtle);
/* tertiary + neutral should use "default" text color.
Check for data-color=neutral on both the button itself and the parents. */
&[data-color="neutral"],
[data-color="neutral"] > &:not([data-color], [data-pressed="true"]),
[data-color="neutral"] :not([data-color]) &:not([data-color], [data-pressed="true"]) {
[data-color="neutral"] &:not([data-color], [data-pressed="true"]) {
color: var(--ax-text-default);
}
/* tertiary + NOT neutral should use "subtle" text color.
We need to explicitly target cases where a parent has data-color=neutral but
a closer parent has a different data-color (overriding the selector above). */
&,
[data-color="neutral"] [data-color]:not([data-color="neutral"]) &:not([data-pressed="true"]) {
color: var(--ax-text-subtle);
}
&:hover {
background-color: var(--ax-bg-moderate-hoverA);
}
@@ -231,6 +247,15 @@
color: highlight;
box-shadow: none;
}
&[data-variant="tertiary"][data-pressed="true"] {
background-color: selecteditem;
color: selecteditemtext;
& span {
forced-color-adjust: none;
}
}
}
.aksel-button:not(:disabled):hover span {
+86 -7
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,14 @@
}
.aksel-data-table__filler-cell {
width: auto;
display: table-cell;
padding: 0;
width: auto;
}
.aksel-data-table__drag-and-drop-root {
padding: 0;
margin: 0;
}
.aksel-data-drag-and-drop__drag-handler__button {
@@ -469,12 +538,11 @@
}
.aksel-data-table__drag-and-drop-item {
background-color: var(--ax-bg-default);
&[data-overlay="true"] {
width: 100%;
opacity: 0.8;
box-shadow: var(--ax-shadow-dialog);
background-color: var(--ax-bg-default);
}
/* TODO - might be removable */
@@ -491,7 +559,7 @@
}
}
.aksel-data-drag-and-drop__drag-handler__alt,
.aksel-data-drag-and-drop__drag-handler__legacy,
.aksel-data-drag-and-drop__drag-handler {
position: relative;
height: 1.5rem;
@@ -593,3 +661,14 @@ html[data-dragging-cursor="true"] *::after {
display: flex;
align-items: center;
}
.aksel-data-table__details-panel-row {
box-shadow: inset 1px 1px 0 0 var(--ax-border-neutral-subtle);
}
.aksel-data-table__details-panel-row-cell {
&:focus-visible {
outline: 2px solid var(--ax-border-focus);
outline-offset: -4px;
}
}
+1
View File
@@ -0,0 +1 @@
declare module "@navikt/ds-css" {}
+30
View File
@@ -1,5 +1,35 @@
# @navikt/ds-react
## 8.10.5
### Patch Changes
- Dependencies: Bumped `@floating-ui/react` from v0.27.8 -> v0.27.19 ([#4851](https://github.com/navikt/aksel/pull/4851))
- Updated dependencies []:
- @navikt/ds-tokens@8.10.5
- @navikt/aksel-icons@8.10.5
## 8.10.4
### Patch Changes
- ActionMenu: Now locked to bottom/top side placement, left/right for sub-menus. ([#4843](https://github.com/navikt/aksel/pull/4843))
- Updated dependencies []:
- @navikt/ds-tokens@8.10.4
- @navikt/aksel-icons@8.10.4
## 8.10.3
### Patch Changes
- Radio, Checkbox: Fix regression where `className` was mistakenly passed to `input`-element ([#4837](https://github.com/navikt/aksel/pull/4837))
- Updated dependencies []:
- @navikt/ds-tokens@8.10.3
- @navikt/aksel-icons@8.10.3
## 8.10.2
### Patch Changes
+8 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-react",
"version": "8.10.2",
"version": "8.10.5",
"description": "React components from the Norwegian Labour and Welfare Administration.",
"author": "Aksel, a team part of the Norwegian Labour and Welfare Administration.",
"license": "MIT",
@@ -698,16 +698,16 @@
"clean": "rimraf cjs esm",
"build": "yarn i18n-jsdoc && concurrently \"tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json\" \"tsc -p tsconfig.esm.json && tsc-alias -p tsconfig.esm.json && yarn write-packagejson\" && yarn i18n-jsdoc --cleanup && yarn copy-types",
"watch": "tsc --watch -p tsconfig.esm.json",
"test": "concurrently \"TZ=UTC vitest run -c tests/vitest.config.ts\" \"yarn test-sb-build\"",
"test": "concurrently \"cross-env TZ=UTC vitest run -c tests/vitest.config.ts\" \"yarn test-sb-build\"",
"test-sb-build": "tsc -p tsconfig.sb.json",
"test:watch": "TZ=UTC vitest watch -c tests/vitest.config.ts",
"copy-types": "copyfiles -f ./src/types/theme.d.ts ./esm/types"
},
"dependencies": {
"@floating-ui/react": "0.27.8",
"@floating-ui/react": "0.27.19",
"@floating-ui/react-dom": "^2.1.8",
"@navikt/aksel-icons": "^8.10.2",
"@navikt/ds-tokens": "^8.10.2",
"@navikt/aksel-icons": "^8.10.5",
"@navikt/ds-tokens": "^8.10.5",
"date-fns": "^4.0.0",
"react-day-picker": "9.14.0"
},
@@ -723,6 +723,7 @@
"aksel": "workspace:^",
"concurrently": "9.2.1",
"copyfiles": "^2.4.1",
"cross-env": "^10.1.0",
"fast-glob": "3.3.3",
"jscodeshift": "17.3.0",
"jsdom": "27.1.0",
@@ -733,8 +734,8 @@
"swr": "^2.4.1",
"tsc-alias": "1.8.16",
"tsx": "^4.20.6",
"typescript": "6.0.2",
"vitest": "4.1.4"
"typescript": "6.0.3",
"vitest": "4.1.5"
},
"peerDependencies": {
"@types/react": "^17.0.30 || ^18 || ^19",
@@ -384,7 +384,7 @@ export const ActionMenuContent = forwardRef<
{...rest}
align={align}
sideOffset={4}
collisionPadding={10}
collisionPadding={5}
returnFocus={context.triggerRef}
safeZone={{
anchor: context.triggerRef.current,
@@ -184,126 +184,66 @@ export const ColorRole = () => (
</HStack>
<h3>data-color=neutral on wrapper and danger on button</h3>
<HStack gap="space-8" data-color="neutral">
<Button data-color="danger" variant="secondary" icon={<StarIcon />}>
<Button data-color="danger" variant="secondary">
Button
</Button>
<Button data-color="danger" variant="tertiary" icon={<StarIcon />}>
<Button data-color="danger" variant="tertiary">
Button
</Button>
<Button
data-color="danger"
variant="secondary"
icon={<StarIcon />}
disabled
>
<Button data-color="danger" variant="tertiary" data-pressed>
Pressed
</Button>
<Button data-color="danger" variant="secondary" disabled>
Button
</Button>
<Button
data-color="danger"
variant="tertiary"
icon={<StarIcon />}
disabled
>
<Button data-color="danger" variant="tertiary" disabled>
Button
</Button>
</HStack>
<h3>data-color=neutral on parent wrapper and danger on button</h3>
<div data-color="neutral">
<HStack gap="space-8">
<Button data-color="danger" variant="secondary" icon={<StarIcon />}>
Button
</Button>
<Button data-color="danger" variant="tertiary" icon={<StarIcon />}>
Button
</Button>
<Button
data-color="danger"
variant="secondary"
icon={<StarIcon />}
disabled
>
Button
</Button>
<Button
data-color="danger"
variant="tertiary"
icon={<StarIcon />}
disabled
>
Button
</Button>
</HStack>
</div>
<h3>data-color=neutral on button</h3>
<HStack gap="space-8">
<Button data-color="neutral" variant="secondary" icon={<StarIcon />}>
<Button data-color="neutral" variant="secondary">
Button
</Button>
<Button data-color="neutral" variant="tertiary" icon={<StarIcon />}>
<Button data-color="neutral" variant="tertiary">
Button
</Button>
<Button
data-color="neutral"
variant="secondary"
icon={<StarIcon />}
disabled
>
<Button data-color="neutral" variant="tertiary" data-pressed>
Pressed
</Button>
<Button data-color="neutral" variant="secondary" disabled>
Button
</Button>
<Button
data-color="neutral"
variant="tertiary"
icon={<StarIcon />}
disabled
>
<Button data-color="neutral" variant="tertiary" disabled>
Button
</Button>
</HStack>
<h3>data-color=neutral on wrapper</h3>
<HStack gap="space-8" data-color="neutral">
<Button variant="secondary" icon={<StarIcon />}>
<Button variant="secondary">Button</Button>
<Button variant="tertiary">Button</Button>
<Button variant="tertiary" data-pressed>
Pressed
</Button>
<Button variant="secondary" disabled>
Button
</Button>
<Button variant="tertiary" icon={<StarIcon />}>
Button
</Button>
<Button variant="secondary" icon={<StarIcon />} disabled>
Button
</Button>
<Button variant="tertiary" icon={<StarIcon />} disabled>
<Button variant="tertiary" disabled>
Button
</Button>
</HStack>
<h3>data-color=neutral on parent wrapper</h3>
<div data-color="neutral">
<HStack gap="space-8">
<Button variant="secondary" icon={<StarIcon />}>
Button
</Button>
<Button variant="tertiary" icon={<StarIcon />}>
Button
</Button>
<Button variant="secondary" icon={<StarIcon />} disabled>
Button
</Button>
<Button variant="tertiary" icon={<StarIcon />} disabled>
Button
</Button>
</HStack>
</div>
<h3>data-color=neutral on parent wrapper and danger on wrapper</h3>
<div data-color="neutral">
<HStack gap="space-8" data-color="danger">
<Button variant="secondary" icon={<StarIcon />}>
<Button variant="secondary">Button</Button>
<Button variant="tertiary">Button</Button>
<Button variant="tertiary" data-pressed>
Pressed
</Button>
<Button variant="secondary" disabled>
Button
</Button>
<Button variant="tertiary" icon={<StarIcon />}>
Button
</Button>
<Button variant="secondary" icon={<StarIcon />} disabled>
Button
</Button>
<Button variant="tertiary" icon={<StarIcon />} disabled>
<Button variant="tertiary" disabled>
Button
</Button>
</HStack>
@@ -311,16 +251,15 @@ export const ColorRole = () => (
<h3>data-color=neutral on both wrappers</h3>
<div data-color="neutral">
<HStack gap="space-8" data-color="neutral">
<Button variant="secondary" icon={<StarIcon />}>
<Button variant="secondary">Button</Button>
<Button variant="tertiary">Button</Button>
<Button variant="tertiary" data-pressed>
Pressed
</Button>
<Button variant="secondary" disabled>
Button
</Button>
<Button variant="tertiary" icon={<StarIcon />}>
Button
</Button>
<Button variant="secondary" icon={<StarIcon />} disabled>
Button
</Button>
<Button variant="tertiary" icon={<StarIcon />} disabled>
<Button variant="tertiary" disabled>
Button
</Button>
</HStack>
@@ -6,7 +6,7 @@ import {
} from "@navikt/aksel-icons";
import { Floating } from "../../../utils/components/floating/Floating";
export interface DataDragAndDropDragHandlerProps {
export interface DragAndDropDragHandlerLegacyProps {
/**
* Whether the drag handler is disabled
*/
@@ -24,18 +24,18 @@ export interface DataDragAndDropDragHandlerProps {
}
/**
* DataDragAndDropDragHandler
* DragAndDropDragHandlerLegacy
*
* A button component that serves as a drag handle for drag and drop operations.
* Can be used to initiate dragging of elements in a data table or list.
*/
export const DataDragAndDropDragHandler = React.forwardRef<
export const DragAndDropDragHandlerLegacy = React.forwardRef<
HTMLButtonElement,
DataDragAndDropDragHandlerProps
DragAndDropDragHandlerLegacyProps
>(({ keyboardDragging, handleRef, alt }) => {
if (alt) {
return (
<div className="aksel-data-drag-and-drop__drag-handler__alt">
<div className="aksel-data-drag-and-drop__drag-handler__legacy">
{keyboardDragging && (
<span
className="aksel-data-drag-and-drop__drag-handler__arrow"
@@ -3,10 +3,10 @@ import React, { useRef } from "react";
import { HStack } from "../../../primitives/stack";
import { cl } from "../../../utils/helpers";
import { useMergeRefs } from "../../../utils/hooks";
import { DataDragAndDropDragHandler } from "../drag-handler/DataDragAndDropDragHandler";
import { DataDragAndDropContext } from "../root/DataDragAndDrop.context";
import { DragAndDropDragHandlerLegacy } from "../drag-handler/DragAndDropDragHandlerLegacy";
import { DragAndDropLegacyContext } from "../root/DragAndDropLegacy.context";
interface DataDragAndDropItemProps extends React.HTMLAttributes<HTMLDivElement> {
interface DragAndDropItemLegacyProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
/**
* Unique id
@@ -21,17 +21,17 @@ interface DataDragAndDropItemProps extends React.HTMLAttributes<HTMLDivElement>
/**
* TODO
*
* @see 🏷️ {@link DataDragAndDropItemProps}
* @see 🏷️ {@link DragAndDropItemLegacyProps}
* @example
* ```tsx
* <DragAndDrop.Item numOfSelectedRows={selectedRows.length} onClear={handleClear}>
* <DragAndDropLegacy.Item numOfSelectedRows={selectedRows.length} onClear={handleClear}>
* TODO
* </DragAndDrop.Item>
* </DragAndDropLegacy.Item>
* ```
*/
const DataDragAndDropItem = React.forwardRef<
const DragAndDropItemLegacy = React.forwardRef<
HTMLDivElement,
DataDragAndDropItemProps
DragAndDropItemLegacyProps
>(({ children, id, index, className, ...rest }, forwardedRef) => {
const handleRef = useRef<HTMLDivElement>(null);
const { ref, isDragging, isDropTarget } = useSortable({
@@ -40,7 +40,7 @@ const DataDragAndDropItem = React.forwardRef<
handle: handleRef,
});
const mergedRef = useMergeRefs(ref, forwardedRef);
const context = React.useContext(DataDragAndDropContext);
const context = React.useContext(DragAndDropLegacyContext);
const mouseDragging = isDragging && context?.inputMethod === "mouse";
const mouseDropTarget = isDropTarget && context?.inputMethod === "mouse";
const keyboardDragging = isDragging && context?.inputMethod === "keyboard";
@@ -58,7 +58,7 @@ const DataDragAndDropItem = React.forwardRef<
data-drop-target={mouseDropTarget}
tabIndex={-1}
>
<DataDragAndDropDragHandler
<DragAndDropDragHandlerLegacy
handleRef={handleRef}
keyboardDragging={keyboardDragging}
alt
@@ -69,6 +69,6 @@ const DataDragAndDropItem = React.forwardRef<
);
});
export default DataDragAndDropItem;
export { DataDragAndDropItem };
export type { DataDragAndDropItemProps };
export default DragAndDropItemLegacy;
export { DragAndDropItemLegacy };
export type { DragAndDropItemLegacyProps };
@@ -1,11 +1,11 @@
import { createContext } from "react";
interface DataDragAndDropContextType {
interface DragAndDropContextLegacyType {
inputMethod: "mouse" | "keyboard" | null;
// setInputMethod: (method: "mouse" | "keyboard" | null) => void;
// setItems: React.Dispatch<React.SetStateAction<any[]>>;
}
export const DataDragAndDropContext = createContext<
DataDragAndDropContextType | undefined
export const DragAndDropLegacyContext = createContext<
DragAndDropContextLegacyType | undefined
>(undefined);
@@ -2,34 +2,32 @@ import { DragDropProvider, DragOverlay } from "@dnd-kit/react";
import { isSortable } from "@dnd-kit/react/sortable";
import React, { forwardRef, isValidElement } from "react";
import { VStack } from "../../../primitives/stack";
import DataDragAndDropItem, {
DataDragAndDropItemProps,
} from "../item/DataDragAndDropItem";
import { DataDragAndDropContext } from "./DataDragAndDrop.context";
import DragAndDropItemLegacy from "../item/DragAndDropItemLegacy";
import { DragAndDropLegacyContext } from "./DragAndDropLegacy.context";
interface DataDragAndDropProps extends React.HTMLAttributes<HTMLDivElement> {
interface DragAndDropLegacyProps extends React.HTMLAttributes<HTMLDivElement> {
children: any[];
setItems: React.Dispatch<React.SetStateAction<any[]>>;
}
interface DataDragAndDropRootComponent extends React.ForwardRefExoticComponent<
DataDragAndDropProps & React.RefAttributes<HTMLDivElement>
interface DragAndDropLegacyRootComponent extends React.ForwardRefExoticComponent<
DragAndDropLegacyProps & React.RefAttributes<HTMLDivElement>
> {
/**
* @see 🏷️ {@link DataDragAndDropItemProps}
* @see 🏷️ {@link DragAndDropItemLegacyProps}
* * @example
* ```jsx
* <DragAndDrop>
* <DragAndDrop.Item id="1" index={0}>
* <DragAndDropLegacy>
* <DragAndDropLegacy.Item id="1" index={0}>
* ...
* </DragAndDrop.Item>
* </DragAndDrop>
* </DragAndDropLegacy.Item>
* </DragAndDropLegacy>
* ```
*/
Item: typeof DataDragAndDropItem;
Item: typeof DragAndDropItemLegacy;
}
const DataDragAndDrop = forwardRef<HTMLDivElement, DataDragAndDropProps>(
const DragAndDropLegacy = forwardRef<HTMLDivElement, DragAndDropLegacyProps>(
({ setItems, children, ...rest }, forwardedRef) => {
const [inputMethod, setInputMethod] = React.useState<
"mouse" | "keyboard" | null
@@ -45,7 +43,7 @@ const DataDragAndDrop = forwardRef<HTMLDivElement, DataDragAndDropProps>(
};
return (
<DataDragAndDropContext.Provider value={{ inputMethod }}>
<DragAndDropLegacyContext.Provider value={{ inputMethod }}>
<DragDropProvider
// TODO Look into overriding default keybinds, might eliminate context need
onBeforeDragStart={(event) =>
@@ -84,13 +82,13 @@ const DataDragAndDrop = forwardRef<HTMLDivElement, DataDragAndDropProps>(
}}
</DragOverlay>
</DragDropProvider>
</DataDragAndDropContext.Provider>
</DragAndDropLegacyContext.Provider>
);
},
) as DataDragAndDropRootComponent;
) as DragAndDropLegacyRootComponent;
DataDragAndDrop.Item = DataDragAndDropItem;
DragAndDropLegacy.Item = DragAndDropItemLegacy;
export { DataDragAndDrop, DataDragAndDropItem };
export default DataDragAndDrop;
export type { DataDragAndDropItemProps, DataDragAndDropProps };
export { DragAndDropLegacy, DragAndDropItemLegacy };
export default DragAndDropLegacy;
export type { DragAndDropLegacyProps };
@@ -49,7 +49,7 @@ export const DragAndDropDragHandler = React.forwardRef<
<button
className="aksel-data-drag-and-drop__drag-handler__arrow"
data-direction="up"
onClick={() => context?.onKeyboardDragEnd(-1)}
onClick={() => context?.onKeyboardDragEnd(-1, itemLabel)}
onMouseDown={(e) => e.preventDefault()}
disabled={context?.dragHandlerActive?.index === 0}
type="button"
@@ -58,13 +58,7 @@ export const DragAndDropDragHandler = React.forwardRef<
</button>
)}
<button
// TODO - Bedre formulering av aria-label?
//aria-label={`Flytt element ${item.index + 1}. Trykk Enter eller Mellomrom for å aktivere, deretter piltastene for å flytte elementet.`}
aria-label={
active
? `Flytt element ${itemLabel || item.index + 1}. Bruk piltastene for å flytte elementet.`
: `Flytt element ${itemLabel || item.index + 1}. Trykk Enter eller Mellomrom for å aktivere flytting.`
}
aria-label={`${itemLabel}. Plass ${item.index + 1}.`}
aria-pressed={Boolean(active)}
aria-roledescription="draggable"
type="button"
@@ -77,18 +71,18 @@ export const DragAndDropDragHandler = React.forwardRef<
}}
onClick={(event) => {
if (!active) {
context?.setDragHandlerActive(item);
context?.onKeyboardDragStart(item);
event.currentTarget.focus();
} else {
context?.setDragHandlerActive(null);
context?.onKeyboardDragStart(null);
}
}}
onBlur={() => context?.setDragHandlerActive(null)}
onBlur={() => context?.cancelDrag()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
// Enter or space, currently active item - end keyboard dragging
event.preventDefault();
context?.setDragHandlerActive(active ? null : item);
context?.onKeyboardDragStart(active ? null : item);
return;
}
@@ -98,17 +92,18 @@ export const DragAndDropDragHandler = React.forwardRef<
// Cancel dragging
// TODO Handle reset
event.preventDefault();
context?.setDragHandlerActive(null);
context?.setAnnouncer(`Flytting av ${itemLabel} avbrutt.`);
context?.cancelDrag(true);
return;
} else if (event.key === "ArrowUp") {
// Move item up
event.preventDefault();
context?.onKeyboardDragEnd(-1);
context?.onKeyboardDragEnd(-1, itemLabel);
return;
} else if (event.key === "ArrowDown") {
// Move item down
event.preventDefault();
context?.onKeyboardDragEnd(1);
context?.onKeyboardDragEnd(1, itemLabel);
return;
}
}}
@@ -120,7 +115,7 @@ export const DragAndDropDragHandler = React.forwardRef<
className="aksel-data-drag-and-drop__drag-handler__arrow"
data-direction="down"
type="button"
onClick={() => context?.onKeyboardDragEnd(1)}
onClick={() => context?.onKeyboardDragEnd(1, itemLabel)}
onMouseDown={(e) => e.preventDefault()}
disabled={
context?.dragHandlerActive?.index === context?.itemAmount - 1
@@ -7,14 +7,16 @@ interface DragAndDropContextType {
dropTarget: DragAndDropElement | null;
setDropTarget: (id: DragAndDropElement | null) => void;
dragHandlerActive: DragAndDropElement | null;
setDragHandlerActive: (active: DragAndDropElement | null) => void;
onKeyboardDragEnd: (diff: number) => void;
onKeyboardDragStart: (active: DragAndDropElement | null) => void;
onKeyboardDragEnd: (diff: number, label: string) => void;
startPendingDrag: (
event: React.PointerEvent,
item: DragAndDropElement,
element?: HTMLElement | null,
) => void;
itemAmount: number;
cancelDrag: (resetOrder?: boolean) => void;
setAnnouncer: (message: string) => void;
}
export const {
@@ -15,10 +15,18 @@ import DragAndDropItem, { DragAndDropItemProps } from "../item/DragAndDropItem";
import { DragAndDropElement } from "../types";
import { DragAndDropProvider } from "./DragAndDrop.context";
interface DragAndDropProps<T> extends React.HTMLAttributes<HTMLUListElement> {
items: ColumnDefinitions<T>;
setItems: React.Dispatch<React.SetStateAction<ColumnDefinitions<T>>>;
renderItem: (item: ColumnDefinition<T>, index: number) => React.ReactNode;
interface DragAndDropProps<
T,
DetailsT extends Record<string, any>,
> extends React.HTMLAttributes<HTMLUListElement> {
items: ColumnDefinitions<T, DetailsT>;
setItems: React.Dispatch<
React.SetStateAction<ColumnDefinitions<T, DetailsT>>
>;
renderItem: (
item: ColumnDefinition<T, DetailsT>,
index: number,
) => React.ReactNode;
}
/**
@@ -28,7 +36,7 @@ interface DragAndDropProps<T> extends React.HTMLAttributes<HTMLUListElement> {
* [x] pointer over listener / state, onPointerEnter, onPointerLeave
* [x] Overlay - Use floating component
* [x] Keyboard navigation
* [ ] UU - announce on drag start, item moved, drag end
* [x] UU - announce on drag start, item moved, drag end
* [x] Make overlay same width as the OG item, currently jumps to content width
* [x] Look into adding a cancel listener event
* [x] Make onClick work on drag handler button, currently blocked by pointer down/up listeners
@@ -36,23 +44,28 @@ interface DragAndDropProps<T> extends React.HTMLAttributes<HTMLUListElement> {
* [x] Make arrow icons into buttons that react to keyboard events, currently just decorative
* [x] Keep handler focus after clicking arrows for dragging
* [x] Look into data-based API vs component-based API
* [ ] Should we have hidden instructions for screen readers on how to use the drag and drop, and should we announce the position of the item while dragging?
* [x] Should we have hidden instructions for screen readers on how to use the drag and drop, and should we announce the position of the item while dragging?
* [x] Discuss if this component should be generic for drag and drop, or if it should be specifically for tables - just for table for now
* [x] Discuss items type
* [ ] Discuss how to implement label best
* [x] Discuss how to implement label best
* [ ] Quick nav (< > samtidig) - få piltastene til å fungere
* [x] Implement new type for items - ColumnDefinitions<T>
* [ ] Remove announcer div and use a live region component instead
* [ ] Make ESC reset position, not just cancel dragging
* [ ] Make instructions for keyboard users (visible?)
* [x] Remove announcer div and use a live region component instead
* [x] Make ESC reset position, not just cancel dragging
* [x] Make instructions for keyboard users (visible?)
* [ ] Ask design about visible keyboard instructions
* [ ] Update design from Figma
* [ ] Look at instructions text
* [x] Remove default ul styling
* [x] Fix setItems type
*
*/
const DRAG_THRESHOLD = 4; // Minimum movement in pixels to start dragging
const SR_INSTRUCTIONS_ID = "drag-and-drop-instructions-id";
function DragAndDropInner<T>(
{ items, setItems, renderItem }: DragAndDropProps<T>,
function DragAndDropInner<T, DetailsT extends Record<string, any>>(
{ items, setItems, renderItem }: DragAndDropProps<T, DetailsT>,
forwardedRef: React.ForwardedRef<HTMLUListElement>,
) {
const [activeItem, setActiveItem] = useState<DragAndDropElement | null>(null);
@@ -60,7 +73,8 @@ function DragAndDropInner<T>(
const [dragHandlerActive, setDragHandlerActive] =
useState<DragAndDropElement | null>(null);
const [overlayWidth, setOverlayWidth] = useState<number | null>(null);
const [announcement, setAnnouncement] = useState("");
const [announcer, setAnnouncer] = useState("");
const initialItemsRef = useRef<ColumnDefinitions<T, DetailsT> | null>(null);
const activeData = items.find((item) => item.id === activeItem?.id);
const activeItemRef = useRef<DragAndDropElement | null>(null);
@@ -79,6 +93,19 @@ function DragAndDropInner<T>(
startY: number;
} | null>(null);
const saveInitialItems = useCallback(() => {
initialItemsRef.current = items;
}, [items]);
const keyboardDragStart = (item: DragAndDropElement | null) => {
if (item) {
saveInitialItems();
} else {
initialItemsRef.current = null;
}
setDragHandlerActive(item);
};
const startPendingDrag = (
event: React.PointerEvent,
item: DragAndDropElement,
@@ -121,6 +148,21 @@ function DragAndDropInner<T>(
[setItems],
);
const cancelDrag = useCallback(
(resetOrder = false) => {
if (resetOrder && initialItemsRef.current) {
setItems(initialItemsRef.current);
}
setOverlayWidth(null);
setDragHandlerActive(null);
setCombinedActiveItem(null);
setCombinedDropTarget(null);
pendingDragStartRef.current = null;
initialItemsRef.current = null;
},
[setItems, setCombinedActiveItem, setCombinedDropTarget],
);
useEffect(() => {
/* This useEffect is used to toggle a class on the html element when dragging,
to prevent cursor issues when dragging over interactive elements,
@@ -156,6 +198,7 @@ function DragAndDropInner<T>(
}
setOverlayWidth(element?.getBoundingClientRect().width ?? null);
saveInitialItems();
setCombinedActiveItem(pendingStart.item);
setCombinedDropTarget(pendingStart.item);
pendingDragStartRef.current = null;
@@ -221,25 +264,12 @@ function DragAndDropInner<T>(
if (active && target && active.id !== target.id) {
reorderItems(active.index, target.index);
setAnnouncement(
`Element flyttet til posisjon ${target?.index + 1} av ${items.length}`,
); // TODO - Bedre formulering?
}
setOverlayWidth(null);
setDragHandlerActive(null);
setCombinedActiveItem(null);
setCombinedDropTarget(null);
pendingDragStartRef.current = null;
cancelDrag();
};
const handlePointerCancel = () => {
setOverlayWidth(null);
setDragHandlerActive(null);
setCombinedActiveItem(null);
setCombinedDropTarget(null);
pendingDragStartRef.current = null;
};
const handlePointerCancel = () => cancelDrag(true);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
@@ -254,10 +284,11 @@ function DragAndDropInner<T>(
setCombinedDropTarget,
setCombinedActiveItem,
reorderItems,
items.length,
saveInitialItems,
cancelDrag,
]);
const onKeyboardDragEnd = (diff: number) => {
const onKeyboardDragEnd = (diff: number, label: string) => {
if (!dragHandlerActive) return;
const targetIndex = dragHandlerActive.index + diff;
@@ -265,11 +296,9 @@ function DragAndDropInner<T>(
return;
}
setAnnouncer(`${label}. Plass ${targetIndex + 1} av ${items.length}.`);
reorderItems(dragHandlerActive.index, targetIndex);
setDragHandlerActive({ ...dragHandlerActive, index: targetIndex });
setAnnouncement(
`Element flyttet til posisjon ${targetIndex + 1} av ${items.length}`,
); // TODO - Bedre formulering?
};
return (
@@ -279,12 +308,27 @@ function DragAndDropInner<T>(
dropTarget={dropTarget}
setDropTarget={setCombinedDropTarget}
dragHandlerActive={dragHandlerActive}
setDragHandlerActive={setDragHandlerActive}
onKeyboardDragStart={keyboardDragStart}
onKeyboardDragEnd={onKeyboardDragEnd}
startPendingDrag={startPendingDrag}
cancelDrag={cancelDrag}
setAnnouncer={setAnnouncer}
itemAmount={items.length}
>
<ul ref={forwardedRef} aria-label="Dra og slipp elementer">
<span id={SR_INSTRUCTIONS_ID} className="sr-only">
Bruk Tab for å fokusere på en kolonne. Trykk mellomrom eller enter for å
starte flytting, bruk piltastene for å flytte kolonnen, trykk mellomrom
eller enter for å slippe, eller Escape for å avbryte.
</span>
<div aria-live="assertive" className="sr-only" aria-atomic>
{announcer}
</div>
<ul
ref={forwardedRef}
aria-label="Kolonneinnstillinger"
aria-describedby={SR_INSTRUCTIONS_ID}
className="aksel-data-table__drag-and-drop-root"
>
{items.map((item, index) => {
return (
<DragAndDropItem
@@ -306,6 +350,7 @@ function DragAndDropInner<T>(
<Floating.Content
align="start"
updatePositionStrategy="always"
aria-hidden
style={{
pointerEvents: "none",
boxSizing: "border-box",
@@ -323,15 +368,15 @@ function DragAndDropInner<T>(
</Floating.Content>
</Floating>
)}
<div aria-live="assertive" className="sr-only" aria-atomic="true">
{announcement}
</div>
</DragAndDropProvider>
);
}
const DragAndDrop = forwardRef(DragAndDropInner) as <T>(
props: DragAndDropProps<T> & React.RefAttributes<HTMLUListElement>,
const DragAndDrop = forwardRef(DragAndDropInner) as <
T,
DetailsT extends Record<string, any>,
>(
props: DragAndDropProps<T, DetailsT> & React.RefAttributes<HTMLUListElement>,
) => React.ReactElement | null;
export { DragAndDrop, DragAndDropItem };
@@ -2,8 +2,3 @@ export interface DragAndDropElement {
id: string;
index: number;
}
export interface DragAndDropItemData {
id: string;
label: string;
}
@@ -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"
onRowClick={(rowId) => console.info("Row clicked!: ", rowId)}
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,108 @@ 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>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const { down, up, expectNodeFocus } = keyboardUtils();
const table = canvas.getByRole("table");
const toggleButton = canvas.getByRole("button", {
name: /toggle single row/i,
});
table.focus();
await down();
expectNodeFocus("Row 1");
await down();
expectNodeFocus("Custom row");
await userEvent.click(toggleButton);
table.focus();
await down();
expectNodeFocus("Row 1");
await down();
expectNodeFocus("Row 3");
await userEvent.click(toggleButton);
table.focus();
await down();
expectNodeFocus("Row 3");
await up();
expectNodeFocus("Custom row");
},
};
export const FocusElementInsideTable: Story = {
render: () => {
return (
<div style={{ padding: "4rem", display: "grid", gap: "2rem" }}>
<DataTable style={{ width: "100%" }} withKeyboardNav layout="auto">
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader>Col 1</DataTableColumnHeader>
<DataTableColumnHeader>Col 2</DataTableColumnHeader>
<DataTableColumnHeader>Col 3</DataTableColumnHeader>
<DataTableColumnHeader>Col 4</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
<DataTable.Tr>
<DataTable.Td>Col 1</DataTable.Td>
<DataTable.Td>
<button>Focusable button</button>
</DataTable.Td>
<DataTable.Th>Col 3</DataTable.Th>
<DataTable.Td>Col 4</DataTable.Td>
</DataTable.Tr>
type FocusRow = { id: 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 +398,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,30 @@ 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 DataDragAndDrop from "../drag-and-drop-old/root/DataDragAndDropRoot";
import { DataTable } from "../table";
import { BodyShort, Heading } from "../../typography";
import DragAndDropLegacy from "../drag-and-drop-legacy/root/DragAndDropLegacyRoot";
import DragAndDrop from "../drag-and-drop/root/DragAndDropRoot";
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,
@@ -183,10 +193,10 @@ export const KitchenSink: Story = {
>
Velg alle
</Switch>
<DataDragAndDrop setItems={setColumnOrder}>
<DragAndDropLegacy setItems={setColumnOrder}>
{table.getAllLeafColumns().map((column, index) => {
return (
<DataDragAndDrop.Item
<DragAndDropLegacy.Item
id={column.id}
index={index}
key={column.id}
@@ -203,10 +213,10 @@ export const KitchenSink: Story = {
>
{column.id}
</Switch>
</DataDragAndDrop.Item>
</DragAndDropLegacy.Item>
);
})}
</DataDragAndDrop>
</DragAndDropLegacy>
</VStack>
</VStack>
</HStack>
@@ -216,16 +226,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 +255,9 @@ export const KitchenSink: Story = {
header.column.getToggleSortingHandler();
handler?.(event);
}}
label={(
header.column.columnDef.header || ""
).toString()}
>
{header.isPlaceholder
? null
@@ -255,32 +268,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 +357,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 +382,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 +442,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 +470,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 +492,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 +529,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,57 +556,127 @@ 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) => {
<DragAndDrop
items={columnView}
setItems={setColumnView}
renderItem={(item, index) => {
return (
<DataDragAndDrop.Item
id={column.id}
index={index}
key={column.id}
<Switch
key={item.id}
size="small"
checked={item.details?.visible}
onChange={(event) => {
const newColumnView = [...columnView];
newColumnView[index] = {
...item,
details: {
...item.details,
visible: event.target.checked,
},
};
setColumnView(newColumnView);
}}
>
<Switch
key={column.id}
size="small"
checked={column.getIsVisible()}
onChange={(event) => {
const handler =
column.getToggleVisibilityHandler();
handler(event);
}}
>
{column.id}
</Switch>
</DataDragAndDrop.Item>
{item.label}
</Switch>
);
})}
</DataDragAndDrop>
}}
/>
</VStack>
</VStack>
</HStack>
@@ -599,84 +687,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 +721,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 +801,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
@@ -4,7 +4,7 @@ import { DragVerticalIcon } from "@navikt/aksel-icons";
import { Switch } from "../../form/switch";
import { HStack } from "../../primitives/stack";
import { Floating } from "../../utils/components/floating/Floating";
import DragAndDrop from "../drag-and-drop-old/root/DataDragAndDropRoot";
import DragAndDrop from "../drag-and-drop-legacy/root/DragAndDropLegacyRoot";
const meta: Meta<typeof DragAndDrop> = {
title: "ds-react/Data/OldDragAndDrop",
@@ -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,
@@ -109,6 +111,7 @@ const DataTableColumnHeader = forwardRef<
>
{sortable ? (
<button
type="button"
className="aksel-data-table__th-sort-button"
onClick={onSortClick}
>
@@ -138,11 +141,12 @@ const DataTableColumnHeader = forwardRef<
{resizeResult.enabled && !UNSAFE_isSelection && (
<button
{...resizeResult.resizeHandlerProps}
type="button"
className="aksel-data-table__th-resize-handle"
aria-label={
resizeResult.isResizingWithKeyboard
? "Bruk pil venstre/høyre"
: "Endre bredde"
: `Endre bredde ${label}`
} // TODO Translate
data-active={resizeResult.isResizingWithKeyboard}
data-disable-keyboard-nav={resizeResult.isResizingWithKeyboard}
@@ -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,57 @@
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}
className="aksel-data-table__details-panel-row-cell"
>
<div style={style}>{content}</div>
</td>
</tr>
);
}
export { DataTableDetailsPanelRow };
@@ -3,19 +3,21 @@ type TableRowEntryId = string | number;
type CollectTableRowEntriesArgs<T> = {
items: T[];
getRowId?: (rowData: T, index: number) => TableRowEntryId;
getSubRows?: (rowData: T) => T[];
isSubRowExpandable?: (rowData: T) => boolean;
getRows?: (rowData: T) => T[];
isRowExpandable?: (rowData: T) => boolean;
};
interface ItemDetail<T> {
id: string | number;
id: TableRowEntryId;
rowData: T;
level: number;
parent: null | T;
children: readonly T[];
parentId: TableRowEntryId | null;
children: readonly TableRowEntryId[];
}
type CollectTableRowEntriesReturn<T> = {
itemDetails: Map<T, ItemDetail<T>>;
itemDetails: Map<TableRowEntryId, ItemDetail<T>>;
rootRowIds: TableRowEntryId[];
/**
* Direct child ids for each row, used to traverse nested selection groups
* without storing every descendant list on each ancestor.
@@ -26,61 +28,65 @@ type CollectTableRowEntriesReturn<T> = {
function collectTableRowEntries<T>({
items,
getRowId,
getSubRows,
isSubRowExpandable,
getRows,
isRowExpandable,
}: CollectTableRowEntriesArgs<T>): CollectTableRowEntriesReturn<T> {
const itemDetailsMap = new Map<T, ItemDetail<T>>();
const itemDetailsMap = new Map<TableRowEntryId, ItemDetail<T>>();
const childRowIdsById = new Map<TableRowEntryId, TableRowEntryId[]>();
const rootRowIds: TableRowEntryId[] = [];
const traverseRow = (
rowData: T,
rowIndex: number,
level: number,
parent: T | null,
parentId?: TableRowEntryId,
parentRowId: TableRowEntryId | null,
): TableRowEntryId => {
const rowId =
getRowId?.(rowData, rowIndex) ??
(parentId == null ? rowIndex : `${parentId}-${rowIndex}`);
const isRowExpandable = isSubRowExpandable?.(rowData) ?? true;
const children = (isRowExpandable ? getSubRows?.(rowData) : []) ?? [];
const rowId = getRowId
? getRowId(rowData, rowIndex)
: getFallbackTableRowId(rowIndex, parentRowId);
itemDetailsMap.set(rowData, {
id: rowId,
level,
parent,
children,
});
const children =
((isRowExpandable?.(rowData) ?? true) ? getRows?.(rowData) : []) ?? [];
const childRowIds: TableRowEntryId[] = [];
for (let childIndex = 0; childIndex < children.length; childIndex++) {
const childRow = children[childIndex];
const childRowId = traverseRow(
childRow,
childIndex,
level + 1,
rowData,
rowId,
);
const childRowId = traverseRow(childRow, childIndex, level + 1, rowId);
childRowIds.push(childRowId);
}
itemDetailsMap.set(rowId, {
id: rowId,
rowData,
level,
parentId: parentRowId,
children: childRowIds,
});
childRowIdsById.set(rowId, childRowIds);
return rowId;
};
for (let rowIndex = 0; rowIndex < items.length; rowIndex++) {
traverseRow(items[rowIndex], rowIndex, 0, null);
rootRowIds.push(traverseRow(items[rowIndex], rowIndex, 0, null));
}
return {
itemDetails: itemDetailsMap,
rootRowIds,
childRowIdsById,
};
}
function getFallbackTableRowId(
rowIndex: number,
parentRowId: TableRowEntryId | null,
): string {
return parentRowId == null ? String(rowIndex) : `${parentRowId}.${rowIndex}`;
}
export { collectTableRowEntries };
export type {
CollectTableRowEntriesArgs,
@@ -121,16 +121,70 @@ function prepareCellFocus(cell: Element): HTMLElement | null {
/**
* Applies focus and scroll to an element.
*/
function applyFocusAndScroll(element: HTMLElement): void {
element.focus({
preventScroll: true,
});
function getStickyOffsets(element: HTMLElement): {
stickyOffsetStart: number;
stickyOffsetEnd: number;
stickyHeaderHeight: number;
} {
const table = element.closest(".aksel-data-table");
element.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "nearest",
});
if (!table) {
return {
stickyOffsetStart: 0,
stickyOffsetEnd: 0,
stickyHeaderHeight: 0,
};
}
const stickyHeader = table.querySelector<HTMLElement>(
`.aksel-data-table__tr[data-sticky="true"]`,
);
const stickyNodesStart = table.querySelectorAll<HTMLElement>(
`.aksel-data-table__column-header[data-sticky="start"]`,
);
const stickyNodesEnd = table.querySelectorAll<HTMLElement>(
`.aksel-data-table__column-header[data-sticky="end"]`,
);
return {
stickyOffsetStart: Array.from(stickyNodesStart).reduce(
(offset, node) => offset + node.getBoundingClientRect().width,
0,
),
stickyOffsetEnd: Array.from(stickyNodesEnd).reduce(
(offset, node) => offset + node.getBoundingClientRect().width,
0,
),
stickyHeaderHeight: stickyHeader?.getBoundingClientRect().height ?? 0,
};
}
function applyFocusAndScroll(element: HTMLElement): void {
const { stickyOffsetStart, stickyOffsetEnd, stickyHeaderHeight } =
getStickyOffsets(element);
const originalScrollMarginInline = element.style.scrollMarginInline;
const originalScrollMarginBlockStart = element.style.scrollMarginBlockStart;
element.style.scrollMarginInline = `${stickyOffsetStart}px ${stickyOffsetEnd}px`;
element.style.scrollMarginBlockStart = `${stickyHeaderHeight}px`;
try {
element.focus({
preventScroll: true,
});
element.scrollIntoView({
behavior: "auto",
block: "nearest",
inline: "nearest",
});
} finally {
element.style.scrollMarginBlockStart = originalScrollMarginBlockStart;
element.style.scrollMarginInline = originalScrollMarginInline;
}
}
/**
@@ -16,8 +16,7 @@ type NavigationAction =
/**
* Maps keyboard events to navigation actions.
* Supports arrow keys, Home/End (row navigation), Ctrl/Cmd+Home/End (table navigation),
* and PageUp/PageDown (multi-row navigation).
* Supports arrow keys, Home/End (row navigation), Ctrl/Cmd+Home/End (table navigation).
*/
function getNavigationAction(event: KeyboardEvent): NavigationAction | null {
const key = event.key;
@@ -1,115 +0,0 @@
import { act, renderHook } from "@testing-library/react";
import React from "react";
import { describe, expect, test, vi } from "vitest";
import {
DataTableExpansionProvider,
useDataTableExpansion,
} from "../useTableExpansion";
import { useTableItems } from "../useTableItems";
type TestRow = {
id: number;
subRows?: TestRow[];
};
function createWrapper(
options: {
onDetailsPanelChange?: (ids: (string | number)[]) => void;
isDetailsPanelExpandable?: (row: TestRow) => boolean;
} = {},
) {
const rows: TestRow[] = [{ id: 1, subRows: [{ id: 10 }] }, { id: 2 }];
return function Wrapper({ children }: { children: React.ReactNode }) {
const tableItems = useTableItems({
items: rows,
getRowId: (row) => row.id,
getSubRows: (row) => row.subRows ?? [],
});
return (
<DataTableExpansionProvider<TestRow>
itemDetails={tableItems.itemDetails}
getDetailsPanelContent={(row) => row.id}
isDetailsPanelExpandable={options.isDetailsPanelExpandable}
onDetailsPanelChange={options.onDetailsPanelChange}
>
{children}
</DataTableExpansionProvider>
);
};
}
describe("useTableExpansion", () => {
test("does not allow toggling rows that are not expandable", () => {
const onDetailsPanelChange = vi.fn();
const { result } = renderHook(() => useDataTableExpansion(), {
wrapper: createWrapper({
onDetailsPanelChange,
isDetailsPanelExpandable: (row) => row.id === 1,
}),
});
expect(result.current.isDetailsPanelExpandable(1)).toBe(true);
expect(result.current.isDetailsPanelExpandable(2)).toBe(false);
expect(result.current.isDetailsPanelExpandable(10)).toBe(false);
act(() => {
result.current.toggleExpansion(2);
});
expect(result.current.isExpanded(2)).toBe(false);
expect(onDetailsPanelChange).not.toHaveBeenCalled();
act(() => {
result.current.toggleExpansion(1);
});
expect(result.current.isExpanded(1)).toBe(true);
expect(onDetailsPanelChange).toHaveBeenCalledWith([1]);
});
test("expand all only expands expandable rows", () => {
const onDetailsPanelChange = vi.fn();
const { result } = renderHook(() => useDataTableExpansion(), {
wrapper: createWrapper({
onDetailsPanelChange,
isDetailsPanelExpandable: (row) => row.id === 1,
}),
});
act(() => {
result.current.toggleAll();
});
expect(result.current.isExpanded(1)).toBe(true);
expect(result.current.isExpanded(2)).toBe(false);
expect(result.current.isAllExpanded).toBe(true);
expect(onDetailsPanelChange).toHaveBeenCalledWith([1]);
});
test("expand all only targets top-level table items", () => {
const onDetailsPanelChange = vi.fn();
const { result } = renderHook(() => useDataTableExpansion(), {
wrapper: createWrapper({
onDetailsPanelChange,
}),
});
expect(result.current.isDetailsPanelExpandable(1)).toBe(true);
expect(result.current.isDetailsPanelExpandable(2)).toBe(true);
expect(result.current.isDetailsPanelExpandable(10)).toBe(false);
act(() => {
result.current.toggleAll();
});
expect(result.current.isExpanded(1)).toBe(true);
expect(result.current.isExpanded(2)).toBe(true);
expect(result.current.isExpanded(10)).toBe(false);
expect(onDetailsPanelChange).toHaveBeenCalledWith([1, 2]);
});
});
@@ -45,6 +45,12 @@ const fallbackRows: FallbackTestRow[] = [
},
];
const duplicatedRowObject: TestRow = {
id: "shared",
name: "Shared",
subRows: [{ id: "shared-child", name: "Child" }],
};
const getSubRows = (row: TestRow) => row.subRows ?? [];
const getVisibleIds = (rows: TestRow[]) => rows.map((row) => row.id);
@@ -59,16 +65,18 @@ describe("useTableItems", () => {
);
expect(getVisibleIds(result.current.items)).toEqual(["a", "b"]);
expect(result.current.itemDetails.get(plainRows[0])).toMatchObject({
expect(result.current.itemDetails.get("a")).toMatchObject({
id: "a",
rowData: plainRows[0],
level: 0,
parent: null,
parentId: null,
children: [],
});
expect(result.current.itemDetails.get(plainRows[1])).toMatchObject({
expect(result.current.itemDetails.get("b")).toMatchObject({
id: "b",
rowData: plainRows[1],
level: 0,
parent: null,
parentId: null,
children: [],
});
});
@@ -78,8 +86,10 @@ describe("useTableItems", () => {
useTableItems({
items: nestedRows,
getRowId: (row) => row.id,
getSubRows,
defaultExpandedSubRowIds: ["a"],
subRows: {
getRows: getSubRows,
defaultExpandedRowIds: ["a"],
},
}),
);
@@ -91,7 +101,7 @@ describe("useTableItems", () => {
useTableItems({
items: nestedRows,
getRowId: (row) => row.id,
getSubRows,
subRows: { getRows: getSubRows },
}),
);
@@ -100,12 +110,14 @@ describe("useTableItems", () => {
expect(result.current.childRowIdsById.get("b")).toEqual(["b1"]);
});
test("uses the same fallback root id to reveal child rows when getRowId is omitted", () => {
test("uses unique fallback ids to reveal child rows when getRowId is omitted", () => {
const { result } = renderHook(() =>
useTableItems({
items: fallbackRows,
getSubRows: (row) => row.subRows ?? [],
defaultExpandedSubRowIds: [0],
subRows: {
getRows: (row: any) => row.subRows ?? [],
defaultExpandedRowIds: ["0"],
},
}),
);
@@ -113,6 +125,7 @@ describe("useTableItems", () => {
"Parent",
"Child",
]);
expect(result.current.childRowIdsById.get("0")).toEqual(["0.0"]);
});
test("updates visible rows in depth-first order for controlled expanded ids", () => {
@@ -121,8 +134,10 @@ describe("useTableItems", () => {
useTableItems({
items: nestedRows,
getRowId: (row) => row.id,
getSubRows,
expandedSubRowIds: expandedIds,
subRows: {
getRows: getSubRows,
expandedRowIds: expandedIds,
},
}),
{
initialProps: { expandedIds: [] as (string | number)[] },
@@ -142,4 +157,35 @@ describe("useTableItems", () => {
"b1",
]);
});
test("tracks duplicated row objects by row id instead of object identity", () => {
const { result } = renderHook(() =>
useTableItems({
items: [duplicatedRowObject, duplicatedRowObject],
subRows: {
getRows: getSubRows,
defaultExpandedRowIds: ["0"],
},
}),
);
expect(result.current.visibleRowIds).toEqual(["0", "0.0", "1"]);
expect(getVisibleIds(result.current.items)).toEqual([
"shared",
"shared-child",
"shared",
]);
expect(result.current.itemDetails.get("0")).toMatchObject({
id: "0",
rowData: duplicatedRowObject,
parentId: null,
children: ["0.0"],
});
expect(result.current.itemDetails.get("1")).toMatchObject({
id: "1",
rowData: duplicatedRowObject,
parentId: null,
children: ["1.0"],
});
});
});
@@ -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,
};
}
@@ -14,7 +14,7 @@ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
});
const [activeCell, setActiveCell] = useState<Element | null>(null);
const activeCellRef = useValueAsRef(activeCell).current;
const activeCellRef = useValueAsRef(activeCell);
const observerRef = useRef<MutationObserver | null>(null);
useEffect(() => {
@@ -24,7 +24,8 @@ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
observerRef.current = new MutationObserver(() => {
gridCacheRef.current.dirty = true;
if (activeCellRef && !activeCellRef.isConnected) {
if (activeCellRef.current && !activeCellRef.current.isConnected) {
setActiveCell(null);
}
});
@@ -0,0 +1,185 @@
import React, { useCallback } from "react";
import { createStrictContext } from "../../../utils/helpers";
import { useControllableState } from "../../../utils/hooks";
import { useTableItemsContext } from "./useTableItems";
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;
toggleAll: () => void;
isAllExpanded: boolean;
getDetailsPanelContent?: (row: unknown) => React.ReactNode;
getDetailsPanelHeight?: (row: unknown) => number | "auto";
showExpandAll: boolean;
enableDetailsPanel: boolean;
};
const {
Provider: DataTableDetailsPanelContextProvider,
useContext: useDataTableDetailsPanel,
} = createStrictContext<DataTableDetailsPanelContextT>({
name: "DataTableDetailsPanelContext",
errorMessage:
"useDataTableDetailsPanel must be used within a DataTableDetailsPanelProvider.",
});
function DataTableDetailsPanelProvider<T>({
children,
detailsPanel = {},
}: { detailsPanel?: DetailsPanelProps<T> } & { children: React.ReactNode }) {
const {
expandedRowIds,
defaultExpandedRowIds = [],
onExpandedRowIdsChange,
getContent,
isRowExpandable,
getHeight,
showExpandAll = false,
} = detailsPanel;
const [expandedIds, setExpandedIds] = useControllableState({
value: expandedRowIds,
defaultValue: defaultExpandedRowIds,
onChange: onExpandedRowIdsChange,
});
/* TODO: False is just fallback until auto and root is merged */
const tableItemsContext = useTableItemsContext(false);
const { itemDetails } = tableItemsContext ?? {
itemDetails: new Map<
string | number,
{ rowData: T; id: string | number; level: number }
>(),
};
const expandableIds = React.useMemo(() => {
if (!getContent) {
return new Set<string | number>();
}
const ids = new Set<string | number>();
for (const { rowData, id, level } of itemDetails.values()) {
/* We only allow Master - Details pattern on top level rows */
if (level > 0) {
continue;
}
if (!isRowExpandable || isRowExpandable(rowData)) {
ids.add(id);
}
}
return ids;
}, [getContent, isRowExpandable, itemDetails]);
const isDetailsPanelExpandableById = useCallback(
(id: string | number) => expandableIds.has(id),
[expandableIds],
);
const isExpanded = useCallback(
(id: string | number) =>
isDetailsPanelExpandableById(id) && expandedIds.includes(id),
[expandedIds, isDetailsPanelExpandableById],
);
const toggleExpansion = useCallback(
(id: string | number) => {
if (!isDetailsPanelExpandableById(id)) {
return;
}
setExpandedIds((currentExpandedIds) =>
currentExpandedIds.includes(id)
? currentExpandedIds.filter((expandedId) => expandedId !== id)
: [...currentExpandedIds, id],
);
},
[isDetailsPanelExpandableById, setExpandedIds],
);
const isAllExpanded =
expandableIds.size > 0 &&
Array.from(expandableIds).every((key) => expandedIds.includes(key));
const toggleAll = useCallback(() => {
setExpandedIds(isAllExpanded ? [] : Array.from(expandableIds));
}, [expandableIds, isAllExpanded, setExpandedIds]);
return (
<DataTableDetailsPanelContextProvider
isExpanded={isExpanded}
isDetailsPanelExpandable={isDetailsPanelExpandableById}
toggleExpansion={toggleExpansion}
toggleAll={toggleAll}
isAllExpanded={isAllExpanded}
getDetailsPanelContent={
getContent as ((row: unknown) => React.ReactNode) | undefined
}
getDetailsPanelHeight={
getHeight as ((row: unknown) => number | "auto") | undefined
}
showExpandAll={showExpandAll}
enableDetailsPanel={!!getContent}
>
{children}
</DataTableDetailsPanelContextProvider>
);
}
function getDataTableDetailsPanelId(tableId: string, rowId: string | number) {
return `${tableId}-expansion-${rowId}`;
}
export {
DataTableDetailsPanelProvider,
getDataTableDetailsPanelId,
useDataTableDetailsPanel,
};
export type { DetailsPanelProps };
@@ -1,146 +0,0 @@
import React, { useCallback } from "react";
import { createStrictContext } from "../../../utils/helpers";
import { useControllableState } from "../../../utils/hooks";
import { useTableItemsContext } from "./useTableItems";
type DataTableExpansionContextT = {
isExpanded: (id: string | number) => boolean;
isDetailsPanelExpandable: (id: string | number) => boolean;
toggleExpansion: (id: string | number) => void;
toggleAll: () => void;
isAllExpanded: boolean;
getDetailsPanelContent?: (row: unknown) => React.ReactNode;
getDetailsPanelHeight?: (row: unknown) => number | "auto";
showExpandAll: boolean;
enableDetailsPanel: boolean;
};
const {
Provider: DataTableExpansionContextProvider,
useContext: useDataTableExpansion,
} = createStrictContext<DataTableExpansionContextT>({
name: "DataTableExpansionContext",
errorMessage:
"useDataTableExpansion must be used within a DataTableExpansionProvider.",
});
type TableExpansionOptions<T> = {
detailsPanelRowIds?: (string | number)[];
defaultDetailsPanelRowIds?: (string | number)[];
onDetailsPanelChange?: (ids: (string | number)[]) => void;
getDetailsPanelContent?: (row: T) => React.ReactNode;
isDetailsPanelExpandable?: (rowData: T) => boolean;
getDetailsPanelHeight?: (row: T) => number | "auto";
showExpandAll?: boolean;
};
function getDataTableExpansionId(tableId: string, rowId: string | number) {
return `${tableId}-expansion-${rowId}`;
}
function DataTableExpansionProvider<T>({
children,
detailsPanelRowIds,
defaultDetailsPanelRowIds = [],
onDetailsPanelChange,
getDetailsPanelContent,
isDetailsPanelExpandable,
getDetailsPanelHeight,
showExpandAll = false,
}: TableExpansionOptions<T> & { children: React.ReactNode }) {
const [expandedIds, setExpandedIds] = useControllableState({
value: detailsPanelRowIds,
defaultValue: defaultDetailsPanelRowIds,
onChange: onDetailsPanelChange,
});
/* TODO: False is just fallback until auto and root is merged */
const tableItemsContext = useTableItemsContext(false);
const { itemDetails } = tableItemsContext ?? {
itemDetails: new Map(),
};
const expandableIds = React.useMemo(() => {
if (!getDetailsPanelContent) {
return new Set<string | number>();
}
const ids = new Set<string | number>();
for (const [rowData, { id, level }] of itemDetails.entries()) {
/* We only allow Master - Details pattern on top level rows */
if (level > 0) {
continue;
}
if (!isDetailsPanelExpandable || isDetailsPanelExpandable(rowData)) {
ids.add(id);
}
}
return ids;
}, [getDetailsPanelContent, isDetailsPanelExpandable, itemDetails]);
const isDetailsPanelExpandableById = useCallback(
(id: string | number) => expandableIds.has(id),
[expandableIds],
);
const isExpanded = useCallback(
(id: string | number) =>
isDetailsPanelExpandableById(id) && expandedIds.includes(id),
[expandedIds, isDetailsPanelExpandableById],
);
const toggleExpansion = useCallback(
(id: string | number) => {
if (!isDetailsPanelExpandableById(id)) {
return;
}
setExpandedIds((currentExpandedIds) =>
currentExpandedIds.includes(id)
? currentExpandedIds.filter((expandedId) => expandedId !== id)
: [...currentExpandedIds, id],
);
},
[isDetailsPanelExpandableById, setExpandedIds],
);
const isAllExpanded =
expandableIds.size > 0 &&
Array.from(expandableIds).every((key) => expandedIds.includes(key));
const toggleAll = useCallback(() => {
setExpandedIds(isAllExpanded ? [] : Array.from(expandableIds));
}, [expandableIds, isAllExpanded, setExpandedIds]);
return (
<DataTableExpansionContextProvider
isExpanded={isExpanded}
isDetailsPanelExpandable={isDetailsPanelExpandableById}
toggleExpansion={toggleExpansion}
toggleAll={toggleAll}
isAllExpanded={isAllExpanded}
getDetailsPanelContent={
getDetailsPanelContent as
| ((row: unknown) => React.ReactNode)
| undefined
}
getDetailsPanelHeight={
getDetailsPanelHeight as ((row: unknown) => number | "auto") | undefined
}
showExpandAll={showExpandAll}
enableDetailsPanel={!!getDetailsPanelContent}
>
{children}
</DataTableExpansionContextProvider>
);
}
export {
DataTableExpansionProvider,
getDataTableExpansionId,
useDataTableExpansion,
};
@@ -7,56 +7,47 @@ 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) => TableRowEntryId;
subRows?: SubRowsProps<T>;
};
type useTableItemsReturn<T> = {
items: T[];
itemDetails: Map<T, ItemDetail<T>>;
itemDetails: Map<TableRowEntryId, ItemDetail<T>>;
/** Row ids for the rows currently rendered in the table body. */
visibleRowIds: TableRowEntryId[];
/** Direct child ids for each row, used to traverse selection groups lazily. */
childRowIdsById: Map<TableRowEntryId, TableRowEntryId[]>;
onExpandedSubRowIdsChange: (id: string | number) => void;
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(
@@ -66,32 +57,35 @@ function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
const { itemDetails, visibleItems, visibleRowIds, childRowIdsById } =
useMemo(() => {
const { itemDetails: rowEntriesMap, childRowIdsById: _childRowIdsById } =
collectTableRowEntries({
items,
getRowId,
getSubRows,
isSubRowExpandable,
});
const {
itemDetails: rowEntriesMap,
rootRowIds,
childRowIdsById: _childRowIdsById,
} = collectTableRowEntries({
items,
getRowId,
getRows,
isRowExpandable,
});
const localVisibleItems: T[] = [];
const localVisibleRowIds: TableRowEntryId[] = [];
const addVisibleRows = (rowData: T): TableRowEntryId[] => {
const details = rowEntriesMap.get(rowData);
const addVisibleRows = (rowId: TableRowEntryId): TableRowEntryId[] => {
const details = rowEntriesMap.get(rowId);
if (!details) {
return [];
}
localVisibleItems.push(rowData);
localVisibleItems.push(details.rowData);
localVisibleRowIds.push(details.id);
const visibleDescendantRowIds: TableRowEntryId[] = [];
if (expandedIdsSet.has(details.id)) {
for (const childRow of details.children) {
const childVisibleRowIds = addVisibleRows(childRow);
for (const childRowId of details.children) {
const childVisibleRowIds = addVisibleRows(childRowId);
visibleDescendantRowIds.push(...childVisibleRowIds);
}
}
@@ -99,8 +93,8 @@ function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
return [details.id, ...visibleDescendantRowIds];
};
for (const rowData of items) {
addVisibleRows(rowData);
for (const rowId of rootRowIds) {
addVisibleRows(rowId);
}
return {
@@ -109,7 +103,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,20 +121,18 @@ function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
itemDetails,
visibleRowIds,
childRowIdsById,
onExpandedSubRowIdsChange: handleExpandedSubRowIdChange,
onExpandedRowIdsChange: handleExpandedSubRowIdChange,
isSubRowExpanded: (id: string | number) => expandedIdsSet.has(id),
};
}
const { Provider: TableItemsProvider, useContext: useTableItemsContext } =
/* TODO: Can we type this better? */
createStrictContext<
Omit<useTableItemsReturn<any>, "visibleRowIds" | "childRowIdsById">
>({
createStrictContext<Omit<useTableItemsReturn<any>, "childRowIdsById">>({
name: "TableItemsContext",
errorMessage:
"useTableItemsContext must be used within a TableItemsProvider",
});
export { useTableItems, TableItemsProvider, useTableItemsContext };
export type { ItemDetail };
export type { ItemDetail, SubRowsProps };
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { useEventCallback } from "../../../utils/hooks";
import { focusInitialTableTarget } from "../helpers/table-cell";
import { focusCellAndUpdateTabIndex } from "../helpers/table-focus";
import { focusCell, focusCellAndUpdateTabIndex } from "../helpers/table-focus";
import {
findFirstCell,
findFirstCellInRow,
@@ -137,6 +137,10 @@ function useTableKeyboardNav({
const target = event.target as Element | null;
if (tableRef && target === tableRef) {
if (activeCell) {
focusCell(activeCell);
return;
}
focusInitialTableTarget(tableRef);
return;
}
@@ -173,7 +177,7 @@ function useTableKeyboardNav({
return {
/* Table should only have tabIndex until the focus is moved inside and is enabled */
tabIndex: enabled ? (activeCell ? undefined : 0) : undefined,
tabIndex: enabled ? 0 : undefined,
setTableRef,
};
}
@@ -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,25 +7,28 @@ 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.
*
* Should be used for cells that act as row headers. Each row should have one rowheader, and only have one cell with `isRowHeader: true`,
*
* TODO: Not implemented
* - Add a generic tablecell component that can render either a td or th based on context or this prop.
*/
isRowHeader?: boolean;
/**
@@ -43,9 +48,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) => 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>
> {
/**
* @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;
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,
});
const tableItems = useTableItems({
items: data,
getRowId,
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}
visibleRowIds={tableItems.visibleRowIds}
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(
function observeAndUpdateStickyStyles() {
const tableWrapperElement = tableWrapperRef.current;
if (!tableWrapperElement) {
return;
}
const handleResize = () => scheduleStickyStylesUpdate();
window.addEventListener("resize", handleResize);
let resizeObserver: ResizeObserver | undefined;
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(tableWrapperElement);
if (tableRef.current) {
resizeObserver.observe(tableRef.current);
}
}
scheduleStickyStylesUpdate();
return () => {
window.removeEventListener("resize", handleResize);
resizeObserver?.disconnect();
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
},
[scheduleStickyStylesUpdate],
);
return (
<div className="aksel-data-table__border-wrapper">
<div ref={tableWrapperRef} className="aksel-data-table__scroll-wrapper">
<Slot
tabIndex={tabIndex}
/* @ts-expect-error Ref is not typed correctly to handle this case */
ref={mergedTableRefs}
data-scroll={applyStickyStyles ? "true" : undefined}
>
{children}
</Slot>
</div>
</div>
);
}
interface DataTableTBodyContentProps {
loadingState: React.ReactNode;
loadingLabel: string;
loadingRows?: number;
emptyState: React.ReactNode;
}
function DataTableTBodyContent({
loadingState,
loadingRows,
loadingLabel,
emptyState,
}: DataTableTBodyContentProps) {
const { items, itemDetails, visibleRowIds } = 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, rowIndex) => {
const rowId = visibleRowIds[rowIndex];
const details = rowId != null ? itemDetails.get(rowId) : undefined;
/* Should in theory be impossible. Look about typing this? */
if (!details) {
return null;
}
const hasSubRows = details.children.length > 0;
return (
<React.Fragment key={details.id}>
<DataTableTr rowId={details.id}>
{columns.map(({ isSticky, 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,96 @@
### Component: DataTable
**Primitive type:** Data table with optional selection, sorting, nested rows, details panels, sticky columns, column resizing, and keyboard cell navigation.
**Reference libraries:** React Aria Table / Adobe Spectrum TableView, MUI Data Grid, Base UI table patterns. Cloudscape Table is a useful secondary reference for resize/details behavior.
### Launch Review Findings
1. **The auto API has no visible caption slot or caption prop.**
Consumers can still pass `aria-label`/`aria-labelledby` through native table props, but `children?: never` means the preview API cannot render a native `caption` or a built-in visible summary/title. Mature table APIs usually provide at least one clear accessible naming path.
2. **Column resizing is present, but the preview API still reads as unfinished.**
Resizing is effectively opt-out in fixed layout because `DataTableColumnHeader` defaults `resizable` to `true`. The interaction also still has unfinished accessibility and i18n details, and `onWidthChange` currently fires for every width step while dragging.
3. **Critical behaviors are under-tested for a preview launch.**
Existing tests cover selection trees, item expansion state, and low-level keyboard helpers, but not the end-to-end behaviors users will notice first: sorting, details panels, loading states, row click behavior, column resizing, and selection accessibility.
### Feature Gap Table
| # | Feature | Status | Priority | Reference | Notes |
| --- | --------------------------------------------------- | ---------- | --------------- | ---------------------------- | ----------------------------------------------------------------------- |
| 1 | Arrow/Home/End keyboard cell navigation | ✅ Present | — | React Aria, MUI | Good base coverage in helper tests. |
| 2 | Controlled/uncontrolled sorting | ✅ Present | — | React Aria, MUI | Supports controlled and uncontrolled sort arrays. |
| 3 | Multi-column sorting | ✅ Present | — | MUI, Spectrum | Shift-click is supported. |
| 4 | Controlled/uncontrolled row selection | ✅ Present | — | React Aria, MUI | Single and multiple selection both exist. |
| 5 | Controlled/uncontrolled nested row expansion | ✅ Present | — | MUI, Spectrum | `subRows` API is already solid for preview. |
| 6 | Controlled/uncontrolled details panel expansion | ✅ Present | — | MUI | Good baseline, limited to top-level rows by design. |
| 7 | Loading overlay / loading rows / empty state | ✅ Present | — | MUI, Spectrum | Baseline states exist. |
| 9 | Visible caption / built-in accessible naming path | ❌ Missing | 🔴 Expected | React Aria, Spectrum | Auto API cannot render `caption`; users only get raw HTML attrs. |
| 13 | Opt-in / polished column resize API | ⚠️ Partial | 🟡 Valuable | MUI, Cloudscape | Feature exists, but defaults and a11y polish are not preview-ready yet. |
| 14 | Per-row disabled selection callback | ❌ Missing | 🟡 Valuable | React Aria, MUI | Current API only accepts disabled keys, not row-driven logic. |
| 15 | Row-level prop/className callback | ❌ Missing | 🟡 Valuable | MUI | No way to attach row-specific attrs/classes without custom rendering. |
| 16 | Header-group / multi-row header support in auto API | ❌ Missing | 🟢 Nice-to-have | React Aria, MUI | Current auto API is intentionally flat. |
| 17 | Accessor shorthand for simple columns | ❌ Missing | 🟢 Nice-to-have | MUI, TanStack-style patterns | Would reduce boilerplate for simple data tables. |
| 18 | Typeahead row navigation | ❌ Missing | 🟢 Nice-to-have | React Aria | Useful on large data sets, not necessary for first preview. |
| 19 | RTL-aware sticky/nesting layout | ❌ Missing | 🟢 Nice-to-have | React Aria, MUI | Current sticky/nesting CSS is mostly physical, not logical. |
### Implementation Notes
#### 9. Visible caption / built-in accessible naming path
- **What it does:** Lets consumers provide a native table caption or a clearly supported naming API without dropping to raw HTML attributes.
- **Reference behavior:** React Aria tables require an accessible label; Spectrum and MUI also expose a clear naming path.
- **Approach here:** Keep `children` forbidden, but add a `caption?: React.ReactNode` prop that renders a native `caption`. Optionally add `captionProps` for styling hooks.
- **Props/API additions needed:** `caption?: React.ReactNode`, optionally `captionProps?: React.HTMLAttributes<HTMLTableCaptionElement>`.
#### 13. Opt-in / polished column resize API
- **What it does:** Makes resize behavior predictable and avoids surprising every preview consumer with advanced header affordances.
- **Reference behavior:** MUI and Cloudscape treat column resize as an explicit feature and spend a lot of API surface on labels, limits, and commit behavior.
- **Approach here:** Default `resizable` to `false`, add missing slider aria metadata, and only call `onWidthChange` on commit or with a small debounce. Also move hard-coded Norwegian labels into the same i18n pattern as the rest of the component.
- **Props/API additions needed:** No new public props required for a first pass. The main change is default behavior and polish.
#### 14. Per-row disabled selection callback
- **What it does:** Lets consumers disable selection from row data instead of precomputing key arrays.
- **Reference behavior:** Many mature table APIs allow row-driven selection rules.
- **Approach here:** Resolve disabled state during row collection so selection helpers can still work with ids internally.
- **Props/API additions needed:** `isRowSelectionDisabled?: (rowData: T) => boolean`.
#### 15. Row-level prop/className callback
- **What it does:** Lets consumers add row-specific classes, test hooks, aria hooks, and state styling without replacing the table implementation.
- **Reference behavior:** MUI exposes row-class callbacks and row metadata hooks.
- **Approach here:** Apply callbacks when rendering `DataTableTr` so the row id and row data are both available.
- **Props/API additions needed:** `getRowProps?: (rowData: T, rowId: string | number) => React.HTMLAttributes<HTMLTableRowElement>` or a narrower `getRowClassName` if you want to keep the API tighter.
### Internal Refactors
1. Split the current root into a small orchestration hook plus presentational sub-components. `DataTableRoot.tsx` currently owns state setup, context assembly, loading/empty rendering, row rendering, and sticky-wrapper logic in one file.
2. Replace object-keyed `itemDetails` lookups with id-keyed row entries. This fixes the duplicate-object bug and simplifies details-panel and selection wiring.
3. Unify loading-state presence checks. `DataTableTBodyContent` uses nullish checks for `loadingState`, while `showLoadingOverlay` in the root uses truthiness checks; those should match.
4. Make localization an explicit seam. The component currently hard-codes visible and aria labels such as sort-resize instructions, expand/collapse labels, and selection labels directly in render code.
5. Revisit the resize default and commit path before large-table performance work. Continuous `onWidthChange` callbacks can easily push rerenders through the whole table if consumers control widths externally.
6. Decide whether the preview API is a plain table or the start of a data-grid abstraction. A few current seams, especially keyboard navigation and selection, are already grid-like, while captioning and row-header semantics still assume a plain table model.
### Test Gaps Before Preview
1. Sorting interaction story/test covering click, keyboard activation, and multi-sort.
2. Details-panel story/test covering expand, collapse, `aria-controls`, and controlled mode.
3. Loading/empty-state tests covering overlay vs skeleton vs custom loading content.
4. Row-click tests covering selection interaction, text-selection guard, and interactive-content guard.
5. Resize tests covering keyboard resize, pointer resize, and form embedding.
6. Accessibility test coverage for selection labels and row-header semantics once those are implemented.
### Suggested Order For Follow-up Work
1. Fix selection labeling and row-header semantics together.
2. Add a built-in caption/naming API.
3. Harden resize defaults and button semantics.
4. Refactor row metadata to be id-based.
5. Fill the missing user-facing stories/tests around sort, details, loading, and row click.
@@ -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;
+6
View File
@@ -1,5 +1,11 @@
# @navikt/ds-tailwind
## 8.10.5
## 8.10.4
## 8.10.3
## 8.10.2
## 8.10.1
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tailwind",
"version": "8.10.2",
"version": "8.10.5",
"description": "Tailwind config based on @navikt/ds-tokens",
"author": "Aksel | Nav designsystem team",
"keywords": [
@@ -27,11 +27,11 @@
},
"homepage": "https://aksel.nav.no/grunnleggende/kode/tailwind/",
"devDependencies": {
"@navikt/ds-tokens": "^8.10.2",
"@navikt/ds-tokens": "^8.10.5",
"tailwindcss": "^3.3.3",
"tsx": "^4.20.6",
"typescript": "6.0.2",
"vitest": "4.1.4"
"typescript": "6.0.3",
"vitest": "4.1.5"
},
"publishConfig": {
"access": "public",
+6
View File
@@ -1,5 +1,11 @@
# @navikt/ds-tokens
## 8.10.5
## 8.10.4
## 8.10.3
## 8.10.2
## 8.10.1
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tokens",
"version": "8.10.2",
"version": "8.10.5",
"description": "Design-tokens for Nav designsystem",
"author": "Aksel | Nav designsystem team",
"keywords": [
@@ -41,8 +41,8 @@
"lodash": "4.18.1",
"style-dictionary": "^4.1.1",
"tsx": "^4.20.6",
"typescript": "6.0.2",
"vitest": "4.1.4"
"typescript": "6.0.3",
"vitest": "4.1.5"
},
"publishConfig": {
"access": "public",
+28
View File
@@ -1,5 +1,33 @@
# Changelog
## 8.10.5
### @navikt/ds-react
- Dependencies: Bumped `@floating-ui/react` from v0.27.8 -> v0.27.19 ([#4851](https://github.com/navikt/aksel/pull/4851))
### @navikt/ds-css
- Pagination: Fix selected-styling in forced-colors mode ([#4858](https://github.com/navikt/aksel/pull/4858))
- Button: Fix issue where secondary and tertiary variants did not have correct text color is certain cases ([#4857](https://github.com/navikt/aksel/pull/4857))
## 8.10.4
### @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.5",
"@navikt/aksel-icons": "^8.10.5",
"@navikt/ds-css": "^8.10.5",
"@navikt/ds-react": "^8.10.5",
"@navikt/ds-tailwind": "^8.10.5",
"@navikt/ds-tokens": "^8.10.5"
}
}
+1 -1
View File
@@ -16,7 +16,7 @@
"playroom": "^1.2.1",
"style-loader": "^4.0.0",
"tsx": "^4.20.6",
"typescript": "6.0.2"
"typescript": "6.0.3"
},
"dependencies": {
"react": "19.2.5"
@@ -63,6 +63,7 @@ function fuseSearch(icons: AkselIcon[]) {
{ name: "variant", weight: 1 },
],
shouldSort: false,
useTokenSearch: true,
});
}
@@ -152,7 +152,6 @@ export const ShowMore = ({
behavior: "instant",
block: "start",
});
// eslint-disable-next-line react-hooks/set-state-in-effect
setShouldScroll(false);
}
}
+35 -11
View File
@@ -100,9 +100,9 @@ export type Ds_propsReference = {
export type Props_seksjon = {
_type: "props_seksjon";
title?: string;
komponenter?: Array<{
title?: string;
heading_level?: "3" | "4";
overridable?: boolean;
propref?: Ds_propsReference;
_type: "komponent";
@@ -3717,9 +3717,9 @@ export type KOMPONENT_BY_SLUG_QUERY_RESULT = {
| {
_key: string;
_type: "props_seksjon";
title?: string;
komponenter: Array<{
title?: string;
heading_level?: "3" | "4";
overridable?: boolean;
propref: {
_id: string;
@@ -4740,9 +4740,9 @@ export type GRUNNLEGGENDE_BY_SLUG_QUERY_RESULT = {
| {
_key: string;
_type: "props_seksjon";
title?: string;
komponenter: Array<{
title?: string;
heading_level?: "3" | "4";
overridable?: boolean;
propref: {
_id: string;
@@ -6321,9 +6321,9 @@ export type ENDRINGSLOGG_QUERY_RESULT = Array<{
| {
_key: string;
_type: "props_seksjon";
title?: string;
komponenter: Array<{
title?: string;
heading_level?: "3" | "4";
overridable?: boolean;
propref: {
_id: string;
@@ -7113,9 +7113,9 @@ export type ENDRINGSLOGG_WITH_NEIGHBORS_QUERY_RESULT = {
| {
_key: string;
_type: "props_seksjon";
title?: string;
komponenter: Array<{
title?: string;
heading_level?: "3" | "4";
overridable?: boolean;
propref: {
_id: string;
@@ -7900,9 +7900,9 @@ export type ENDRINGSLOGG_WITH_NEIGHBORS_QUERY_RESULT = {
| {
_key: string;
_type: "props_seksjon";
title?: string;
komponenter: Array<{
title?: string;
heading_level?: "3" | "4";
overridable?: boolean;
propref: {
_id: string;
@@ -8687,9 +8687,9 @@ export type ENDRINGSLOGG_WITH_NEIGHBORS_QUERY_RESULT = {
| {
_key: string;
_type: "props_seksjon";
title?: string;
komponenter: Array<{
title?: string;
heading_level?: "3" | "4";
overridable?: boolean;
propref: {
_id: string;
@@ -12211,9 +12211,9 @@ export type ALL_KOMPONENTS_MARKDOWN_QUERY_RESULT = Array<{
| {
_key: string;
_type: "props_seksjon";
title?: string;
komponenter: Array<{
title?: string;
heading_level?: "3" | "4";
overridable?: boolean;
propref: {
_id: string;
@@ -13018,9 +13018,9 @@ export type ALL_GRUNNLEGGENDE_MARKDOWN_QUERY_RESULT = Array<{
| {
_key: string;
_type: "props_seksjon";
title?: string;
komponenter: Array<{
title?: string;
heading_level?: "3" | "4";
overridable?: boolean;
propref: {
_id: string;
@@ -14575,9 +14575,9 @@ export type KOMPONENT_BY_SLUG_MARKDOWN_QUERY_RESULT = {
| {
_key: string;
_type: "props_seksjon";
title?: string;
komponenter: Array<{
title?: string;
heading_level?: "3" | "4";
overridable?: boolean;
propref: {
_id: string;
@@ -15382,9 +15382,9 @@ export type GRUNNLEGGENDE_BY_SLUG_MARKDOWN_QUERY_RESULT = {
| {
_key: string;
_type: "props_seksjon";
title?: string;
komponenter: Array<{
title?: string;
heading_level?: "3" | "4";
overridable?: boolean;
propref: {
_id: string;
@@ -16294,6 +16294,29 @@ 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, example, params, return } } }
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;
example: string | null;
params: Array<string> | null;
return: 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 +16365,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 example,\n params,\n return\n }\n }\n }': COMPONENT_PROPS_QUERY_RESULT;
}
}
@@ -35,6 +35,7 @@ async function fuseGlobalSearch(query: string) {
includeMatches: true,
threshold: 0.18,
distance: 50,
useTokenSearch: true,
});
const fuseResults = fuse
@@ -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>
);
@@ -1,3 +1,4 @@
import { stegaClean } from "next-sanity";
import { BodyShort, Box, Heading } from "@navikt/ds-react";
import type { AkselColorRole } from "@navikt/ds-tokens/types";
import { ExtractPortableComponentProps } from "@/app/_sanity/types";
@@ -13,9 +14,9 @@ type PropsSeksjonComponentT = NonNullable<
>[number];
function PropsSeksjon(props: ExtractPortableComponentProps<"props_seksjon">) {
const { komponenter, title } = props.value;
const { komponenter } = props.value;
if (!komponenter || komponenter.length === 0 || !title) {
if (!komponenter || komponenter.length === 0) {
return null;
}
@@ -62,7 +63,7 @@ function PropTable({ component }: { component: PropsSeksjonComponentT }) {
<div lang="en" data-block-margin="space-28" className={styles.propsSeksjon}>
<Heading
size="xsmall"
level="3"
level={stegaClean(component.heading_level) || "3"}
className={styles.propsSeksjonHeader}
id={component._key}
>
@@ -86,55 +87,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 +113,7 @@ const PropEntry = ({
<PropsSeksjonDescription
description={prop.description}
params={prop.params}
returnVal={prop.return}
/>
<PropsSeksjonCode code={prop.example} title="Example" />
</Box>
@@ -167,10 +136,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;
}
@@ -4,9 +4,9 @@ import { buildMarkdown } from "@/app/api/markdown/helpers/build-markdown";
function PropsSeksjonMarkdown(
data: ExtractPortableMarkdownComponentProps<"props_seksjon">,
) {
const { komponenter, title } = data.value;
const { komponenter } = data.value;
if (!komponenter || komponenter.length === 0 || !title) {
if (!komponenter || komponenter.length === 0) {
return "";
}
+16 -17
View File
@@ -30,31 +30,30 @@
"cp:assets": "yarn cp:playroom && yarn cp:public && yarn cp:static"
},
"dependencies": {
"@floating-ui/react": "0.27.8",
"@navikt/analytics-types": "0.0.7",
"@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",
"fuse.js": "^7.0.0",
"fuse.js": "^7.3.0",
"isomorphic-dompurify": "2.26.0",
"jscodeshift": "17.3.0",
"lodash": "4.18.1",
"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 +67,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 +87,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",
@@ -106,14 +105,14 @@
"babel-plugin-react-compiler": "^1.0.0",
"concurrently": "9.2.1",
"copyfiles": "^2.4.1",
"cross-env": "^10.0.0",
"cross-env": "^10.1.0",
"jsdom": "27.1.0",
"json5": "2.2.1",
"pino-pretty": "^10.0.0",
"rss": "^1.2.2",
"showdown": "^2.1.0",
"tsx": "^4.20.6",
"typescript": "6.0.2",
"vitest": "4.1.4"
"typescript": "6.0.3",
"vitest": "4.1.5"
}
}
+17 -7
View File
@@ -387,13 +387,6 @@
"value": "props_seksjon"
}
},
"title": {
"type": "objectAttribute",
"value": {
"type": "string"
},
"optional": true
},
"komponenter": {
"type": "objectAttribute",
"value": {
@@ -408,6 +401,23 @@
},
"optional": true
},
"heading_level": {
"type": "objectAttribute",
"value": {
"type": "union",
"of": [
{
"type": "string",
"value": "3"
},
{
"type": "string",
"value": "4"
}
]
},
"optional": true
},
"overridable": {
"type": "objectAttribute",
"value": {
@@ -1,5 +1,6 @@
import { defineField, defineType } from "sanity";
import { BulletListIcon } from "@navikt/aksel-icons";
import type { Props_seksjon } from "@/app/_sanity/query-types";
export const PropsSeksjon = defineType({
title: "Props",
@@ -7,13 +8,6 @@ export const PropsSeksjon = defineType({
type: "object",
icon: () => <BulletListIcon aria-hidden />,
fields: [
defineField({
title: "Tittel (h2)",
name: "title",
type: "string",
initialValue: "Props",
readOnly: true,
}),
defineField({
title: "Props",
type: "array",
@@ -25,20 +19,28 @@ export const PropsSeksjon = defineType({
name: "komponent",
fields: [
{
title: "Komponent navn",
description: "Slik man ville brukt den, eks Accordion.Item",
title: "Komponentnavn",
description: "Slik man ville brukt den, f.eks. Accordion.Item",
type: "string",
name: "title",
},
defineField({
title: "Overstyr overskriftsnivå",
name: "heading_level",
type: "string",
options: {
list: ["3", "4"],
},
}),
{
title: "Bruker komponenten OverridableComponent API-et",
title: "Komponenten bruker OverridableComponent API-et",
type: "boolean",
name: "overridable",
initialValue: false,
},
{
name: "propref",
title: "Komponent referanse",
title: "Komponent-referanse",
type: "reference",
to: [{ type: "ds_props" }],
},
@@ -48,9 +50,17 @@ export const PropsSeksjon = defineType({
}),
],
preview: {
prepare() {
select: {
komponenter: "komponenter",
},
prepare({ komponenter }) {
return {
title: "Props",
subtitle: (komponenter as Props_seksjon["komponenter"])
?.map((k) =>
k.heading_level ? `${k.title} (h${k.heading_level})` : k.title,
)
.join(", "),
media: () => <BulletListIcon aria-hidden />,
};
},
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more