Compare commits

..
Author SHA1 Message Date
Ken d97d72a76e 🎉 Add submenu closing 2026-06-01 11:45:05 +02:00
Ken e56d4a98d3 🚚 Rename utility 2026-06-01 11:22:01 +02:00
Ken bd070ae7f5 ♻️ update selector 2026-06-01 10:41:17 +02:00
Ken ec03db9c34 🐛 Rename story 2026-06-01 09:47:52 +02:00
Ken b17104b58e 🎉 Use data- attrb 2026-06-01 09:47:03 +02:00
Ken c8c41fd619 ♻️ Refactor togglegroup descendant use 2026-06-01 08:55:29 +02:00
Ken f28e57d383 🔥 Avoid using descendant context 2026-05-31 20:40:45 +02:00
Ken b50aa783e5 🐛 Properly rename file 2026-05-31 11:16:37 +02:00
Ken e48430d8c7 🎉 Refactor chips 2026-05-31 11:16:25 +02:00
Ken dd9a271a66 🐛 Add note 2026-05-31 11:06:07 +02:00
Ken 6db29cf2bd 🐛 Update skill 2026-05-31 11:05:14 +02:00
Ken 8e061d19be 🎉 Created skill for refactor 2026-05-31 10:59:52 +02:00
Ken 36a81bff7f [Icons] Update test-description 2026-05-30 15:16:26 +02:00
Ken 50a833f6c8 [Icons] 🐛 Fix metadata type definition 2026-05-30 15:10:05 +02:00
github-actions[bot] 9f0b850579 Version Packages (#4910)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-29 15:38:35 +02:00
Halvor Haugan cd4443e7cb [website] Adjust examples of Box and Bleed (#4914) 2026-05-29 14:09:07 +02:00
Halvor Haugan 23157ec0b5 [website] Add label and desc to FileUpload.Trigger example (#4913) 2026-05-29 14:06:54 +02:00
Ken 15d54f3f59 [Sanity] Use named exportDataset import for backup 2026-05-29 11:14:50 +02:00
Ken da9bab40b0 [Dismissablelayer] Focus edgecase (#4909)
* 🐛 Make sure Dismissablelayer handles focus-events when clicking inside container

* 📝 Changeset

* 🧪 Added tests
2026-05-29 10:31:43 +02:00
Ken c5e3f42c4f [LLM] Optimize instructions
[LLM] More optimizations
2026-05-29 09:48:38 +02:00
Halvor Haugan 54634ffed9 Tokens: Add types for token_docs (#4912) 2026-05-28 15:21:16 +02:00
df95714326 [DataGrid] Column display-prop (#4908)
* 🎉 Added columnDisplay setting

* 📝 Changeset

* 🐛 Handle silent error

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* 🐛 Re add closing tag

* Update @navikt/core/react/src/data/stories/Data.data-prop.stories.tsx

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

* Update @navikt/core/react/src/data/stories/Data.data-prop.stories.tsx

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

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2026-05-28 14:15:25 +02:00
Halvor Haugan 79444a7485 DataGrid.Table: Fix auto-width for col with "show nested rows" button (#4906) 2026-05-28 10:03:13 +02:00
Ken a375059cdb [DataGrid] 🐛 Loadingbar now follows sticky header properly (#4897)
* 🐛 Loadingbar now follows sticky header properly

* 💄 Fix loading-bar in safari, removed opacity transition

* ♻️ Removed unused data-sticky attrb on rows
2026-05-28 09:33:44 +02:00
Halvor Haugan 819c68e3a7 useControllableState: More precise type for setter (#4907) 2026-05-27 21:10:03 +02:00
dependabot[bot]andHalvor Haugan 354cc81223 Bump playwright from 1.59.1 to 1.60.0 (#4904)
* Bump playwright from 1.59.1 to 1.60.0

Bumps [playwright](https://github.com/microsoft/playwright) from 1.59.1 to 1.60.0.
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.59.1...v1.60.0)

---
updated-dependencies:
- dependency-name: playwright
  dependency-version: 1.60.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

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

* sync versions

* sync versions 2

* version sync 3

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Halvor Haugan <halvor.haugan@nav.no>
2026-05-26 21:41:12 +02:00
dependabot[bot]andHalvor Haugan 9f5c345386 Bump @biomejs/biome from 2.4.10 to 2.4.15 (#4905)
* Bump @biomejs/biome from 2.4.10 to 2.4.15

Bumps [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) from 2.4.10 to 2.4.15.
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.15/packages/@biomejs/biome)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

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

* migrate

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Halvor Haugan <halvor.haugan@nav.no>
2026-05-26 21:00:05 +02:00
dependabot[bot] adad5b4214 Bump @changesets/cli from 2.29.7 to 2.31.0 (#4903)
Bumps [@changesets/cli](https://github.com/changesets/changesets) from 2.29.7 to 2.31.0.
- [Release notes](https://github.com/changesets/changesets/releases)
- [Commits](https://github.com/changesets/changesets/compare/@changesets/cli@2.29.7...@changesets/cli@2.31.0)

---
updated-dependencies:
- dependency-name: "@changesets/cli"
  dependency-version: 2.31.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-26 20:36:18 +02:00
Halvor Haugan 7ed5890dae [website] Make Stepper "wizard" example interactive 2026-05-26 15:48:28 +02:00
Halvor Haugan 0b31d893f6 [website] Adjust Select error example 2026-05-26 15:47:26 +02:00
c273939376 Bump react/react-dom from 19.2.5 to 19.2.6 (#4876)
* Bump react-dom in the react group across 1 directory

Bumps the react group with 1 update in the / directory: [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom).


Updates `react-dom` from 19.2.5 to 19.2.6
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.6/packages/react-dom)

---
updated-dependencies:
- dependency-name: react-dom
  dependency-version: 19.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: react
...

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

* 📝 Sync react-versions

* 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>
Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
Co-authored-by: Halvor Haugan <halvor.haugan@nav.no>
2026-05-26 12:17:05 +02:00
Anniken Granberg 92fa6410b7 DataTable: Small cleanup to drag and drop (#4899)
* DataTable: Small improvements to drag and drop

* changeset

* pr comments
2026-05-26 09:33:33 +02:00
Halvor Haugan c2f8cc28cd [website] Update descs for some primitives examples (#4898) 2026-05-22 18:29:57 +02:00
Ken d3620d4d15 [Website] DataGrid demo-update (#4895)
* [Website] Removed redundant column from DataGrid demo

* [Website] Demo-data refactor

* 🎉 Nested demo-data

* 🐛 Dynamic rng number

* 🔥 removed withNesting type

* 📝 Inline data-generation

* 📝 rename RNG -> randomnumbergenerator
2026-05-22 14:09:13 +02:00
Ken fd06b8caf6 [Analyzer] Bundle analyzer treeshake (#4894)
* 🐛 Better treeshaking of icon-package

* 📝 Lock rolldown to v1

* 📝 sync rolldown versions
2026-05-22 13:44:45 +02:00
Halvor Haugan f323321cb4 DataGridTable: remove todo 2026-05-22 09:16:50 +02:00
Ken 5a29f39e16 [Website] Re-order data-grid examples 2026-05-21 15:28:14 +02:00
github-actions[bot] 27e221d1be Version Packages (#4888)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-21 14:47:20 +02:00
Halvor Haugan b89dbce45b [DataGrid] Fix docgen (#4892) 2026-05-21 14:43:42 +02:00
Anniken Granberg 3d4f94d0fe DataTable: Change padding values and changed rowDensity variant naming 2026-05-21 14:01:22 +02:00
Halvor Haugan 0a1b110efc DataGridTable: Add todos 2026-05-21 13:45:18 +02:00
Halvor Haugan 6576b43734 [website] Use ShowMore in Preview note (#4891) 2026-05-21 13:43:30 +02:00
KenandHalvor Haugan a84c86e9a0 [DataGrid] Examples for website (#4889)
* 🎉 Added initial example for data-grid

* 🎉 Extract data-generation to separate util

* 🔥 removed old comment

* 🎉 Demo layout

* ♻️ Reduce height

* 📝 Re-order columns

* 🎉 Added more demos

* 🎉 Added loading and empty-state stories

* 🔥 Removed isloading from sorting

* 🎉 Fixed performance issues

* 🐛 Make sortable columnheader static when table is loading

* 🐛 Removed isLoading-prop from sorting-demo

* 🐛 Make onRowAction return rowData

* 📝 Updated examples with best practices

* 📝 Reduce line-count

* 📝 Use vstack

* 📝 Rename row-action

* Update aksel.nav.no/website/pages/eksempler/data-grid/row-click.tsx

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

* Update aksel.nav.no/website/pages/eksempler/data-grid/sorting.tsx

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

* 🐛 Improve layout for nested example

* ♿ A11y improvements

* Update aksel.nav.no/website/components/website-modules/examples/__parts/DataGridDemoData.tsx

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

---------

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2026-05-21 13:27:39 +02:00
KenandHalvor Haugan dc5299b1f9 [DataGrid] Release Preview 🎉 (#4885)
* 🎉 Ready component for release

* 🐛 Removed old import of table-css

* 📝 Rename to PREVIEW

---------

Co-authored-by: Halvor Haugan <halvor.haugan@nav.no>
2026-05-20 13:13:11 +02:00
Ken 80c2458c66 [DataGrid] Added todo for columnheader sortable-area 2026-05-20 13:12:11 +02:00
Ken 96b6aed396 [DataGrid] Sync namespace types 2026-05-20 12:47:55 +02:00
Halvor Haugan baaf495ee6 [DataGrid] Fix typos 2026-05-20 12:46:15 +02:00
KenandCopilot Autofix powered by AI 76e04106f7 [Icons] New icon Newsletter (#4887)
* 🎉 Added new icon NewsLetter

* 📝 changeset

* Potential fix for pull request finding

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

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-20 12:43:53 +02:00
Ken 67574283ca [DataGrid] Make keyboard-nav default enabled 2026-05-20 12:38:06 +02:00
Ken a74d2a75bb [DataGrid] Update namespace columns def 2026-05-20 12:23:27 +02:00
Ken ed0e6dbfd7 [DataGrid] Update Namespaces 2026-05-20 12:01:45 +02:00
Ken 4bb83d5e6f [DataGrid] Rename onRowClick -> onRowAction 2026-05-20 11:47:57 +02:00
Ken 5e43726f08 [DataGrid] Update selection API 2026-05-20 11:47:57 +02:00
Ken bde3d18341 [DataGrid] Rename sorting API 2026-05-20 11:47:57 +02:00
KenandHalvor Haugan 6700e5789a [Website] 🎉 Added custom preview-card block (#4886)
* 🎉 Added custom preview-card block

* 🐛 Fix unpacking of link-references

* fix type error

---------

Co-authored-by: Halvor Haugan <halvor.haugan@nav.no>
2026-05-20 11:29:58 +02:00
Halvor Haugan dbeaddd186 [DataGrid] JSDoc adjustments and misc 2026-05-20 09:55:44 +02:00
Halvor Haugan c6d98539d9 DataGridTable: Fix sticky header 2026-05-19 20:01:49 +02:00
Halvor Haugan ebbff6520c [DataGrid] Rename DataTableProps -> DataGridTableProps 2026-05-19 19:59:24 +02:00
Ken ca7f40e1e5 [LLM] 🎉 Added docs-route for structured content (#4846)
* 🎉 Added docs-route for structured content

* ♻️ Extracted and re-used helpers

* ♻️ Refactor route handler
2026-05-19 15:40:27 +02:00
dependabot[bot] dbe6b4ba43 Bump postcss from 8.4.31 to 8.5.14 (#4880)
Bumps [postcss](https://github.com/postcss/postcss) from 8.4.31 to 8.5.14.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.4.31...8.5.14)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 15:34:26 +02:00
dependabot[bot] ae4f8c372b Bump webpack-dev-server from 5.2.3 to 5.2.4 (#4884)
Bumps [webpack-dev-server](https://github.com/webpack/webpack-dev-server) from 5.2.3 to 5.2.4.
- [Release notes](https://github.com/webpack/webpack-dev-server/releases)
- [Changelog](https://github.com/webpack/webpack-dev-server/blob/main/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-dev-server/compare/v5.2.3...v5.2.4)

---
updated-dependencies:
- dependency-name: webpack-dev-server
  dependency-version: 5.2.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 15:33:26 +02:00
Ken 05a60ded83 [DataGrid] Bug: Make sure header cells have unique ids in case of multiple tables 2026-05-19 14:46:29 +02:00
Ken f8dc76d751 [DataGrid] Added namespace exports for props 2026-05-19 14:43:32 +02:00
Halvor Haugan a942e2585a [DataGrid] DataTableColumnHeader: SR improvements 2026-05-19 14:38:30 +02:00
Ken f98b44a037 [DataGrid] Fix props not giving error in stories 2026-05-19 13:54:05 +02:00
Ken 72b75c8edf [DataGrid] Optimize sort callback and resize-observing 2026-05-19 13:32:41 +02:00
Ken 831111bbd0 [DataGrid] Better types for stickyColumns 2026-05-19 13:32:41 +02:00
Ken bb5c02e07e [DataGrid] Optimized rendering 2026-05-19 13:32:41 +02:00
Ken fce759d17f [DataGrid] Refactor Settings name 2026-05-19 13:32:41 +02:00
Ken a005f4528b [DataGrid] Refactor Settings API 2026-05-19 13:32:40 +02:00
Ken 67d07f24a5 [DataGrid] Move settings 2026-05-19 13:32:40 +02:00
Ken cc38262dda [DataGrid] Move isLoading to root, rename loading -> loadingContent 2026-05-19 13:32:40 +02:00
Ken caf68104ee [DataGrid] Moved selectionTrigger prop to table itself 2026-05-19 13:32:40 +02:00
Ken 39c075be69 [DataGrid] Moved selection-prop to grid root 2026-05-19 13:32:40 +02:00
Halvor Haugan a6b434d46a DataGrid: Fix resize test 2026-05-19 12:21:14 +02:00
Halvor Haugan 2d77db8053 [DataGrid] Column width: Rename default -> defaultValue 2026-05-19 12:16:07 +02:00
Halvor Haugan 32a7038a10 DataGrid.Table: make sure all console warnings are prefixed 2026-05-19 11:57:45 +02:00
Halvor Haugan c0285b8950 DataTable: improved auto-width logic 2026-05-19 11:40:12 +02:00
Halvor Haugan 39106714d1 DataTable: ActionMenu in kitchen sink story 2026-05-19 11:29:27 +02:00
Halvor Haugan 3e789ce63d DataTable: overflow:hidden on cells 2026-05-19 11:25:44 +02:00
Halvor Haugan 1fe4d23504 Remove vitest.setup.ts (#4883) 2026-05-19 09:47:21 +02:00
Halvor Haugan 4fb8c14891 ⬆️ Upgrade next from 16.2.3 to 16.2.6 (#4882) 2026-05-19 09:47:16 +02:00
Halvor Haugan 61e1d1b012 Remove unused package cross-zip-cli 2026-05-19 09:32:38 +02:00
Halvor Haugan 15a96bb39b ⬆️ Bump uuid to latest patch versions 2026-05-18 19:42:30 +02:00
dependabot[bot] 566e1dcf3c Bump @babel/plugin-transform-modules-systemjs from 7.29.0 to 7.29.4 (#4872)
Bumps [@babel/plugin-transform-modules-systemjs](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-modules-systemjs) from 7.29.0 to 7.29.4.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.4/packages/babel-plugin-transform-modules-systemjs)

---
updated-dependencies:
- dependency-name: "@babel/plugin-transform-modules-systemjs"
  dependency-version: 7.29.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 19:37:46 +02:00
Halvor Haugan 84441246ac DataTable: remove TODOs out of JSDoc 2026-05-18 19:17:25 +02:00
Halvor Haugan 2986f265d8 DataTable: fix bug 2026-05-18 19:13:49 +02:00
Halvor Haugan e400caef2f [DataTable] Rename textAlign -> align 2026-05-18 19:03:49 +02:00
Halvor Haugan 73d4ed3104 DataTable: Rename header -> headerCell, label -> header 2026-05-18 18:57:18 +02:00
Halvor Haugan 2591ae4385 DataGridRoot.context.ts: RowDef -> RowT 2026-05-18 18:41:32 +02:00
Halvor Haugan 692340b9b2 Adjust workflow names 2026-05-18 16:51:45 +02:00
Halvor Haugan a37f514eac DataTable: Remove magic tag/button CSS 2026-05-18 16:23:44 +02:00
Halvor Haugan 815ff7c55f [website] Adjust desc on primitives examples (#4874) 2026-05-18 16:06:25 +02:00
dependabot[bot] 5dbf9d3f16 Bump fast-uri from 3.1.0 to 3.1.2 (#4873)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 16:01:12 +02:00
dependabot[bot] ffe22d2bf6 Bump @vitest/eslint-plugin from 1.6.16 to 1.6.17 in the vitest group (#4877)
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.16 to 1.6.17
- [Release notes](https://github.com/vitest-dev/eslint-plugin-vitest/releases)
- [Commits](https://github.com/vitest-dev/eslint-plugin-vitest/compare/v1.6.16...v1.6.17)

---
updated-dependencies:
- dependency-name: "@vitest/eslint-plugin"
  dependency-version: 1.6.17
  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>
2026-05-18 15:59:48 +02:00
dependabot[bot] a2912b928b Bump figlet from 1.10.0 to 1.11.0 (#4878)
Bumps [figlet](https://github.com/patorjk/figlet.js) from 1.10.0 to 1.11.0.
- [Release notes](https://github.com/patorjk/figlet.js/releases)
- [Commits](https://github.com/patorjk/figlet.js/compare/v1.10.0...v1.11.0)

---
updated-dependencies:
- dependency-name: figlet
  dependency-version: 1.11.0
  dependency-type: direct:production
  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-18 15:59:01 +02:00
Ken 2a62921f7c [DataGrid] Rename Types and root context 2026-05-18 15:57:51 +02:00
Ken 0387e38e20 [DataGrid] Moved DataTable tests 2026-05-18 15:57:51 +02:00
Ken fc0fab5b60 [DataGrid] removed old legacy-implementation 2026-05-18 15:57:51 +02:00
Ken 329c59f4cd [DataGrid] Avoid extending div-element 2026-05-18 15:57:50 +02:00
Ken a9b7c16b89 [DataGrid] Rename table root -> DataGridTable 2026-05-18 15:57:50 +02:00
Ken 06178182e2 [DataGrid] Move data-grid dir 2026-05-18 15:57:50 +02:00
Ken f2bce6c3db [DataGrid] Add table as sub-component 2026-05-18 15:57:50 +02:00
github-actions[bot] ee976548c7 Version Packages (#4868)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-18 15:09:49 +02:00
KenandHalvor Haugan 34b28ca9cd [Website] 💄 Added preview-tag (#4869)
* 💄 Added preview-tag

* 🐛 Fix sidebar type error, change color to meta-purple

* Update aksel.nav.no/website/components/website-modules/StatusTag.tsx

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

---------

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2026-05-18 15:09:36 +02:00
KenandHalvor Haugan 4fb29fa9a9 [DataTable] Table -> DataGrid wrapper (#4879)
* 🎉 Set up DataGrid root

* 🎉 Moved data props to grid-component

* Update @navikt/core/react/src/data/data-grid/root/DataGridRoot.context.ts

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

* Update @navikt/core/react/src/data/data-grid/root/DataGridRoot.tsx

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

* 📝 Doc changes

---------

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2026-05-18 14:45:53 +02:00
Halvor Haugan 30f687ba6c DataTable: Fix auto-resize issue 2026-05-18 13:24:09 +02:00
Ken 6ce7ce7d4b [DataTable] Fixed ref-issue for keyboard-nav 2026-05-18 12:11:41 +02:00
Halvor Haugan 154f12f84e DataTable: Type/jsdoc adjustments for detailsPanel and subRows 2026-05-18 11:20:42 +02:00
Anniken Granberg aea6430c9f DataTable: Fix row density for firefox 2026-05-18 11:06:35 +02:00
Halvor Haugan 2b9f450028 [DataTable] Root: Adjust JSDoc 2026-05-18 10:34:08 +02:00
Halvor Haugan b46949eb56 [DataTable] Root: fix docgen, adjust jsdoc 2026-05-18 09:03:05 +02:00
Halvor Haugan 137ddc1841 DataTable: Fix auto resize story test (#4875) 2026-05-15 16:41:15 +02:00
Halvor Haugan 19dc8cadb6 DataTable: fix auto resize test 2026-05-15 14:03:58 +02:00
Halvor Haugan 336503c5c1 DataTable: group width-props 2026-05-15 13:05:31 +02:00
Ken 4cdb7feb02 [DataTable] Moved fullWidthColSpan calculation to hook, renmaed const 2026-05-13 18:00:40 +02:00
Ken 7330c74c7b [DataTable] Moved TableElementWrapper out one level to avoid re-renders 2026-05-13 17:55:52 +02:00
Ken 604cd7b5f7 [DataTable] Moved sorting hook result into context 2026-05-13 17:51:56 +02:00
Halvor Haugan be7d524851 DataTable: fix test 2026-05-13 16:07:31 +02:00
Halvor Haugan b42b259a4f DataTable: some comments 2026-05-13 16:01:47 +02:00
Anniken Granberg def2ff5939 DataTable / dnd: Cleanup todos 2026-05-13 15:00:15 +02:00
Anniken Granberg bb8a9f96df DataTable: Remove large text size and change text size values 2026-05-13 15:00:15 +02:00
Ken e9d723a1ba [KI] Optimized instructions, added caveman 2026-05-13 11:57:54 +02:00
Ken 5fdca37439 [DataTable] Optimize context by moving item-context to root 2026-05-13 11:57:54 +02:00
Anniken GranbergandCopilot Autofix powered by AI d50aef6ced [Website] Added support for tabs in code block (#4870)
* [Website] Added support for tabs in code block

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-13 11:38:50 +02:00
Ken 8b0d4a5517 [DataTable] Update sorting state 2026-05-13 10:37:36 +02:00
Ken 79fd31c117 [DataTable] rename disableRowSelectionOnClick to selectionTrigger and disableRowSelection to enableRowSelection 2026-05-13 10:16:01 +02:00
Ken 92d599fa97 🔥 Removed old documentation 2026-05-13 10:16:01 +02:00
Halvor Haugan c34d9fc966 DataTable: contentMaxWidth can now be any string (or number) 2026-05-13 10:10:18 +02:00
Halvor Haugan f497787566 DataTable: truncateContent default false when layout=auto 2026-05-13 09:59:51 +02:00
Ken 2a6ec64316 [DataTable] removed DetailsT from columnDefinitions 2026-05-12 20:01:37 +02:00
Ken cdbda9c231 [DataTable] Added todos 2026-05-12 19:43:01 +02:00
Ken 6b0b2f9376 [DataTable] Remove legacy comment 2026-05-12 19:39:37 +02:00
Ken ead1139a09 [DataTable] Sticky-state now accounts for selection and expansion-state 2026-05-12 19:38:58 +02:00
Ken de438d487e [DataTable] update stickyColumns props 2026-05-12 19:13:26 +02:00
Ken 8028ff69e8 [DataTable] Refactor getRowId to only accept string, reference TableRowEntryId where possible 2026-05-12 16:20:16 +02:00
Ken 459ee4a66f [DataTable] Rename emptyState -> emptyContent 2026-05-12 15:53:48 +02:00
Ken 45c6635ec8 [DataTable] Refactor loading-api 2026-05-12 15:53:48 +02:00
Anniken Granberg ab3bfc8074 DataTable: remove dnd-kit package 2026-05-12 15:35:27 +02:00
Ken 4329775c9f [DataTable] Removed shouldBlockNavigation prop 2026-05-12 15:23:15 +02:00
Ken 6d3271d946 [DataTable] Require selectionMode to be set when selection-prop is defined 2026-05-12 15:12:43 +02:00
Ken dd02a7d4a9 [DataTable] Replace UNSAFE_isSelection with 'cellType=action' prop 2026-05-12 15:10:22 +02:00
Ken b8738227b2 📝 Add todos with priorities 2026-05-12 14:46:47 +02:00
Ken c717b5a2d1 Add review 2026-05-12 14:46:47 +02:00
Ken e9dd76d7e0 📝 Add review 2026-05-12 14:46:47 +02:00
19ac81dbb4 [website] DescriptionList component (#4862)
Co-authored-by: Ken <ken.aleksander@gmail.com>
Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>
2026-05-12 11:45:18 +02:00
Halvor Haugan 20e6935a31 DataTable: CSS adjustment I forgot to commit after editing DataTableColumnHeader 2026-05-12 11:41:17 +02:00
Halvor Haugan 807b2feef2 [website] Fix riktekst toolbar alignment issue 2026-05-12 11:38:48 +02:00
Ken a9a8bad7a4 🔥 Removed deprecated description-fields in sanity-workspace config 2026-05-11 18:47:51 +02:00
Ken 412a00cdd2 🎉 Developer workspace in sanity is now hidden for non admins/devs 2026-05-11 18:23:20 +02:00
7727c3249a [DataTable] Selection api refactor (#4860)
* :WIP: Refactor selection with rowdata

* ♻️ Implement multiple-select handler

* ♻️ Fix bugs and performance

* 🔥 Removed unused code

* 🔥 removed old comments

* 🔥 removed old comments

* ♻️ Resolve function or boolean

* 🐛 Use console util for printing to dev

* 🔥 removed legacy-tests

* 🐛 Preserve previous pages selection

* ⚡ Use set directly

* avoid any

* comment

* rename useTableItemsReturn -> UseTableItemsReturn

* fix type of disableRowSelection

* 🐛 Handle selection of child-rows when parent-row is selected

* 🏷️ Types refactor

* 🐛 Update stories to reflect updated state

* Update @navikt/core/react/src/data/table/hooks/useTableSort.ts

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

* 📝 Updated comments

* 🔥 Comment out non-working kitchen-sink demo

* forward T in canSelectTableRow types

* single select stories

* 🏷️ Removed unused type

---------

Co-authored-by: Halvor Haugan <halvor.haugan@nav.no>
Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2026-05-11 18:05:41 +02:00
Halvor Haugan c40ecab7dd DataTable: Adjustments & comments in DataTableColumnHeader 2026-05-11 17:46:09 +02:00
Halvor Haugan ed2eb5885f DataTable: Storybook test for autoWidth 2026-05-11 17:28:31 +02:00
dependabot[bot] d9ed2ab6eb Bump esbuild from 0.27.7 to 0.28.0 (#4866)
Bumps [esbuild](https://github.com/evanw/esbuild) from 0.27.7 to 0.28.0.
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.27.7...v0.28.0)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.28.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-11 15:50:33 +02:00
dependabot[bot] 1338d97853 Bump ts-morph from 27.0.2 to 28.0.0 (#4865)
Bumps [ts-morph](https://github.com/dsherret/ts-morph) from 27.0.2 to 28.0.0.
- [Release notes](https://github.com/dsherret/ts-morph/releases)
- [Commits](https://github.com/dsherret/ts-morph/compare/27.0.2...28.0.0)

---
updated-dependencies:
- dependency-name: ts-morph
  dependency-version: 28.0.0
  dependency-type: direct:production
  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-05-11 15:49:35 +02:00
Anniken Granberg fa6c935530 DataTable: remove old drag and drop 2026-05-11 14:17:25 +02:00
Anniken GranbergandKen 1f39de488f 🎨 InfoCard: Added bigger padding for medium sized InfoCard.Message (#4861)
* 🎨 InfoCard: Change padding for small and medium for InfoCard.Message

* changeset

* Update @navikt/core/css/src/alert.css

Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>

---------

Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>
2026-05-11 12:35:20 +02:00
Ken e29b94e4d2 [Website] Codeblock adjustments (#4859)
* 💄 Adjusted styles

* 📝 Adjust default language render

* 🐛 Sync codeblock-render with new tabs
2026-05-11 12:15:47 +02:00
dependabot[bot] cb2d048ed8 Bump the storybook group with 9 updates (#4864)
Bumps the storybook group with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [@storybook/addon-a11y](https://github.com/storybookjs/storybook/tree/HEAD/code/addons/a11y) | `10.3.5` | `10.3.6` |
| [@storybook/addon-docs](https://github.com/storybookjs/storybook/tree/HEAD/code/addons/docs) | `10.3.5` | `10.3.6` |
| [@storybook/addon-themes](https://github.com/storybookjs/storybook/tree/HEAD/code/addons/themes) | `10.3.5` | `10.3.6` |
| [@storybook/addon-vitest](https://github.com/storybookjs/storybook/tree/HEAD/code/addons/vitest) | `10.3.5` | `10.3.6` |
| [@storybook/nextjs-vite](https://github.com/storybookjs/storybook/tree/HEAD/code/frameworks/nextjs) | `10.3.5` | `10.3.6` |
| [@storybook/react-vite](https://github.com/storybookjs/storybook/tree/HEAD/code/frameworks/react-vite) | `10.3.5` | `10.3.6` |
| [eslint-plugin-storybook](https://github.com/storybookjs/storybook/tree/HEAD/code/lib/eslint-plugin) | `10.3.5` | `10.3.6` |
| [storybook](https://github.com/storybookjs/storybook/tree/HEAD/code/core) | `10.3.5` | `10.3.6` |
| [storybook-addon-pseudo-states](https://github.com/storybookjs/storybook/tree/HEAD/code/addons/pseudo-states) | `10.3.5` | `10.3.6` |


Updates `@storybook/addon-a11y` from 10.3.5 to 10.3.6
- [Release notes](https://github.com/storybookjs/storybook/releases)
- [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md)
- [Commits](https://github.com/storybookjs/storybook/commits/v10.3.6/code/addons/a11y)

Updates `@storybook/addon-docs` from 10.3.5 to 10.3.6
- [Release notes](https://github.com/storybookjs/storybook/releases)
- [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md)
- [Commits](https://github.com/storybookjs/storybook/commits/v10.3.6/code/addons/docs)

Updates `@storybook/addon-themes` from 10.3.5 to 10.3.6
- [Release notes](https://github.com/storybookjs/storybook/releases)
- [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md)
- [Commits](https://github.com/storybookjs/storybook/commits/v10.3.6/code/addons/themes)

Updates `@storybook/addon-vitest` from 10.3.5 to 10.3.6
- [Release notes](https://github.com/storybookjs/storybook/releases)
- [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md)
- [Commits](https://github.com/storybookjs/storybook/commits/v10.3.6/code/addons/vitest)

Updates `@storybook/nextjs-vite` from 10.3.5 to 10.3.6
- [Release notes](https://github.com/storybookjs/storybook/releases)
- [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md)
- [Commits](https://github.com/storybookjs/storybook/commits/v10.3.6/code/frameworks/nextjs)

Updates `@storybook/react-vite` from 10.3.5 to 10.3.6
- [Release notes](https://github.com/storybookjs/storybook/releases)
- [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md)
- [Commits](https://github.com/storybookjs/storybook/commits/v10.3.6/code/frameworks/react-vite)

Updates `eslint-plugin-storybook` from 10.3.5 to 10.3.6
- [Release notes](https://github.com/storybookjs/storybook/releases)
- [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md)
- [Commits](https://github.com/storybookjs/storybook/commits/v10.3.6/code/lib/eslint-plugin)

Updates `storybook` from 10.3.5 to 10.3.6
- [Release notes](https://github.com/storybookjs/storybook/releases)
- [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md)
- [Commits](https://github.com/storybookjs/storybook/commits/v10.3.6/code/core)

Updates `storybook-addon-pseudo-states` from 10.3.5 to 10.3.6
- [Release notes](https://github.com/storybookjs/storybook/releases)
- [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md)
- [Commits](https://github.com/storybookjs/storybook/commits/v10.3.6/code/addons/pseudo-states)

---
updated-dependencies:
- dependency-name: "@storybook/addon-a11y"
  dependency-version: 10.3.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: storybook
- dependency-name: "@storybook/addon-docs"
  dependency-version: 10.3.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: storybook
- dependency-name: "@storybook/addon-themes"
  dependency-version: 10.3.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: storybook
- dependency-name: "@storybook/addon-vitest"
  dependency-version: 10.3.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: storybook
- dependency-name: "@storybook/nextjs-vite"
  dependency-version: 10.3.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: storybook
- dependency-name: "@storybook/react-vite"
  dependency-version: 10.3.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: storybook
- dependency-name: eslint-plugin-storybook
  dependency-version: 10.3.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: storybook
- dependency-name: storybook
  dependency-version: 10.3.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: storybook
- dependency-name: storybook-addon-pseudo-states
  dependency-version: 10.3.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: storybook
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 08:54:38 +02:00
Anniken Granberg 11d8874fba DataTable: Add textSize and connect row density to selected text size 2026-05-08 19:19:25 +02:00
Halvor Haugan 7d85a0c69e DataTable: BaseCell type improvements + comments 2026-05-08 16:32:39 +02:00
Halvor Haugan e537e040fe DataTable: Built-in spacing for for tags and buttons 2026-05-08 11:48:32 +02:00
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
262 changed files with 13629 additions and 10837 deletions

No files matched your search

+45 -108
View File
@@ -1,43 +1,27 @@
# Aksel - Copilot Instructions
## Use this file first
## Approach
- Trust these instructions to narrow the search space, but still read the target code and nearest related files before editing.
- Optimize for local work. Prefer known paths, nearby patterns, and existing scripts over broad repo searches.
- Ignore release, deploy, changelog, CMS sync, and other remote/ops scripts unless the user asks.
- Narrow search space; don't replace reading code.
- Before editing: read target file + nearest related files (`index.ts`, story, test, exports).
- Prefer known paths/scripts over broad searches.
- Ignore release/deploy/changelog/ops scripts unless asked.
## How to use this file
## Behavior
- Use this file to narrow the search space, not to replace reading code.
- Before editing, read the target file and the nearest related files in the same folder such as `index.ts`, story, test, and package/root export files.
- Search wider only when nearby files do not answer the question.
## Agent mode
- No apologies, no “I think”, no change summaries.
- Don’t ask to confirm info that’s already in context.
- Favor performance and security; add robust error handling and safe logging.
- Default tone: short and answer-first. Aim for max 0–2 concise sentences where needed.
- Thinking and explanation process: Keep it short, omit whats not strictly necessary, and focus on the next edit.
- Avoid long explanations or multiple steps at once.
- Don't explain thinking process for each step, just do the next edit. Only add explanations when the user explicitly asks for it or when it's necessary to clarify a non-obvious change.
## Inline chat and editor completions
- Verify before you claim. Don’t speculate.
- Keep functions small and focused; remove duplication (rule of three).
- Show only what changes. Provide minimal diffs/snippets; don’t paste whole files or unrelated lines.
- Keep edits scoped to a single file unless explicitly asked to touch more.
- Extract requirements into a tiny checklist and proceed. Ask a question only when truly blocked.
- Avoid heavy formatting: bullets are OK; skip tables; use code fences only for code.
- For performance/security: avoid unnecessary re-renders, side effects at module top-level, unsafe HTML, and leaking env vars.
- No apologies, no "I think", no change summaries.
- Answer-first. 0-2 sentences max.
- No thinking aloud; do next edit. Explain only when asked or non-obvious.
- Show only what changes. Minimal diffs; don't paste whole files.
- Edits scoped to one file unless asked. Ask only when truly blocked.
- Favor perf/security; avoid re-renders, top-level side effects, unsafe HTML, env var leaks.
## Repo summary
- Large Yarn 4 workspaces monorepo for NAV's design system: public React/CSS/tokens/icons/stylelint/CLI packages plus docs site and playroom.
- Yarn 4 workspaces monorepo: public React/CSS/tokens/icons/stylelint/CLI packages + docs site + playroom.
- Stack: TypeScript 6, React 19, Next.js 16, Storybook 10, Vitest 4, ESLint 9, Stylelint 17, Biome 2, Prettier 3.
- Runtime: Node 22+ recommended, Yarn 4.12.0.
- Important constraint: code under `@navikt/*` must stay React 17 API compatible. Do not use React 18/19-only APIs there.
- Node 22+, Yarn 4.12.0.
- `@navikt/*` must stay React 17 compatible.
## Start and validate
@@ -45,101 +29,54 @@
2. `yarn install`
3. `yarn boot`
- Run `yarn boot` after first install and after `yarn clean`.
- Full install may need `NPM_AUTH_TOKEN` for GitHub Packages.
- `yarn boot` after first install + after `yarn clean`.
- Full install needs `NPM_AUTH_TOKEN` for GitHub Packages.
Public-package-only install:
```bash
yarn workspaces focus @navikt/aksel-icons @navikt/ds-tokens @navikt/ds-css @navikt/ds-react @navikt/ds-tailwind @navikt/aksel @navikt/aksel-stylelint
```
Public-package-only: `yarn workspaces focus @navikt/aksel-icons @navikt/ds-tokens @navikt/ds-css @navikt/ds-react @navikt/ds-tailwind @navikt/aksel @navikt/aksel-stylelint`
## Definition of done
- Behavior or visual change: update the implementation, closest story, and closest test.
- New public export: update the component `index.ts`, package `src/index.ts`, and `package.json` exports.
- User-facing package change: add a changeset unless the task says otherwise.
- Validate with the narrowest workspace command first; use root commands only for cross-workspace changes.
- Behavior/visual change: update impl + closest story + test.
- New public export: sync component `index.ts`, `src/index.ts`, `package.json` exports.
- User-facing change: add changeset unless told otherwise.
- Validate with narrowest workspace command first.
### Common local commands
### Commands
Always prefer using these commands that already exists in workspaces before running custom commands or doing manual steps. These are optimized for local development and validation. For example:
- `yarn boot` - build all workspaces
- `yarn test` - run all workspace tests
- `yarn lint` - eslint + stylelint + biome + cycle + version checks
- `yarn boot` - build all
- `yarn test` / `yarn lint` - all tests / all linting
- `yarn lint:eslint` / `yarn lint:css` / `yarn lint:biome`
- `yarn storybook` - root Storybook on `localhost:6006`
- `yarn storybook:aksel` - website/example Storybook on `localhost:6007`
- `yarn storybook:test` - Storybook browser tests, slow, uses Playwright/Firefox
- `yarn dev` - website dev server on `localhost:3000`
- `yarn storybook` - root `:6006`
- `yarn storybook:aksel` - website examples `:6007`
- `yarn storybook:test` - browser tests (Playwright/Firefox)
- `yarn dev` - website `:3000`
- `yarn clean` - remove build artifacts
- `yarn workspace @navikt/ds-react build`
- `yarn workspace @navikt/ds-react test`
- `yarn workspace @navikt/ds-react build|test`
- `yarn workspace website test`
- `yarn workspace playroom sync-imports`
### Validated locally
- Works: `yarn boot`, `yarn test`, `yarn storybook --ci --smoke-test`, `yarn storybook:aksel --ci --smoke-test`
- Caveat: `yarn lint` and `yarn lint:eslint` can fail if playroom imports are stale. Fix with `yarn workspace playroom sync-imports`.
`yarn lint` fails if playroom imports stale → `yarn workspace playroom sync-imports`.
## Where to edit
- `@navikt/core/react/` - `@navikt/ds-react`
- `src/<component>/<Component>.tsx`
- `src/<component>/index.ts`
- `src/<component>/*.stories.tsx`
- `src/index.ts`
- `package.json` exports map for new public components
- `@navikt/core/react/src/<component>/` - component source, index, stories
- `@navikt/core/react/src/index.ts` + `package.json` exports - public API surface
- `@navikt/core/css/src/` - component CSS
- `@navikt/core/tokens/src/` - design token source
- `@navikt/core/tokens/src/` - design tokens
- `@navikt/core/tailwind/` - Tailwind preset
- `@navikt/aksel-icons/icons/` - source SVGs; `src/` is generated
- `@navikt/aksel-stylelint/src/` - Stylelint rules/config
- `@navikt/aksel-icons/icons/` - source SVGs (`src/` generated)
- `@navikt/aksel-stylelint/src/` - Stylelint rules
- `@navikt/aksel/src/` - CLI and codemods
- `aksel.nav.no/website/` - Next.js docs site
- `aksel.nav.no/playroom/` - playroom and generated imports
- `examples/` - example apps
- `scripts/` - shared repo scripts
- Root references: `README.md`, `CONTRIBUTING.md`, `aksel.nav.no/README.md`, `package.json`, `eslint.config.js`, `stylelint.config.mjs`, `biome.json`, `tsconfig.json`, `tsconfig.build.json`, `.storybook/`, `.github/workflows/build-validation.yml`
- `aksel.nav.no/playroom/` - playroom
- Root: `eslint.config.js`, `stylelint.config.mjs`, `biome.json`, `tsconfig.json`, `.storybook/`
## Coding defaults
- Keep diffs small. No drive-by refactors, formatting churn, or new dependencies unless asked.
- Read the full file and copy the nearest existing pattern before changing code.
- Keep public APIs stable. Prefer additive props and exports.
- In `@navikt/*`, import React in TSX files; root JSX runtime is classic.
- Use named exports with explicit type exports.
- Keep `forwardRef`, `className`, `...rest`, and `as`/`OverridableComponent` patterns when the surrounding component uses them.
- Reuse internal helpers instead of re-implementing them: `cl`, `composeEventHandlers`, `useId`, `omit`, `useClientLayoutEffect`, `useEventListener`.
- Use `node:` imports for Node builtins.
- Never import from `esm/` or `cjs/` build output.
- Keep import order consistent: `@navikt/*`, `@/*`, then relative. Let Prettier handle sorting.
- Preserve or add JSDoc for public props/components when changing public APIs.
## React and ds-react specifics
- Component `index.ts` files in `@navikt/core/react/src/**` start with `"use client"`.
- When adding or exposing a public component, keep these in sync:
- component `index.ts`
- `@navikt/core/react/src/index.ts`
- `@navikt/core/react/package.json` exports
- Do not use React 18/19-only APIs under `@navikt/*`. Use internal helpers like `useId`.
## CSS and tokens
- Never hardcode colors, spacing, radius, or shadows. Use tokens only.
- Public tokens use `--ax-*`. Internal component variables use `--__axc-*`.
- Follow existing CSS nesting, `data-*` selectors, `focus-visible`, `forced-colors`, and reduced-motion patterns.
- Prettier formats; Biome is lint-only.
## Stories, tests, website
- Update the closest story and test whenever behavior or visuals change.
- Use Testing Library queries by role and name.
- Stories live next to components and use `@storybook/react-vite`.
- For component work, prefer Storybook over `yarn dev`.
- `aksel.nav.no/website/pages/eksempler/**` and `pages/templates/**` feed the website and Storybook. In these files, only import from `@navikt/*` and `react`.
- The website uses both `app/` and `pages/`; stay inside the router already used nearby.
- `yarn storybook:aksel` works for website examples/templates without Sanity secrets. `yarn dev` may need website env values such as `SANITY_READ_NO_DRAFTS`.
- Small diffs. No drive-by refactors or new deps unless asked.
- Copy nearest existing pattern before writing new code.
- Stable public APIs. Prefer additive props/exports.
- `node:` imports for Node builtins. Never import from `esm/`/`cjs/` output.
- Import order: `@navikt/*`, `@/*`, relative. (Prettier sorts)
- Preserve JSDoc on public props/components when changing public APIs.
- Reuse helpers: `cl`, `composeEventHandlers`, `useId`, `omit`, `useClientLayoutEffect`, `useEventListener`.
@@ -0,0 +1,78 @@
---
description: >
Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra.
Always use caveman for code-related questions unless spesificed otherwise. For non-code topics, use caveman if user seems to want quick, concise answers.
applyTo: "**"
---
# caveman
ACTIVE EVERY RESPONSE.
default: full
switch mode:
- "/caveman lite" → lite
- "/caveman full" → full
- "/caveman ultra" → ultra
- "stop caveman" | "normal mode" → off
persist: keep last state
## intensity
| Level | What change |
| --------- | ------------------------------------------------------------------------------------------------------------- |
| **lite** | short sentences, no filler, keep grammar |
| **full** | fragments ok, drop articles, short words. Classic caveman |
| **ultra** | Abbreviate (DB/auth/config/req/res/fn/impl), fragments, abbrev (db/api/req/res/fn), arrows (→), minimal words |
Example - "Why React component re-render?"
- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
- ultra: "Inline obj prop → new ref → re-render. `useMemo`."
## global rules
- no filler (just/really/basically/etc)
- no pleasantries
- no hedging
- keep tech exact
- no long sentences
- prefer symbols (→, =)
## patterns
Patterns: `[thing] [action] [reason]. [next step].`
## examples
lite:
"Component re-renders because you create a new object each render. Use useMemo."
full:
"New object each render → new ref → re-render. useMemo."
ultra:
"inline obj → new ref → re-render. fix: useMemo."
## auto-Clarity
Drop caveman for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user asks to clarify or repeats question. Resume caveman after clear part done.
Example - destructive op:
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
>
> ```sql
> DROP TABLE users;
> ```
>
> Caveman resume. Verify backup exist first.
## boundaries
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end
@@ -1,11 +1,11 @@
description: "Use for JS/TS code in this repo."
applyTo: "**/\*.tsx", "**/_.jsx", "\*\*/_.ts", "\*_/_.js"
---
description: "JS/TS code conventions."
applyTo: "**/*.tsx", "**/*.jsx", "**/*.ts", "**/*.js"
---
# JS/TS instructions
- Prefer `for...of` over `Array.forEach`.
- Prefer named exports; keep type exports separate from value exports.
- Group exports at the bottom: values first, then types.
- In `*.stories.*`, keep the default export at the top.
- Named exports; type exports separate from value exports.
- Group exports at bottom: values first, then types.
- In `*.stories.*`, keep default export at top.
@@ -1,13 +1,17 @@
description: "Use for ds-react components and component CSS."
---
description: "ds-react components and component CSS."
applyTo: "@navikt/core/react/src/**", "@navikt/core/css/src/**"
---
# ds-react and component instructions
# ds-react component instructions
- Read the full component folder before editing.
- Preserve the surrounding `forwardRef`, `className`, `...rest`, and `as`/`OverridableComponent` patterns.
- Keep `@navikt/*` code React 17 API compatible.
- Use tokens, not hardcoded component values.
- If behavior or visuals change, update the nearest story and test.
- If a public component changes, keep component exports, package exports, and root exports in sync.
- Read full component folder before editing.
- Preserve `forwardRef`, `className`, `...rest`, `as`/`OverridableComponent` patterns.
- `@navikt/*` must stay React 17 compatible. No React 18/19-only APIs; use internal `useId` etc.
- `@navikt/*` TSX: import React explicitly (classic JSX).
- Component `index.ts` starts with `"use client"`.
- Use tokens, not hardcoded values. Token names: `--ax-*` (public), `--__axc-*` (internal).
- Follow CSS nesting, `data-*` selectors, `focus-visible`, `forced-colors`, reduced-motion.
- Prettier formats CSS; Biome lint-only.
- Behavior/visual change: update nearest story + test.
- Public component change: sync component `index.ts`, `src/index.ts`, `package.json` exports.
+10 -7
View File
@@ -1,11 +1,14 @@
description: "Use for tests and stories."
applyTo: "**/_.test._", "**/\*.stories.tsx"
---
description: "Tests and stories."
applyTo: "**/*.test.*", "**/*.stories.tsx"
---
# Testing and Storybook instructions
- Prefer story `play` functions and `yarn storybook:test` for real user flows; use Vitest for logic and non-browser cases.
- Prefer focused assertions over snapshots unless nearby tests already use snapshots.
- Cover the main path and one or two meaningful edge cases.
- If UI behavior changes, update the nearest story and test in the same change.
- Prefer story `play` functions + `yarn storybook:test` for user flows; Vitest for logic/non-browser.
- Prefer focused assertions over snapshots unless nearby tests use them.
- Cover main path + 1-2 meaningful edge cases.
- UI behavior change: update nearest story + test in same change.
- Testing Library: query by role and name.
- Stories use `@storybook/react-vite`, live next to components.
- Prefer `yarn storybook` for component work over `yarn dev`.
+8 -10
View File
@@ -1,14 +1,12 @@
description: "Use for website and playroom work."
applyTo:
- "aksel.nav.no/website/\*\*"
- "aksel.nav.no/playroom/\*\*"
---
description: "Website and playroom."
applyTo: "aksel.nav.no/website/**, aksel.nav.no/playroom/**"
---
# Website and playroom instructions
- Stay inside the router already used nearby; do not mix `app/` and `pages/` patterns in the same area.
- In `aksel.nav.no/website/pages/eksempler/**` and `aksel.nav.no/website/pages/templates/**`, only import from `@navikt/*` and `react`.
- Reuse nearby website utilities and patterns before adding abstractions.
- Prefer `yarn storybook:aksel` for examples/templates; use `yarn dev` only for app-only behavior.
- Stay in router already used nearby; don't mix `app/`/`pages/`.
- `pages/eksempler/**` and `pages/templates/**`: only import from `@navikt/*` and `react`.
- Reuse nearby utils/patterns before adding abstractions.
- Prefer `yarn storybook:aksel` for examples/templates; `yarn dev` only for app-specific behavior.
- `yarn storybook:aksel` needs no secrets. `yarn dev` needs `SANITY_READ_NO_DRAFTS`.
@@ -0,0 +1,221 @@
---
name: ds-component-restructure
description: "Restructure a ds-react component to follow canonical folder/file conventions. Use when: restructuring a component, reorganizing component files, migrating component structure, refactoring component layout, splitting monolithic component files, renaming component files to match domain."
argument-hint: "Path to component folder, e.g. @navikt/core/react/src/accordion"
---
# ds-component-restructure
Restructure a `ds-react` component folder to follow canonical conventions — without breaking the public API.
## Canonical Structure
```
<component>/
├── index.ts # "use client" + re-exports from root
├── <Component>.stories.tsx # Storybook stories
├── <Component>.tests.stories.tsx # Storybook play-tests (only if play tests exist)
├── root/
│ ├── <Component>Root.tsx # Main compound component + sub-component namespace
│ ├── <Component>Root.context.ts # Context (if shared across sub-components)
│ └── <Component>Root.test.ts # Unit tests for root (if needed)
├── <subcomponent>/ # One dir per sub-component
│ ├── <Component><Subcomponent>.tsx
│ └── <Component><Subcomponent>.test.ts # Unit tests for sub-component (if needed)
├── helpers/
│ ├── <helper-name>.ts
│ └── <helper-name>.test.ts # Unit tests co-located with helper
└── <Component>.types.ts # Shared types (if needed across multiple files)
```
### Naming Rules
| What | Pattern | Example |
| ---------------------- | --------------------------------------- | ----------------------------- |
| Root component file | `<Component>Root.tsx` | `AccordionRoot.tsx` |
| Sub-component file | `<Component><Subcomponent>.tsx` | `AccordionItem.tsx` |
| Internal sub-component | `<Component><Subcomponent>Internal.tsx` | `DialogBackdropInternal.tsx` |
| Sub-component dir | lowercase sub-component name | `item/` |
| Context file | `<Component>Root.context.ts` | `AccordionRoot.context.ts` |
| Shared types file | `<Component>.types.ts` | `Accordion.types.ts` |
| Stories | `<Component>.stories.tsx` | `Accordion.stories.tsx` |
| Play-test stories | `<Component>.tests.stories.tsx` | `Accordion.tests.stories.tsx` |
| Unit test file | `<filename>.test.ts(x)` | `useAccordion.test.ts` |
| `index.ts` | always `index.ts` | `index.ts` |
## Procedure
### 1. Read the Component
Read every file in the component folder:
- `index.ts` — current exports
- All `.tsx` / `.ts` source files
- Stories and test stories
- Note what is currently exported publicly
- Note: Ignore inconsistensies in file naming and structure — the goal is to fix those, not preserve them. Focus on understanding the component's public API and internal structure.
### 2. Map Files to Target Structure
Build this table before touching any files:
| Old path | New path | Renamed? | Publicly exported? | Importers to update |
| ------------------- | --------------------------- | -------- | ------------------ | --------------------- |
| `DialogTrigger.tsx` | `trigger/DialogTrigger.tsx` | No | Yes | `root/DialogRoot.tsx` |
| ... | | | | |
For each file:
- Determine new path and whether it moves/renames
- Check whether content needs splitting (e.g. types → `<Component>.types.ts`)
- List every file that imports it (grep for the filename or exported symbol)
- Flag circular dep risks: context importing sub-components, helpers importing component files, types importing non-types
Resolve circular deps before proceeding: move shared pieces to a lower-level file (types → `.types.ts`, utilities → `helpers/`).
### 3. Create New Files
For each row in the mapping table where the path changes:
1. Create the file at the new path with all import paths updated to reflect new locations
2. Update every file in the "Importers to update" column — use the mapping table, don't rely on memory
3. Do NOT delete old files yet
### 4. Update `index.ts`
The repo uses a mix of default and named exports because `react-docgen-typescript` (used by `scripts/docgen.ts`) has inconsistent pickup depending on export style. Follow the existing pattern in the component's original `index.ts` — do NOT change export style during restructure.
Typical pattern (main component as default re-export, sub-components as named):
```ts
"use client";
export {
default as <Component>, // main component — default export from Root file
<Component>Trigger, // sub-components — named exports
// ...
} from "./root/<Component>Root";
export type {
<Component>Props,
<Component>TriggerProps,
// ...
} from "./root/<Component>Root";
```
Some components export everything as named (no `default`). If the original had no default export, keep it that way. If types are in `<Component>.types.ts`, re-export them from there directly or via the root.
### 5. Remove Old Files
Only after verifying new files are correct and `index.ts` exports match the original, delete the old files.
### 6. Validate No Breaking Changes
**Step 1 — Export diff.** Before deleting old files, extract all exported names from the original `index.ts` (recorded in step 1) and compare against the new `index.ts`. Every name must be present:
```bash
# Exports in new index.ts that are missing from original → regression
# Exports in original that are missing from new index.ts → missing export (breaking change)
```
Concretely: grep for `export` lines in both versions and diff them. All component names and type names must match exactly.
**Step 2 — Build.** Run `yarn workspace @navikt/ds-react build` — must succeed with no TypeScript errors.
**Step 3 — Package root.** Verify `@navikt/core/react/src/index.ts` still re-exports the component. No changes needed there unless the component's `index.ts` path changed (it shouldn't).
### 7. Update Stories
Move stories to component root dir. If stories import from internal paths, update those paths.
**Exception:** A `stories/` subdirectory is acceptable if it already exists — do not flatten it.
## Unit Tests
Unit tests live next to the file they test — same directory, same base name with `.test.ts` / `.test.tsx` suffix.
| What to test | File location |
| ---------------------- | --------------------------------------------------- |
| Helper utility | `helpers/<helper-name>.test.ts` |
| Sub-component logic | `<subcomponent>/<Component><Subcomponent>.test.tsx` |
| Root component / hooks | `root/<Component>Root.test.tsx` |
| Context helpers | `root/<Component>Root.context.test.ts` |
Do NOT move existing test files unless their source file moves. When moving a source file, move its test file with it and update import paths.
## Edge Cases
### Flat components
Leave flat components flat — do NOT introduce subdirs unless the component contains internal utility-components (files that render JSX but are not public sub-components). If such a utility-component exists, move it to its own subdir following the canonical naming.
Example: `button/Button.tsx` has no other sub-components, so should stay in root.
```
### `parts/` directory
If the component uses a `parts/` dir instead of `root/`, rename it to `root/` during restructure. Update all relative imports.
### Public hooks
Hooks that are part of the public API (e.g. `useTabs`, `useFormField`) live in `root/` alongside the root component:
```
root/
├── <Component>Root.tsx
├── <Component>Root.context.ts
└── use<Hook>.ts # public hook
````
Export from `index.ts` the same way as components:
```ts
export { use<Hook> } from "./root/use<Hook>";
export type { Use<Hook>Return } from "./root/use<Hook>";
````
### Internal sub-components
Sub-components that are implementation details (not exported publicly) follow the same dir convention as public ones, but the filename gets an `Internal` suffix:
```
backdrop/
└── DialogBackdropInternal.tsx ← internal, NOT exported from index.ts
```
- **Dir name**: same pattern as public sub-components
- **File name**: `<Component><Subcomponent>Internal.tsx`
- **`index.ts`**: do NOT export it
### Nested subcomponents
Do NOT nest subcomponent dirs inside other subcomponent dirs. All sub-component dirs must be direct children of the component root:
```
# Wrong
checkbox/
└── input/
└── CheckboxInput.tsx
# Correct
checkbox/
└── checkbox-input/
└── CheckboxInput.tsx
```
## Constraints
- **No breaking changes.** Public export names and prop shapes must be identical after restructuring.
- **React 17 compatible.** No React 18/19-only APIs. Import React explicitly in `.tsx` files.
- `index.ts` must start with `"use client"`.
- Use tokens, not hardcoded values.
- Preserve `forwardRef`, `className`, `...rest`, `as`/`OverridableComponent` patterns.
- Keep JSDoc on public props and components.
## Reference Files
- Canonical example: `@navikt/core/react/src/dialog/`
- Package public API: `@navikt/core/react/src/index.ts`
- Component `index.ts` pattern: `@navikt/core/react/src/dialog/index.ts`
- Context pattern: `@navikt/core/react/src/dialog/root/DialogRoot.context.ts`
+2 -2
View File
@@ -1,4 +1,4 @@
name: E2E tests (Playwright)
name: E2E tests
on:
push:
branches: [main, next]
@@ -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.60.0-noble
services:
testapp:
image: ${{ needs.prebuild-app-for-playwright.outputs.image }}
+2 -2
View File
@@ -1,4 +1,4 @@
name: Validation (Lint, Tests)
name: Validation
on:
push:
@@ -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.60.0-noble
steps:
- name: Checkout code
uses: actions/checkout@v6
@@ -1,4 +1,4 @@
name: Visual regression (Chromatic)
name: Visual regression
on:
push:
-2
View File
@@ -98,7 +98,6 @@ examples/webpack/out
.iml
.DS_Store
*.d.ts
*-exports.less
*/figma-api/icons
*/ds-icons/png
@@ -136,7 +135,6 @@ _docs.json
.next
!*/website/lib/
!*/website/public/
!aksel.nav.no/**/*.d.ts
!*/sanity/lib/
!*/sanity/static/
out
-1
View File
@@ -4,7 +4,6 @@ import addonThemes, { withThemeByClassName } from "@storybook/addon-themes";
import addonVitest from "@storybook/addon-vitest";
import { definePreview } from "@storybook/react-vite";
import React, { useEffect } from "react";
import "../@navikt/core/css/src/data-table.css";
import "../@navikt/core/css/src/data-token-filter.css";
import "../@navikt/core/css/src/data-toolbar.css";
import "../@navikt/core/css/src/index.css";
-7
View File
@@ -1,7 +0,0 @@
import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
import { setProjectAnnotations } from "@storybook/react-vite";
import * as projectAnnotations from "./preview";
// This is an important step to apply the right configuration when testing your stories.
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
+16
View File
@@ -1,5 +1,21 @@
# @navikt/aksel-icons
## 8.11.1
## 8.11.0
### Minor Changes
- Icons: New icon `Newsletter` and `NewsletterFill` ([#4887](https://github.com/navikt/aksel/pull/4887))
## 8.10.6
## 8.10.5
## 8.10.4
## 8.10.3
## 8.10.2
## 8.10.1
@@ -28,7 +28,7 @@ describe(`SVG should be correctly parsed`, () => {
</svg>
`;
test("a", () => {
test("should replace width and height with 1em, and fill with currentColor", () => {
expect(parseIcon(simpleSvg)).toEqual(simpleSvgResult);
expect(parseIcon(advancedSvg)).toEqual(advancedSvgResult);
});
+1 -1
View File
@@ -42,7 +42,7 @@ fs.writeFileSync(
id: string;
name: string;
category: string;
sub_category: "Time",
sub_category: string;
keywords: string[];
variant: "stroke" | "fill";
created_at: Date;
+1 -2
View File
@@ -22,5 +22,4 @@ dist-ssr
*.njsproj
*.sln
*.sw?
!src
!*.d.ts
!src
@@ -13,9 +13,9 @@
"dependencies": {
"@navikt/ds-css": "*",
"@navikt/ds-react": "*",
"fuse.js": "^7.0.0",
"react": "19.2.5",
"react-dom": "19.2.5"
"fuse.js": "^7.3.0",
"react": "19.2.6",
"react-dom": "19.2.6"
},
"devDependencies": {
"@figma/plugin-typings": "^1.100.2",
@@ -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"
+2 -1
View File
@@ -1,3 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.4695 11.0304C16.7624 11.3233 17.2371 11.3233 17.53 11.0304L21.03 7.53042C21.1707 7.38977 21.2498 7.19906 21.2498 7.00015C21.2498 6.80124 21.1707 6.61053 21.03 6.46988L17.53 2.96988C17.2371 2.67698 16.7624 2.67698 16.4695 2.96988C16.1766 3.26277 16.1766 3.73753 16.4695 4.03042L18.6892 6.25015L9.49976 6.25015C9.08555 6.25015 8.74976 6.58593 8.74976 7.00015C8.74976 7.41436 9.08555 7.75015 9.49976 7.75015L18.6892 7.75015L16.4695 9.96988C16.1766 10.2628 16.1766 10.7375 16.4695 11.0304ZM6.46949 21.0304C6.76238 21.3233 7.23714 21.3233 7.53003 21.0304C7.82293 20.7375 7.82293 20.2628 7.53003 19.9699L5.31031 17.7501H14.4998C14.914 17.7501 15.2498 17.4144 15.2498 17.0001C15.2498 16.5859 14.914 16.2501 14.4998 16.2501H5.31031L7.53003 14.0304C7.82293 13.7375 7.82293 13.2628 7.53003 12.9699C7.23714 12.677 6.76238 12.677 6.46949 12.9699L2.96949 16.4699C2.67659 16.7628 2.67659 17.2375 2.96949 17.5304L6.46949 21.0304Z" fill="#202733"/>
<path d="M16.4695 11.0302C16.7624 11.3231 17.2372 11.3231 17.5301 11.0302L21.0301 7.53018C21.1707 7.38953 21.2498 7.19882 21.2498 6.9999C21.2498 6.80099 21.1707 6.61028 21.0301 6.46963L17.5301 2.96963C17.2372 2.67674 16.7624 2.67674 16.4695 2.96963C16.1766 3.26252 16.1766 3.73728 16.4695 4.03018L18.6892 6.2499H9.49979C9.08558 6.2499 8.74979 6.58569 8.74979 6.9999C8.74979 7.41412 9.08558 7.7499 9.49979 7.7499H18.6892L16.4695 9.96963C16.1766 10.2625 16.1766 10.7373 16.4695 11.0302Z" fill="#202733"/>
<path d="M6.46952 21.0302C6.76241 21.3231 7.23717 21.3231 7.53006 21.0302C7.82296 20.7373 7.82296 20.2625 7.53006 19.9696L5.31034 17.7499H14.4998C14.914 17.7499 15.2498 17.4141 15.2498 16.9999C15.2498 16.5857 14.914 16.2499 14.4998 16.2499H5.31034L7.53006 14.0302C7.82296 13.7373 7.82296 13.2625 7.53006 12.9696C7.23717 12.6767 6.76241 12.6767 6.46952 12.9696L2.96952 16.4696C2.67662 16.7625 2.67662 17.2373 2.96952 17.5302L6.46952 21.0302Z" fill="#202733"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

+2 -1
View File
@@ -1,3 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.4697 21.0303C16.7626 21.3232 17.2374 21.3232 17.5303 21.0303L21.0303 17.5303C21.3232 17.2374 21.3232 16.7626 21.0303 16.4697C20.7374 16.1768 20.2626 16.1768 19.9697 16.4697L17.75 18.6893L17.75 9.5C17.75 9.08579 17.4142 8.75 17 8.75C16.5858 8.75 16.25 9.08579 16.25 9.5L16.25 18.6893L14.0303 16.4697C13.7374 16.1768 13.2626 16.1768 12.9697 16.4697C12.6768 16.7626 12.6768 17.2374 12.9697 17.5303L16.4697 21.0303ZM4.03033 7.53033L6.25 5.31066L6.25 14.5C6.25 14.9142 6.58579 15.25 7 15.25C7.41421 15.25 7.75 14.9142 7.75 14.5L7.75 5.31066L9.96967 7.53033C10.2626 7.82322 10.7374 7.82322 11.0303 7.53033C11.3232 7.23744 11.3232 6.76256 11.0303 6.46967L7.53033 2.96967C7.23744 2.67678 6.76256 2.67678 6.46967 2.96967L2.96967 6.46967C2.67678 6.76256 2.67678 7.23744 2.96967 7.53033C3.26256 7.82322 3.73744 7.82322 4.03033 7.53033Z" fill="#202733"/>
<path d="M7 15.25C7.41422 15.25 7.75 14.9142 7.75 14.5V5.31055L9.96973 7.53028C10.2626 7.82317 10.7374 7.82317 11.0303 7.53028C11.3232 7.23738 11.3232 6.76262 11.0303 6.46973L7.53028 2.96973C7.23738 2.67684 6.76262 2.67684 6.46973 2.96973L2.96973 6.46973C2.67684 6.76262 2.67684 7.23738 2.96973 7.53028C3.26262 7.82317 3.73738 7.82317 4.03028 7.53028L6.25 5.31055V14.5C6.25 14.9142 6.58579 15.25 7 15.25Z" fill="#202733"/>
<path d="M16.4697 21.0303C16.7626 21.3232 17.2374 21.3232 17.5303 21.0303L21.0303 17.5303C21.3232 17.2374 21.3232 16.7626 21.0303 16.4697C20.7374 16.1768 20.2626 16.1768 19.9697 16.4697L17.75 18.6895V9.5C17.75 9.08579 17.4142 8.75 17 8.75C16.5858 8.75 16.25 9.08579 16.25 9.5V18.6895L14.0303 16.4697C13.7374 16.1768 13.2626 16.1768 12.9697 16.4697C12.6768 16.7626 12.6768 17.2374 12.9697 17.5303L16.4697 21.0303Z" fill="#202733"/>
</svg>

Before

Width:  |  Height:  |  Size: 999 B

After

Width:  |  Height:  |  Size: 957 B

+2 -1
View File
@@ -1,3 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.25 19.5C11.25 19.9142 10.9143 20.25 10.5 20.25H4.50003C4.30112 20.25 4.11035 20.171 3.9697 20.0303C3.82905 19.8897 3.75003 19.6989 3.75003 19.5L3.75003 13.5C3.75003 13.0858 4.08582 12.75 4.50003 12.75C4.91424 12.75 5.25003 13.0858 5.25003 13.5L5.25003 17.6894L9.96971 12.9697C10.2626 12.6768 10.7375 12.6768 11.0304 12.9697C11.3233 13.2626 11.3233 13.7374 11.0304 14.0303L6.3107 18.75L10.5 18.75C10.9143 18.75 11.25 19.0858 11.25 19.5ZM19.5 11.25C19.0858 11.25 18.75 10.9142 18.75 10.5L18.75 6.31066L14.0304 11.0303C13.7375 11.3232 13.2626 11.3232 12.9697 11.0303C12.6768 10.7374 12.6768 10.2626 12.9697 9.96967L17.6894 5.24998L13.5 5.24998C13.0858 5.24998 12.75 4.9142 12.75 4.49998C12.75 4.08577 13.0858 3.74998 13.5 3.74998L19.5 3.74998C19.6989 3.74998 19.8897 3.829 20.0304 3.96965C20.171 4.11031 20.25 4.30107 20.25 4.49998L20.25 10.5C20.25 10.9142 19.9142 11.25 19.5 11.25Z" fill="#202733"/>
<path d="M19.5 11.25C19.9142 11.25 20.25 10.9142 20.25 10.5V4.5C20.25 4.30109 20.1709 4.11038 20.0303 3.96973C19.8896 3.82907 19.6989 3.75 19.5 3.75H13.5C13.0858 3.75002 12.75 4.0858 12.75 4.5C12.75 4.9142 13.0858 5.24998 13.5 5.25H17.6895L12.9697 9.96973C12.6769 10.2626 12.6769 10.7374 12.9697 11.0303C13.2626 11.3232 13.7374 11.3232 14.0303 11.0303L18.75 6.31055V10.5C18.75 10.9142 19.0858 11.25 19.5 11.25Z" fill="#202733"/>
<path d="M10.5 20.25C10.9142 20.25 11.25 19.9142 11.25 19.5C11.25 19.0858 10.9142 18.75 10.5 18.75H6.31055L11.0303 14.0303C11.3232 13.7374 11.3232 13.2626 11.0303 12.9697C10.7374 12.6768 10.2626 12.6768 9.96973 12.9697L5.25 17.6895V13.5C5.25 13.0858 4.91421 12.75 4.5 12.75C4.08579 12.75 3.75 13.0858 3.75 13.5V19.5C3.75 19.6989 3.82907 19.8896 3.96973 20.0303C4.11038 20.1709 4.30109 20.25 4.5 20.25H10.5Z" fill="#202733"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 957 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.25 3C18.4489 3 18.6396 3.07907 18.7803 3.21973C18.9209 3.36038 19 3.55109 19 3.75V7.18848L21.0303 8.91309C21.1709 9.05362 21.2499 9.2446 21.25 9.44336L21.2012 20.2461C21.2009 20.6601 20.8652 20.9961 20.4512 20.9961L3.5 20.9824C3.08579 20.9824 2.75 20.6466 2.75 20.2324V9.43066C2.75007 9.23181 2.82908 9.04099 2.96973 8.90039L5 7.18848V3.75C5 3.33579 5.33579 3 5.75 3H18.25ZM5.38086 19.5508H18.6191L13.6553 14.249H10.3447L5.38086 19.5508ZM15.1201 13.6533L19.75 18.3428L19.7012 10.9424L15.1201 13.6533ZM4.29883 18.2324L8.87891 13.6533L4.25 10.832L4.29883 18.2324ZM6.5 10.4307L10.2266 12.749H13.7734L17.5 10.4307V4.5H6.5V10.4307ZM13 9C13.4142 9 13.75 9.33579 13.75 9.75C13.75 10.1642 13.4142 10.5 13 10.5H9C8.58579 10.5 8.25 10.1642 8.25 9.75C8.25 9.33579 8.58579 9 9 9H13ZM15 6C15.4142 6 15.75 6.33579 15.75 6.75C15.75 7.16421 15.4142 7.5 15 7.5H9C8.58579 7.5 8.25 7.16421 8.25 6.75C8.25 6.33579 8.58579 6 9 6H15Z" fill="#202733"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+16
View File
@@ -0,0 +1,16 @@
name: Newsletter
category: Interface
sub_category: Communication
keywords:
- email
- message
- communication
- send
- e-post
- melding
- kommunikasjon
- sende
- lest
- read
variant: Stroke
created_at: 28.04.2026
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.0068 14.5024C13.2715 14.5026 13.5255 14.6075 13.7129 14.7944L19.5017 20.5706C19.6595 20.728 19.548 20.9975 19.3251 20.9975L4.65308 20.9966C4.43081 20.9966 4.31908 20.7283 4.47566 20.5705L10.2051 14.7983C10.3927 14.6093 10.6477 14.5026 10.9141 14.5024H13.0068ZM20.998 19.3451C20.998 19.5675 20.7293 19.6792 20.5717 19.5222L15.1631 14.1372C15.0485 14.0231 15.0712 13.8313 15.209 13.7466L20.6171 10.4212C20.7837 10.3188 20.998 10.4387 20.998 10.6342V19.3451ZM8.71191 13.7466C8.84841 13.8314 8.8701 14.0213 8.75684 14.1353L3.4293 19.4971C3.27204 19.6554 3.00195 19.5441 3.00195 19.3209V10.6475C3.00195 10.4514 3.21739 10.3316 3.38392 10.4352L8.71191 13.7466ZM18.248 3.00244C18.4468 3.00256 18.6378 3.08162 18.7783 3.22217C18.9188 3.3628 18.998 3.55363 18.998 3.75244V7.35922C18.998 7.42875 19.027 7.49514 19.078 7.54245L20.1735 8.55951C20.2948 8.67212 20.2752 8.86939 20.1341 8.95588L13.7725 12.855C13.6152 12.9513 13.4344 13.0024 13.25 13.0024H10.6699C10.4841 13.0024 10.3016 12.9508 10.1436 12.853L3.85591 8.96112C3.71561 8.87428 3.69644 8.67773 3.81731 8.56541L4.91822 7.54244C4.96913 7.49514 4.99805 7.42878 4.99805 7.3593V3.75244C4.99805 3.33837 5.33403 3.00268 5.74805 3.00244H18.248ZM8.99609 9.00244C8.58188 9.00244 8.24609 9.33823 8.24609 9.75244C8.24609 10.1667 8.58188 10.5024 8.99609 10.5024H12.9961C13.4103 10.5024 13.7461 10.1667 13.7461 9.75244C13.7461 9.33823 13.4103 9.00244 12.9961 9.00244H8.99609ZM8.99609 6.00244C8.58188 6.00244 8.24609 6.33823 8.24609 6.75244C8.24609 7.16666 8.58188 7.50244 8.99609 7.50244H14.9961C15.4103 7.50244 15.7461 7.16666 15.7461 6.75244C15.7461 6.33823 15.4103 6.00244 14.9961 6.00244H8.99609Z" fill="#202733"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,16 @@
name: NewsletterFill
category: Interface
sub_category: Communication
keywords:
- email
- message
- communication
- send
- e-post
- melding
- kommunikasjon
- sende
- lest
- read
variant: Fill
created_at: 28.04.2026
+2 -1
View File
@@ -1,3 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.5 20.25C10.0858 20.25 9.74998 19.9142 9.74998 19.5L9.74999 15.3107L5.03035 20.0303C4.73745 20.3232 4.26258 20.3232 3.96969 20.0303C3.67679 19.7374 3.67679 19.2626 3.96968 18.9697L8.68935 14.25L4.5 14.25C4.08579 14.25 3.75 13.9142 3.75 13.5C3.75 13.0858 4.08579 12.75 4.5 12.75L10.5 12.75C10.6989 12.75 10.8897 12.829 11.0303 12.9697C11.171 13.1103 11.25 13.3011 11.25 13.5L11.25 19.5C11.25 19.9142 10.9142 20.25 10.5 20.25ZM20.25 10.5C20.25 10.9142 19.9142 11.25 19.5 11.25H13.5C13.0858 11.25 12.75 10.9142 12.75 10.5V4.5C12.75 4.08579 13.0858 3.75 13.5 3.75C13.9142 3.75 14.25 4.08579 14.25 4.5V8.68935L18.9697 3.96967C19.2626 3.67678 19.7375 3.67678 20.0303 3.96967C20.3232 4.26256 20.3232 4.73744 20.0303 5.03033L15.3107 9.74999H19.5C19.9142 9.74999 20.25 10.0858 20.25 10.5Z" fill="#202733"/>
<path d="M19.5 11.25C19.9142 11.25 20.25 10.9142 20.25 10.5C20.25 10.0858 19.9142 9.75 19.5 9.75H15.3105L20.0303 5.03027C20.3232 4.73738 20.3232 4.26262 20.0303 3.96973C19.7374 3.67685 19.2626 3.67684 18.9697 3.96973L14.25 8.68945V4.5C14.25 4.08579 13.9142 3.75 13.5 3.75C13.0858 3.75001 12.75 4.08579 12.75 4.5V10.5C12.75 10.9142 13.0858 11.25 13.5 11.25H19.5Z" fill="#202733"/>
<path d="M10.5 20.25C10.9142 20.25 11.25 19.9142 11.25 19.5V13.5C11.25 13.3011 11.1709 13.1104 11.0303 12.9697C10.8896 12.8291 10.6989 12.75 10.5 12.75H4.5C4.08579 12.75 3.75 13.0858 3.75 13.5C3.75001 13.9142 4.0858 14.25 4.5 14.25H8.68945L3.96973 18.9697C3.67685 19.2626 3.67685 19.7374 3.96973 20.0303C4.26261 20.3232 4.73738 20.3232 5.03027 20.0303L9.75 15.3105V19.5C9.75 19.9142 10.0858 20.25 10.5 20.25Z" fill="#202733"/>
</svg>

Before

Width:  |  Height:  |  Size: 953 B

After

Width:  |  Height:  |  Size: 910 B

+4 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-icons",
"version": "8.10.2",
"version": "8.11.1",
"author": "Aksel | Nav",
"homepage": "https://aksel.nav.no/ikoner",
"repository": {
@@ -78,18 +78,17 @@
"adm-zip": "^0.5.10",
"concurrently": "9.2.1",
"copyfiles": "^2.4.1",
"cross-zip-cli": "1.0.0",
"fast-glob": "3.3.3",
"hast-util-select": "6.0.4",
"js-yaml": "^4.1.1",
"lodash": "4.18.1",
"prettier": "3.8.1",
"react": "19.2.5",
"react": "19.2.6",
"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"
}
}
+12
View File
@@ -1,5 +1,17 @@
# @navikt/aksel-stylelint
## 8.11.1
## 8.11.0
## 8.10.6
## 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.11.1",
"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.11.1",
"@navikt/ds-tokens": "^8.11.1",
"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"
}
}
+48
View File
@@ -1,5 +1,53 @@
# @navikt/aksel
## 8.11.1
### Patch Changes
- Updated dependencies [[`54634ff`](https://github.com/navikt/aksel/commit/54634ffed97000a96b375820a4693413ddb78066), [`a375059`](https://github.com/navikt/aksel/commit/a375059cdb983c95c0ab6004cc81e5eee3516833)]:
- @navikt/ds-tokens@8.11.1
- @navikt/ds-css@8.11.1
## 8.11.0
### Patch Changes
- Updated dependencies [[`dc5299b`](https://github.com/navikt/aksel/commit/dc5299b1f95bd53ae0b0cd9c48aa20c87eb1438f)]:
- @navikt/ds-css@8.11.0
- @navikt/ds-tokens@8.11.0
## 8.10.6
### Patch Changes
- Updated dependencies [[`1f39de4`](https://github.com/navikt/aksel/commit/1f39de488fa3b1942c5e81cf22da3cf4c93bde26)]:
- @navikt/ds-css@8.10.6
- @navikt/ds-tokens@8.10.6
## 8.10.5
### Patch Changes
- Updated dependencies [[`35295e7`](https://github.com/navikt/aksel/commit/35295e7c010bd4866c3cd4ae9ff72641a234b089), [`0522dfa`](https://github.com/navikt/aksel/commit/0522dfae59320477b10cab2941d0b004d280d79a)]:
- @navikt/ds-css@8.10.5
- @navikt/ds-tokens@8.10.5
## 8.10.4
### Patch Changes
- Updated dependencies []:
- @navikt/ds-css@8.10.4
- @navikt/ds-tokens@8.10.4
## 8.10.3
### Patch Changes
- Updated dependencies [[`92f6d9e`](https://github.com/navikt/aksel/commit/92f6d9e93b879962df8415f7fad22203174dd3a8)]:
- @navikt/ds-css@8.10.3
- @navikt/ds-tokens@8.10.3
## 8.10.2
### Patch Changes
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel",
"version": "8.10.2",
"version": "8.11.1",
"description": "Aksel command line interface. Codemods and other utilities for Aksel users.",
"author": "Aksel | Nav designsystem team",
"license": "MIT",
@@ -40,15 +40,15 @@
"url": "https://github.com/navikt/aksel/issues"
},
"dependencies": {
"@navikt/ds-css": "^8.10.2",
"@navikt/ds-tokens": "^8.10.2",
"@navikt/ds-css": "^8.11.1",
"@navikt/ds-tokens": "^8.11.1",
"chalk": "5.6.2",
"cli-progress": "3.12.0",
"clipboardy": "5.0.1",
"commander": "14.0.3",
"enquirer": "2.4.1",
"fast-glob": "3.3.3",
"figlet": "1.10.0",
"figlet": "1.11.0",
"is-git-clean": "1.1.0",
"jscodeshift": "17.3.0",
"jscodeshift-add-imports": "1.0.11",
@@ -59,8 +59,8 @@
"@types/is-git-clean": "1.1.2",
"@types/jscodeshift": "17.3.0",
"rimraf": "6.1.3",
"typescript": "6.0.2",
"vitest": "4.1.4"
"typescript": "6.0.3",
"vitest": "4.1.5"
},
"sideEffects": false,
"engines": {
-1
View File
@@ -1 +0,0 @@
!*.d.ts
+34
View File
@@ -1,5 +1,39 @@
# @navikt/ds-css
## 8.11.1
### Patch Changes
- DataGrid: Loading-bar now stays stuck to sticky-header. ([#4897](https://github.com/navikt/aksel/pull/4897))
## 8.11.0
### Minor Changes
- DataGrid: New `preview` of DataGrid and DataGrid.Table-components ([#4885](https://github.com/navikt/aksel/pull/4885))
## 8.10.6
### Patch Changes
- 🎨 InfoCard: Added bigger padding for medium sized InfoCard.Message ([#4861](https://github.com/navikt/aksel/pull/4861))
## 8.10.5
### Patch Changes
- Pagination: Fix selected-styling in forced-colors mode ([#4858](https://github.com/navikt/aksel/pull/4858))
- Button: Fix issue where secondary and tertiary variants did not have correct text color is certain cases ([#4857](https://github.com/navikt/aksel/pull/4857))
## 8.10.4
## 8.10.3
### Patch Changes
- Add module declaration to avoid TypeScript error when importing ([`92f6d9e`](https://github.com/navikt/aksel/commit/92f6d9e93b879962df8415f7fad22203174dd3a8))
## 8.10.2
### Patch Changes
+14 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-css",
"version": "8.10.2",
"version": "8.11.1",
"description": "CSS for Nav Designsystem",
"author": "Aksel | Nav designsystem team",
"keywords": [
@@ -14,7 +14,9 @@
"files": [
"*",
"!dist/version",
"!config/bundle.js"
"!config",
"!.gitignore",
"!CHANGELOG.md"
],
"repository": {
"type": "git",
@@ -30,9 +32,9 @@
"css:get-version": "node config/get-version.js"
},
"devDependencies": {
"@navikt/ds-tokens": "^8.10.2",
"@navikt/ds-tokens": "^8.11.1",
"browserslist": "^4.25.0",
"esbuild": "^0.27.4",
"esbuild": "^0.28.0",
"fast-glob": "3.3.3",
"lightningcss": "^1.30.1",
"normalize.css": "^8.0.1",
@@ -46,9 +48,13 @@
],
"main": "./dist/index.css",
"exports": {
".": "./dist/index.css",
"./dist/*": "./dist/*",
"./tokens.json": "./tokens.json",
"./config/_mappings": "./config/_mappings.js"
".": {
"types": "./types.d.ts",
"default": "./dist/index.css"
},
"./dist/*": {
"types": "./types.d.ts",
"default": "./dist/*"
}
}
}
+7 -3
View File
@@ -215,13 +215,17 @@
display: flex;
align-items: flex-start;
gap: var(--ax-space-8);
padding: var(--ax-space-12) var(--ax-space-16);
padding: var(--ax-space-16) var(--ax-space-20);
& > .aksel-base-alert__icon {
margin-top: var(--ax-space-2);
}
.aksel-base-alert[data-size="small"] & > .aksel-base-alert__icon {
margin-top: 0;
.aksel-base-alert[data-size="small"] & {
padding: var(--ax-space-12) var(--ax-space-16);
& > .aksel-base-alert__icon {
margin-top: 0;
}
}
}
+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 {
@@ -1,6 +1,14 @@
/* stylelint-disable csstools/value-no-unknown-custom-properties */
/* stylelint-disable no-duplicate-selectors */
.aksel-data-grid {
flex-grow: 1;
display: flex;
flex-direction: column;
width: 100%;
overflow: hidden;
}
.aksel-data-table__border-wrapper {
/* display:flex and max-height:100% is needed for a "no page scroll" layout */
display: flex;
@@ -11,6 +19,7 @@
overflow: auto;
width: 100%;
position: relative;
scroll-timeline: --horizontal-table-scroll inline;
}
.aksel-data-table {
@@ -34,14 +43,20 @@
min-width: 100%;
}
--__axc-data-table-density: var(--ax-space-16);
/* Default font size is prop "medium", which is variable --ax-font-size-large */
--__axc-data-table-text-size: var(--ax-font-size-large);
--__axc-data-table-density: var(--ax-space-12);
&[data-density="condensed"] {
--__axc-data-table-density: var(--ax-space-8);
&[data-text-size="small"] {
--__axc-data-table-text-size: var(--ax-font-size-medium);
}
&[data-density="spacious"] {
--__axc-data-table-density: var(--ax-space-32);
&[data-density="tight"] {
--__axc-data-table-density: var(--ax-space-6);
}
&[data-density="loose"] {
--__axc-data-table-density: var(--ax-space-20);
}
&[data-zebra-stripes="true"] {
@@ -53,16 +68,23 @@
.aksel-data-table__thead {
background-color: var(--ax-bg-raised);
position: relative; /* Shadow ends up behind loading/empty-state without, consider after-element instead like with sticky */
box-shadow: 0 1px 0 0 var(--ax-border-neutral-subtle);
.aksel-data-table[data-loading="true"] &::after {
content: "";
position: absolute;
block-size: 2px;
inset-block-end: -2px;
background-color: var(--ax-bg-strong);
animation: animateDataTableLoading 1s linear infinite;
z-index: 11;
}
&[data-sticky="true"] {
position: sticky;
top: 0;
z-index: 3;
}
}
.aksel-data-table__tbody {
@@ -70,12 +92,7 @@
position: relative;
&[inert] {
transition: opacity 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
opacity: var(--ax-opacity-disabled);
@starting-style {
opacity: 0.001;
}
}
&[inert]::after {
@@ -83,15 +100,8 @@
position: absolute;
inset: 0;
background-color: var(--ax-bg-overlay);
transition: opacity 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
z-index: 10;
z-index: 2;
opacity: 0.2;
/* iOS 26+: Ensure the backdrop covers the entire visible viewport. */
@starting-style {
opacity: 0.001;
}
}
}
@@ -99,21 +109,6 @@
&[data-selected="true"] {
background-color: var(--ax-bg-softA);
}
.aksel-data-table__thead &[data-sticky="true"] {
position: sticky;
top: 0;
z-index: 3;
&::after {
content: "";
position: absolute;
inset-inline: 0;
bottom: -1px;
pointer-events: none;
border-bottom: 1px solid var(--ax-border-neutral-subtle);
}
}
}
/* BORDER STYLING START */
@@ -141,9 +136,25 @@
border-bottom: 1px solid var(--ax-border-neutral-subtle);
}
.aksel-data-table__thead {
position: relative; /* Shadow ends up behind loading/empty-state without, consider after-element instead like with sticky */
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 {
@@ -153,11 +164,16 @@
vertical-align: middle;
scroll-margin: var(--ax-space-0);
padding: 0;
font-size: var(--__axc-data-table-text-size);
&:not(.aksel-data-table__column-header) {
overflow: hidden; /* Needed for cases where left margin is added to cell-content (nested rows) */
}
&[data-nested="true"] {
position: relative;
& > div {
& > .aksel-data-table__cell-content {
margin-inline-start: calc(32px + 8px + calc(var(--__axc-data-table-nested-depth) * var(--ax-space-16)));
}
}
@@ -195,7 +211,25 @@
&[data-sticky="start"] {
left: 0;
&::after {
&[data-sticky-last]::before {
content: "";
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
box-shadow: 4px 0 8px 1px var(--ax-border-neutral-subtleA);
clip-path: inset(0 -24px 0 0);
opacity: 0;
}
.aksel-data-table[data-scroll] &[data-sticky-last]::before {
opacity: 1;
animation: scroll-shadow-fade-start 1s linear;
animation-timeline: --horizontal-table-scroll;
}
&[data-sticky-last]::after {
inset-inline: 0 -1px;
border-right-width: 1px;
}
@@ -203,10 +237,32 @@
&[data-sticky="end"] {
right: 0;
&::before {
content: "";
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
box-shadow: -4px 0 8px 1px var(--ax-border-neutral-subtleA);
clip-path: inset(0 0 0 -24px);
opacity: 0;
}
.aksel-data-table[data-scroll] &::before {
opacity: 1;
animation: scroll-shadow-fade-end 1s linear;
animation-timeline: --horizontal-table-scroll;
}
}
background-color: var(--ax-bg-raised);
.aksel-data-table__tr[data-selected="true"] & {
background-color: var(--ax-bg-soft);
}
.aksel-data-table__thead & {
background-color: var(--ax-bg-neutral-soft);
}
@@ -220,10 +276,10 @@
outline-offset: -4px;
}
&[data-selectable="true"] {
&[data-cell-type="action"] {
padding: 0;
& > div {
& > .aksel-data-table__cell-content {
display: grid;
place-content: center;
}
@@ -231,14 +287,14 @@
--__axc-data-table-pi-cell: var(--ax-space-16);
&:not([data-selectable="true"]) > div {
&:not([data-cell-type="action"]) > .aksel-data-table__cell-content {
padding-block: var(--__axc-data-table-density);
padding-inline: var(--__axc-data-table-pi-cell);
overflow: hidden;
.aksel-data-table[data-truncate-content="true"] & {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
}
}
@@ -252,11 +308,11 @@
position: relative;
font-weight: var(--ax-font-weight-bold);
&:has(.aksel-data-table__th-sort-button:hover) {
&:has(.aksel-data-table__th-sort-button:hover:not(:disabled)) {
background-color: var(--ax-bg-neutral-moderate-hover);
}
&:has(.aksel-data-table__th-sort-button:active) {
&:has(.aksel-data-table__th-sort-button:active:not(:disabled)) {
background-color: var(--ax-bg-neutral-moderate-pressed);
}
@@ -282,6 +338,11 @@
&:focus-visible {
outline: none;
}
&:disabled {
color: inherit;
cursor: default;
}
}
.aksel-data-table__th-sort-icon {
@@ -292,7 +353,7 @@
visibility: hidden;
}
.aksel-data-table__th-sort-button:is(:hover, :focus-visible, :focus-within) & {
.aksel-data-table__th-sort-button:is(:hover, :focus-visible, :focus-within):not(:disabled) & {
visibility: visible;
}
@@ -377,11 +438,11 @@
transform: translateY(-50%);
height: 1.5rem;
&.aksel-data-table__th-resize-handle-indicator--start {
&:first-child {
right: 12px;
}
&.aksel-data-table__th-resize-handle-indicator--end {
&:last-child {
left: 12px;
}
}
@@ -429,10 +490,10 @@
outline-offset: -4px;
}
&[data-selectable="true"] {
&[data-cell-type="action"] {
padding: 0;
& > div {
& > .aksel-data-table__cell-content {
display: grid;
place-content: center;
}
@@ -444,9 +505,14 @@
}
.aksel-data-table__filler-cell {
width: auto;
display: table-cell;
padding: 0;
width: auto;
}
.aksel-data-table__drag-and-drop-root {
padding: 0;
margin: 0;
}
.aksel-data-drag-and-drop__drag-handler__button {
@@ -469,21 +535,11 @@
}
.aksel-data-table__drag-and-drop-item {
background-color: var(--ax-bg-default);
&[data-overlay="true"] {
width: 100%;
opacity: 0.8;
box-shadow: var(--ax-shadow-dialog);
}
/* TODO - might be removable */
&[data-keyboard-dragging="true"] {
.aksel-data-drag-and-drop__drag-handler__button {
outline: 2px solid var(--ax-border-focus);
outline-offset: -1px;
border-radius: var(--ax-radius-8);
}
background-color: var(--ax-bg-default);
}
&[data-drop-target="true"]:not([data-overlay="true"]) {
@@ -491,7 +547,6 @@
}
}
.aksel-data-drag-and-drop__drag-handler__alt,
.aksel-data-drag-and-drop__drag-handler {
position: relative;
height: 1.5rem;
@@ -593,3 +648,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
@@ -31,6 +31,7 @@
@import "./chat.css" layer(aksel.components.modules);
@import "./copybutton.css" layer(aksel.components.modules);
@import "./date.css" layer(aksel.components.modules);
@import "./data-grid-preview.css" layer(aksel.components.modules);
@import "./dialog.css" layer(aksel.components.modules);
@import "./dropdown.css" layer(aksel.components.modules);
@import "./action-menu.css" layer(aksel.components.modules);
+1
View File
@@ -0,0 +1 @@
declare module "@navikt/ds-css" {}
-1
View File
@@ -1,3 +1,2 @@
scripts/*.backup
!**/types/theme.d.ts
*tsbuildinfo
+66
View File
@@ -1,5 +1,71 @@
# @navikt/ds-react
## 8.11.1
### Patch Changes
- DataGrid: Loading-bar now stays stuck to sticky-header. ([#4897](https://github.com/navikt/aksel/pull/4897))
- Popover: Fixed issue where clicking inside container sometimes could lead to Popover closing ([#4909](https://github.com/navikt/aksel/pull/4909))
- DataGrid: Added `columnDisplay` value to settings ([#4908](https://github.com/navikt/aksel/pull/4908))
- DataGrid.Table: Fix auto-width for col with "show nested rows" button ([#4906](https://github.com/navikt/aksel/pull/4906))
- Updated dependencies [[`54634ff`](https://github.com/navikt/aksel/commit/54634ffed97000a96b375820a4693413ddb78066)]:
- @navikt/ds-tokens@8.11.1
- @navikt/aksel-icons@8.11.1
## 8.11.0
### Minor Changes
- DataGrid: New `preview` of DataGrid and DataGrid.Table-components ([#4885](https://github.com/navikt/aksel/pull/4885))
### Patch Changes
- Updated dependencies [[`76e0410`](https://github.com/navikt/aksel/commit/76e04106f792516afc986a003dd6f451f8064f84)]:
- @navikt/aksel-icons@8.11.0
- @navikt/ds-tokens@8.11.0
## 8.10.6
### Patch Changes
- Updated dependencies []:
- @navikt/ds-tokens@8.10.6
- @navikt/aksel-icons@8.10.6
## 8.10.5
### Patch Changes
- Dependencies: Bumped `@floating-ui/react` from v0.27.8 -> v0.27.19 ([#4851](https://github.com/navikt/aksel/pull/4851))
- Updated dependencies []:
- @navikt/ds-tokens@8.10.5
- @navikt/aksel-icons@8.10.5
## 8.10.4
### Patch Changes
- ActionMenu: Now locked to bottom/top side placement, left/right for sub-menus. ([#4843](https://github.com/navikt/aksel/pull/4843))
- Updated dependencies []:
- @navikt/ds-tokens@8.10.4
- @navikt/aksel-icons@8.10.4
## 8.10.3
### Patch Changes
- Radio, Checkbox: Fix regression where `className` was mistakenly passed to `input`-element ([#4837](https://github.com/navikt/aksel/pull/4837))
- Updated dependencies []:
- @navikt/ds-tokens@8.10.3
- @navikt/aksel-icons@8.10.3
## 8.10.2
### Patch Changes
+30 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-react",
"version": "8.10.2",
"version": "8.11.1",
"description": "React components from the Norwegian Labour and Welfare Administration.",
"author": "Aksel, a team part of the Norwegian Labour and Welfare Administration.",
"license": "MIT",
@@ -46,6 +46,26 @@
"default": "./cjs/index.js"
}
},
"./PREVIEW": {
"import": {
"types": "./esm/preview.d.ts",
"default": "./esm/preview.js"
},
"require": {
"types": "./cjs/preview.d.ts",
"default": "./cjs/preview.js"
}
},
"./PREVIEW/DataGrid": {
"import": {
"types": "./esm/data-grid/index.d.ts",
"default": "./esm/data-grid/index.js"
},
"require": {
"types": "./cjs/data-grid/index.d.ts",
"default": "./cjs/data-grid/index.js"
}
},
"./types/theme": {
"types": "./esm/types/theme.d.ts"
},
@@ -698,16 +718,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.11.1",
"@navikt/ds-tokens": "^8.11.1",
"date-fns": "^4.0.0",
"react-day-picker": "9.14.0"
},
@@ -723,18 +743,19 @@
"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",
"react": "19.2.5",
"react-dom": "19.2.5",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-router": "^7.13.1",
"rimraf": "6.1.3",
"swr": "^2.4.1",
"tsc-alias": "1.8.16",
"tsx": "^4.20.6",
"typescript": "6.0.2",
"vitest": "4.1.4"
"typescript": "6.0.3",
"vitest": "4.1.5"
},
"peerDependencies": {
"@types/react": "^17.0.30 || ^18 || ^19",
@@ -384,7 +384,7 @@ export const ActionMenuContent = forwardRef<
{...rest}
align={align}
sideOffset={4}
collisionPadding={10}
collisionPadding={5}
returnFocus={context.triggerRef}
safeZone={{
anchor: context.triggerRef.current,
@@ -184,126 +184,66 @@ export const ColorRole = () => (
</HStack>
<h3>data-color=neutral on wrapper and danger on button</h3>
<HStack gap="space-8" data-color="neutral">
<Button data-color="danger" variant="secondary" icon={<StarIcon />}>
<Button data-color="danger" variant="secondary">
Button
</Button>
<Button data-color="danger" variant="tertiary" icon={<StarIcon />}>
<Button data-color="danger" variant="tertiary">
Button
</Button>
<Button
data-color="danger"
variant="secondary"
icon={<StarIcon />}
disabled
>
<Button data-color="danger" variant="tertiary" data-pressed>
Pressed
</Button>
<Button data-color="danger" variant="secondary" disabled>
Button
</Button>
<Button
data-color="danger"
variant="tertiary"
icon={<StarIcon />}
disabled
>
<Button data-color="danger" variant="tertiary" disabled>
Button
</Button>
</HStack>
<h3>data-color=neutral on parent wrapper and danger on button</h3>
<div data-color="neutral">
<HStack gap="space-8">
<Button data-color="danger" variant="secondary" icon={<StarIcon />}>
Button
</Button>
<Button data-color="danger" variant="tertiary" icon={<StarIcon />}>
Button
</Button>
<Button
data-color="danger"
variant="secondary"
icon={<StarIcon />}
disabled
>
Button
</Button>
<Button
data-color="danger"
variant="tertiary"
icon={<StarIcon />}
disabled
>
Button
</Button>
</HStack>
</div>
<h3>data-color=neutral on button</h3>
<HStack gap="space-8">
<Button data-color="neutral" variant="secondary" icon={<StarIcon />}>
<Button data-color="neutral" variant="secondary">
Button
</Button>
<Button data-color="neutral" variant="tertiary" icon={<StarIcon />}>
<Button data-color="neutral" variant="tertiary">
Button
</Button>
<Button
data-color="neutral"
variant="secondary"
icon={<StarIcon />}
disabled
>
<Button data-color="neutral" variant="tertiary" data-pressed>
Pressed
</Button>
<Button data-color="neutral" variant="secondary" disabled>
Button
</Button>
<Button
data-color="neutral"
variant="tertiary"
icon={<StarIcon />}
disabled
>
<Button data-color="neutral" variant="tertiary" disabled>
Button
</Button>
</HStack>
<h3>data-color=neutral on wrapper</h3>
<HStack gap="space-8" data-color="neutral">
<Button variant="secondary" icon={<StarIcon />}>
<Button variant="secondary">Button</Button>
<Button variant="tertiary">Button</Button>
<Button variant="tertiary" data-pressed>
Pressed
</Button>
<Button variant="secondary" disabled>
Button
</Button>
<Button variant="tertiary" icon={<StarIcon />}>
Button
</Button>
<Button variant="secondary" icon={<StarIcon />} disabled>
Button
</Button>
<Button variant="tertiary" icon={<StarIcon />} disabled>
<Button variant="tertiary" disabled>
Button
</Button>
</HStack>
<h3>data-color=neutral on parent wrapper</h3>
<div data-color="neutral">
<HStack gap="space-8">
<Button variant="secondary" icon={<StarIcon />}>
Button
</Button>
<Button variant="tertiary" icon={<StarIcon />}>
Button
</Button>
<Button variant="secondary" icon={<StarIcon />} disabled>
Button
</Button>
<Button variant="tertiary" icon={<StarIcon />} disabled>
Button
</Button>
</HStack>
</div>
<h3>data-color=neutral on parent wrapper and danger on wrapper</h3>
<div data-color="neutral">
<HStack gap="space-8" data-color="danger">
<Button variant="secondary" icon={<StarIcon />}>
<Button variant="secondary">Button</Button>
<Button variant="tertiary">Button</Button>
<Button variant="tertiary" data-pressed>
Pressed
</Button>
<Button variant="secondary" disabled>
Button
</Button>
<Button variant="tertiary" icon={<StarIcon />}>
Button
</Button>
<Button variant="secondary" icon={<StarIcon />} disabled>
Button
</Button>
<Button variant="tertiary" icon={<StarIcon />} disabled>
<Button variant="tertiary" disabled>
Button
</Button>
</HStack>
@@ -311,16 +251,15 @@ export const ColorRole = () => (
<h3>data-color=neutral on both wrappers</h3>
<div data-color="neutral">
<HStack gap="space-8" data-color="neutral">
<Button variant="secondary" icon={<StarIcon />}>
<Button variant="secondary">Button</Button>
<Button variant="tertiary">Button</Button>
<Button variant="tertiary" data-pressed>
Pressed
</Button>
<Button variant="secondary" disabled>
Button
</Button>
<Button variant="tertiary" icon={<StarIcon />}>
Button
</Button>
<Button variant="secondary" icon={<StarIcon />} disabled>
Button
</Button>
<Button variant="tertiary" icon={<StarIcon />} disabled>
<Button variant="tertiary" disabled>
Button
</Button>
</HStack>
@@ -0,0 +1,205 @@
import { Meta } from "@storybook/react-vite";
import React, { useState } from "react";
import { Chips } from ".";
import { VStack } from "../primitives/stack";
import { renderStoriesForChromatic } from "../utils/renderStoriesForChromatic";
const meta: Meta<typeof Chips> = {
title: "ds-react/Chips",
component: Chips,
parameters: {
chromatic: { disable: true },
},
};
export default meta;
const options = ["Norsk", "Dansk", "Svensk", "Tysk", "Spansk"];
export const Default = (props) => {
const [selected, setSelected] = useState(["Dansk", "Svensk"]);
const [filter, setFilter] = useState(options);
if (props.type === "toggle") {
return (
<Chips size={props.size ?? "medium"}>
{options.map((c) => (
<Chips.Toggle
selected={selected.includes(c)}
key={c}
onClick={() =>
setSelected(
selected.includes(c)
? selected.filter((x) => x !== c)
: [...selected, c],
)
}
>
{c}
</Chips.Toggle>
))}
</Chips>
);
}
return (
<Chips size={props.size ?? "medium"}>
{filter.map((c) => (
<Chips.Removable
key={c}
onClick={() => setFilter((x) => x.filter((y) => y !== c))}
>
{c}
</Chips.Removable>
))}
</Chips>
);
};
Default.args = {
type: "toggle",
};
Default.argTypes = {
type: {
control: { type: "radio" },
options: ["toggle", "removable"],
},
size: {
control: { type: "radio" },
options: ["medium", "small"],
},
};
export const Toggle = () => {
const [selected, setSelected] = useState<number[]>([2, 4]);
return (
<Chips>
{options.map((c, y) => (
<Chips.Toggle
selected={selected.includes(y)}
onClick={() =>
setSelected(
selected.includes(y)
? selected.filter((x) => x !== y)
: [...selected, y],
)
}
key={y}
>
{c}
</Chips.Toggle>
))}
</Chips>
);
};
export const ToggleNoCheckmark = () => {
const [selected, setSelected] = useState<number>(2);
return (
<Chips>
{options.map((c, y) => (
<Chips.Toggle
selected={selected === y}
checkmark={false}
onClick={() => setSelected(y)}
key={y}
>
{c}
</Chips.Toggle>
))}
</Chips>
);
};
export const Removable = () => {
return (
<Chips>
{options.map((c, y) => (
<Chips.Removable key={y}>{c}</Chips.Removable>
))}
</Chips>
);
};
export const Medium = (props: any) => {
const [selected, setSelected] = useState<number[]>([2]);
return (
<VStack gap="space-16">
<Chips>
{options.map((c, y) => (
<Chips.Removable key={y} {...props}>
{c}
</Chips.Removable>
))}
</Chips>
<Chips>
{options.map((c, y) => (
<Chips.Toggle
{...props}
selected={selected.includes(y)}
onClick={() =>
setSelected(
selected.includes(y)
? selected.filter((x) => x !== y)
: [...selected, y],
)
}
key={y}
>
{c}
</Chips.Toggle>
))}
</Chips>
</VStack>
);
};
export const Small = () => {
const [selected, setSelected] = useState<number[]>([2, 4]);
return (
<div className="colgap">
<Chips size="small">
{options.map((c, y) => (
<Chips.Removable key={y}>{c}</Chips.Removable>
))}
</Chips>
<Chips size="small">
{options.map((c, y) => (
<Chips.Toggle
selected={selected.includes(y)}
onClick={() =>
setSelected(
selected.includes(y)
? selected.filter((x) => x !== y)
: [...selected, y],
)
}
key={y}
>
{c}
</Chips.Toggle>
))}
</Chips>
</div>
);
};
export const ColorRole = () => (
<VStack gap="space-24">
<div>
<Medium />
</div>
<div data-color="brand-magenta">
<Medium />
</div>
<div>
<Medium data-color="brand-beige" />
</div>
</VStack>
);
export const Chromatic = renderStoriesForChromatic({
Medium,
Small,
ColorRole,
ToggleNoCheckmark,
});
+6 -2
View File
@@ -1,7 +1,11 @@
"use client";
export { default as Chips, ChipsRemovable, ChipsToggle } from "./Chips";
export {
default as Chips,
ChipsRemovable,
ChipsToggle,
} from "./root/ChipsRoot";
export type {
ChipsProps,
ChipsRemovableProps,
ChipsToggleProps,
} from "./Chips";
} from "./root/ChipsRoot";
@@ -1,8 +1,8 @@
import React, { forwardRef } from "react";
import { XMarkIcon } from "@navikt/aksel-icons";
import { AkselColor } from "../types";
import { cl, composeEventHandlers } from "../utils/helpers";
import { useI18n } from "../utils/i18n/i18n.hooks";
import { AkselColor } from "../../types";
import { cl, composeEventHandlers } from "../../utils/helpers";
import { useI18n } from "../../utils/i18n/i18n.hooks";
export interface ChipsRemovableProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: string;
@@ -1,7 +1,9 @@
import React, { HTMLAttributes, forwardRef } from "react";
import { cl } from "../utils/helpers";
import RemovableChips, { ChipsRemovableProps } from "./Removable";
import ToggleChips, { ChipsToggleProps } from "./Toggle";
import { cl } from "../../utils/helpers";
import RemovableChips, {
ChipsRemovableProps,
} from "../removable/ChipsRemovable";
import ToggleChips, { ChipsToggleProps } from "../toggle/ChipsToggle";
interface ChipsProps extends HTMLAttributes<HTMLUListElement> {
children: React.ReactNode;
@@ -1,7 +1,7 @@
import React, { forwardRef } from "react";
import { AkselColor } from "../types";
import type { OverridableComponent } from "../utils-external";
import { cl } from "../utils/helpers";
import { AkselColor } from "../../types";
import type { OverridableComponent } from "../../utils-external";
import { cl } from "../../utils/helpers";
export interface ChipsToggleProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: string;
@@ -0,0 +1,3 @@
"use client";
export { DataGrid } from "./root/DataGridRoot";
export { DataGridTable } from "../data/table/root/DataGridTableRoot";
@@ -0,0 +1,46 @@
type DataGridSettings = {
/**
* Controls vertical cell padding.
* @default "standard"
*/
rowDensity?: "tight" | "standard" | "loose";
/**
* Zebra striped table
* @default false
*/
zebraStripes?: boolean;
/**
* Truncate content in cells and show ellipsis for overflowed text.
*
* **NB:** When using this together with `layout="auto"`,
* you have to manually set a `maxWidth` on columns that should be truncated.
* @default false if layout="auto", else true
*/
truncateContent?: boolean;
/**
* Sticky columns that remain visible when horizontally scrolling the table.
*
* You can specify 1 sticky column on the left and 1 on the right.
*/
stickyColumns?: {
start?: 1;
end?: 1;
};
/**
* Adjusts font-size
* @default "medium"
*/
textSize?: "small" | "medium";
/**
* Optional configuration for column order and visibility.
*
* If provided, only columns listed here are rendered, and the array order
* determines the rendered column order.
*
* Set `visible: false` to hide a column without removing it from the list.
* Each `id` must be unique and match a column `id` in the `columns` prop.
*/
columnDisplay?: { id: string; visible: boolean }[];
};
export type { DataGridSettings };
@@ -0,0 +1,21 @@
import type { SelectionProps } from "../../data/table/hooks/useTableSelection";
import type { ColumnDefinitions } from "../../data/table/root/DataGridTable.types";
import { createStrictContext } from "../../utils/helpers";
import type { DataGridSettings } from "./DataGrid.types";
type DataGridContextValue<RowT = unknown> = {
data: RowT[];
columnDefinitions: ColumnDefinitions<RowT>;
getRowId?: (rowData: RowT) => string;
selection?: SelectionProps<RowT>;
isLoading?: boolean;
tableSettings?: DataGridSettings;
};
const { Provider: DataGridContextProvider, useContext: useDataGridContext } =
createStrictContext<DataGridContextValue>({
name: "DataGridContext",
errorMessage: "DataGrid hooks must be used within a <DataGrid />",
});
export { DataGridContextProvider, useDataGridContext };
@@ -0,0 +1,154 @@
import React, { forwardRef, useMemo } from "react";
import type { SelectionProps } from "../../data/table/hooks/useTableSelection";
import type { ColumnDefinitions } from "../../data/table/root/DataGridTable.types";
import { DataGridTable } from "../../data/table/root/DataGridTableRoot";
import { cl } from "../../utils/helpers";
import type { DataGridSettings } from "./DataGrid.types";
import { DataGridContextProvider } from "./DataGridRoot.context";
interface DataGridProps<RowT> {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
/**
* Definitions of the columns to display.
*/
columns: ColumnDefinitions<RowT>;
/**
* The data to display.
*
* Each object in the array represents a row, and the properties of the
* object are used to render the cells based on `columnDefinitions`.
*/
data: RowT[];
/**
* Function to get unique row ID from row data.
*
* If not provided, the row index will be used as ID.
* This can cause issues if your data changes dynamically,
* so it's recommended to provide a stable ID if possible.
*/
getRowId?: (rowData: RowT) => string;
/**
* Object with props related to row selection.
*/
selection?: SelectionProps<RowT>;
/**
* Determines if the data grid is in a loading state.
* See `loadingContent` prop on the `DataGrid.Table` component for display settings.
* @default false
*/
isLoading?: boolean;
/**
* Settings for the data grid.
*/
settings?: DataGridSettings;
}
interface DataGridComponent {
<RowT>(
props: DataGridProps<RowT> & React.RefAttributes<HTMLDivElement>,
): React.ReactElement | null;
/**
* @see 🏷️ {@link DataGridTableProps}
*
* @example
* <DataGrid columnDefinitions={columnDefs} data={rowData} getRowId={(row) => row.id}>
* <DataGrid.Table />
* </DataGrid>
*/
Table: typeof DataGridTable;
}
/**
* Component for displaying tabular data.
*
* **WARNING: This component is in active development and may receive breaking changes outside major releases!**
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/datagrid)
* @see 🏷️ {@link DataGridProps}
*
* @example
* ```jsx
* <DataGrid columnDefinitions={columnDefs} data={rowData} getRowId={(row) => row.id}>
* <DataGrid.Table />
* </DataGrid>
* ```
*/
const DataGridInternal = forwardRef<HTMLDivElement, DataGridProps<any>>(
(
{
children,
className,
columns,
data,
getRowId,
selection,
isLoading = false,
settings,
...rest
}: DataGridProps<unknown>,
ref,
) => {
const resolvedSettings = useMemo(
() => ({
rowDensity: settings?.rowDensity ?? "standard",
zebraStripes: settings?.zebraStripes ?? false,
truncateContent: settings?.truncateContent,
stickyColumns: settings?.stickyColumns ?? {},
textSize: settings?.textSize ?? "medium",
columnDisplay: settings?.columnDisplay,
}),
[
settings?.rowDensity,
settings?.zebraStripes,
settings?.truncateContent,
settings?.stickyColumns,
settings?.textSize,
settings?.columnDisplay,
],
);
return (
<div {...rest} ref={ref} className={cl("aksel-data-grid", className)}>
<DataGridContextProvider
columnDefinitions={columns}
data={data}
getRowId={getRowId}
selection={selection}
isLoading={isLoading}
tableSettings={resolvedSettings}
>
{children}
</DataGridContextProvider>
</div>
);
},
);
const DataGrid = DataGridInternal as unknown as DataGridComponent;
DataGrid.Table = DataGridTable;
// eslint-disable-next-line @typescript-eslint/no-namespace, import/export
export namespace DataGrid {
export type Props<T = unknown> = DataGridProps<T>;
export type Columns<T = unknown> = ColumnDefinitions<T>;
export type Selection<T = unknown> = SelectionProps<T>;
export type Settings = DataGridSettings;
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Table {
// eslint-disable-next-line @typescript-eslint/no-shadow
export type Props<T = unknown> = DataGridTable.Props<T>;
export type Sorting = DataGridTable.Sorting;
export type SortEntry = DataGridTable.SortEntry;
export type SortChangeDetail = DataGridTable.SortChangeDetail;
export type LoadingContent = DataGridTable.LoadingContent;
export type SubRows<T = unknown> = DataGridTable.SubRows<T>;
export type DetailsPanel<T = unknown> = DataGridTable.DetailsPanel<T>;
}
}
// eslint-disable-next-line import/export
export { DataGrid, DataGridInternal }; // DataGridRoot needs to be exported b.c. of docgen
export default DataGrid;
@@ -1,104 +0,0 @@
import React from "react";
import {
CaretDownCircleFillIcon,
CaretUpCircleFillIcon,
DragVerticalIcon,
} from "@navikt/aksel-icons";
import { Floating } from "../../../utils/components/floating/Floating";
export interface DataDragAndDropDragHandlerProps {
/**
* Whether the drag handler is disabled
*/
// disabled?: boolean;
/**
* Wether dragging is done by keyboard. Used to conditionally render drag indicators.
*/
keyboardDragging?: boolean;
/**
* Handle ref is forwarded to the button element serving as drag handle.
*/
handleRef: React.Ref<HTMLDivElement>;
// Just for testing purposes, to render an alternative drag handler
alt?: boolean;
}
/**
* DataDragAndDropDragHandler
*
* A button component that serves as a drag handle for drag and drop operations.
* Can be used to initiate dragging of elements in a data table or list.
*/
export const DataDragAndDropDragHandler = React.forwardRef<
HTMLButtonElement,
DataDragAndDropDragHandlerProps
>(({ keyboardDragging, handleRef, alt }) => {
if (alt) {
return (
<div className="aksel-data-drag-and-drop__drag-handler__alt">
{keyboardDragging && (
<span
className="aksel-data-drag-and-drop__drag-handler__arrow"
data-direction="up"
>
<CaretUpCircleFillIcon aria-hidden fontSize="1.2rem" />
</span>
)}
<div
ref={handleRef}
className="aksel-data-drag-and-drop__drag-handler__button"
>
<DragVerticalIcon
aria-hidden
title="Dra for å flytte"
fontSize="1.5rem"
/>
</div>
{keyboardDragging && (
<span
className="aksel-data-drag-and-drop__drag-handler__arrow"
data-direction="down"
>
<CaretDownCircleFillIcon aria-hidden fontSize="1.2rem" />
</span>
)}
</div>
);
}
return (
<Floating>
{keyboardDragging && (
<Floating.Content
side="top"
avoidCollisions={false}
updatePositionStrategy="always"
className="aksel-data-drag-and-drop__drag-handler__arrow"
>
<CaretUpCircleFillIcon aria-hidden fontSize="1.2rem" />
</Floating.Content>
)}
<Floating.Anchor asChild>
<div
ref={handleRef}
className="aksel-data-drag-and-drop__drag-handler__button"
>
<DragVerticalIcon
aria-hidden
title="Dra for å flytte"
fontSize="1.5rem"
/>
</div>
</Floating.Anchor>
{keyboardDragging && (
<Floating.Content
avoidCollisions={false}
updatePositionStrategy="always"
className="aksel-data-drag-and-drop__drag-handler__arrow"
>
<CaretDownCircleFillIcon aria-hidden fontSize="1.2rem" />
</Floating.Content>
)}
</Floating>
);
});
@@ -1,74 +0,0 @@
import { useSortable } from "@dnd-kit/react/sortable";
import React, { useRef } from "react";
import { HStack } from "../../../primitives/stack";
import { cl } from "../../../utils/helpers";
import { useMergeRefs } from "../../../utils/hooks";
import { DataDragAndDropDragHandler } from "../drag-handler/DataDragAndDropDragHandler";
import { DataDragAndDropContext } from "../root/DataDragAndDrop.context";
interface DataDragAndDropItemProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
/**
* Unique id
*/
id: string;
/**
* Index of the item being dragged
*/
index: number;
}
/**
* TODO
*
* @see 🏷️ {@link DataDragAndDropItemProps}
* @example
* ```tsx
* <DragAndDrop.Item numOfSelectedRows={selectedRows.length} onClear={handleClear}>
* TODO
* </DragAndDrop.Item>
* ```
*/
const DataDragAndDropItem = React.forwardRef<
HTMLDivElement,
DataDragAndDropItemProps
>(({ children, id, index, className, ...rest }, forwardedRef) => {
const handleRef = useRef<HTMLDivElement>(null);
const { ref, isDragging, isDropTarget } = useSortable({
id,
index,
handle: handleRef,
});
const mergedRef = useMergeRefs(ref, forwardedRef);
const context = React.useContext(DataDragAndDropContext);
const mouseDragging = isDragging && context?.inputMethod === "mouse";
const mouseDropTarget = isDropTarget && context?.inputMethod === "mouse";
const keyboardDragging = isDragging && context?.inputMethod === "keyboard";
return (
<HStack gap="space-8" align="center" wrap={false} asChild>
{/* TODO Should this be a <li>? */}
<div
ref={mergedRef}
{...rest}
className={cl("aksel-data-table__drag-and-drop-item", className)}
data-dragging={isDragging}
data-mouse-dragging={mouseDragging}
data-keyboard-dragging={keyboardDragging}
data-drop-target={mouseDropTarget}
tabIndex={-1}
>
<DataDragAndDropDragHandler
handleRef={handleRef}
keyboardDragging={keyboardDragging}
alt
/>
<div>{children}</div>
</div>
</HStack>
);
});
export default DataDragAndDropItem;
export { DataDragAndDropItem };
export type { DataDragAndDropItemProps };
@@ -1,11 +0,0 @@
import { createContext } from "react";
interface DataDragAndDropContextType {
inputMethod: "mouse" | "keyboard" | null;
// setInputMethod: (method: "mouse" | "keyboard" | null) => void;
// setItems: React.Dispatch<React.SetStateAction<any[]>>;
}
export const DataDragAndDropContext = createContext<
DataDragAndDropContextType | undefined
>(undefined);
@@ -1,96 +0,0 @@
import { DragDropProvider, DragOverlay } from "@dnd-kit/react";
import { isSortable } from "@dnd-kit/react/sortable";
import React, { forwardRef, isValidElement } from "react";
import { VStack } from "../../../primitives/stack";
import DataDragAndDropItem, {
DataDragAndDropItemProps,
} from "../item/DataDragAndDropItem";
import { DataDragAndDropContext } from "./DataDragAndDrop.context";
interface DataDragAndDropProps extends React.HTMLAttributes<HTMLDivElement> {
children: any[];
setItems: React.Dispatch<React.SetStateAction<any[]>>;
}
interface DataDragAndDropRootComponent extends React.ForwardRefExoticComponent<
DataDragAndDropProps & React.RefAttributes<HTMLDivElement>
> {
/**
* @see 🏷️ {@link DataDragAndDropItemProps}
* * @example
* ```jsx
* <DragAndDrop>
* <DragAndDrop.Item id="1" index={0}>
* ...
* </DragAndDrop.Item>
* </DragAndDrop>
* ```
*/
Item: typeof DataDragAndDropItem;
}
const DataDragAndDrop = forwardRef<HTMLDivElement, DataDragAndDropProps>(
({ setItems, children, ...rest }, forwardedRef) => {
const [inputMethod, setInputMethod] = React.useState<
"mouse" | "keyboard" | null
>(null);
const setItemOrder = (initalIndex: number, targetIndex: number) => {
setItems((items) => {
const newItems = [...items];
const [movedItem] = newItems.splice(initalIndex, 1);
newItems.splice(targetIndex, 0, movedItem);
return newItems;
});
};
return (
<DataDragAndDropContext.Provider value={{ inputMethod }}>
<DragDropProvider
// TODO Look into overriding default keybinds, might eliminate context need
onBeforeDragStart={(event) =>
setInputMethod(
event.operation.activatorEvent?.type === "pointerdown"
? "mouse"
: "keyboard",
)
}
onDragOver={(event) => {
if (event.operation.activatorEvent?.type === "pointerdown") {
// Prevents items to rearrange while dragging with mouse, but allows keyboard dragging to work as intended
event.preventDefault();
}
}}
onDragEnd={(event) => {
const { source, target } = event.operation;
if (!isSortable(source) || !isSortable(target)) return;
setItemOrder(source.initialIndex, target.index);
}}
>
<VStack asChild gap="space-12">
<div {...rest} ref={forwardedRef}>
{children}
</div>
</VStack>
<DragOverlay dropAnimation={null}>
{(source) => {
// Overlay not needed and causes glitching when using keyboard
if (inputMethod === "keyboard") return null;
if (!isSortable(source)) return null;
if (isValidElement(children[source.initialIndex])) {
return children[source.initialIndex];
}
return null;
}}
</DragOverlay>
</DragDropProvider>
</DataDragAndDropContext.Provider>
);
},
) as DataDragAndDropRootComponent;
DataDragAndDrop.Item = DataDragAndDropItem;
export { DataDragAndDrop, DataDragAndDropItem };
export default DataDragAndDrop;
export type { DataDragAndDropItemProps, DataDragAndDropProps };
@@ -49,7 +49,7 @@ export const DragAndDropDragHandler = React.forwardRef<
<button
className="aksel-data-drag-and-drop__drag-handler__arrow"
data-direction="up"
onClick={() => context?.onKeyboardDragEnd(-1)}
onClick={() => context?.onKeyboardDragEnd(-1, itemLabel)}
onMouseDown={(e) => e.preventDefault()}
disabled={context?.dragHandlerActive?.index === 0}
type="button"
@@ -58,13 +58,7 @@ export const DragAndDropDragHandler = React.forwardRef<
</button>
)}
<button
// TODO - Bedre formulering av aria-label?
//aria-label={`Flytt element ${item.index + 1}. Trykk Enter eller Mellomrom for å aktivere, deretter piltastene for å flytte elementet.`}
aria-label={
active
? `Flytt element ${itemLabel || item.index + 1}. Bruk piltastene for å flytte elementet.`
: `Flytt element ${itemLabel || item.index + 1}. Trykk Enter eller Mellomrom for å aktivere flytting.`
}
aria-label={`${itemLabel}. Plass ${item.index + 1}.`}
aria-pressed={Boolean(active)}
aria-roledescription="draggable"
type="button"
@@ -77,18 +71,22 @@ 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={() => {
if (context?.activeItem) return;
context?.cancelDrag();
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
// Enter or space, currently active item - end keyboard dragging
event.preventDefault();
context?.setDragHandlerActive(active ? null : item);
context?.onKeyboardDragStart(active ? null : item);
return;
}
@@ -96,19 +94,19 @@ export const DragAndDropDragHandler = React.forwardRef<
if (event.key === "Escape") {
// Cancel dragging
// TODO Handle reset
event.preventDefault();
context?.setDragHandlerActive(null);
context?.setAnnouncer(`Flytting av ${itemLabel} avbrutt.`);
context?.cancelDrag(true);
return;
} else if (event.key === "ArrowUp") {
// Move item up
event.preventDefault();
context?.onKeyboardDragEnd(-1);
context?.onKeyboardDragEnd(-1, itemLabel);
return;
} else if (event.key === "ArrowDown") {
// Move item down
event.preventDefault();
context?.onKeyboardDragEnd(1);
context?.onKeyboardDragEnd(1, itemLabel);
return;
}
}}
@@ -120,7 +118,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 {
@@ -3,14 +3,17 @@ import React, {
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useId } from "../../../utils-external";
import { Floating } from "../../../utils/components/floating/Floating";
import { cl } from "../../../utils/helpers";
import {
ColumnDefinition,
ColumnDefinitions,
} from "../../table/root/DataTable.types";
} from "../../table/root/DataGridTable.types";
import DragAndDropItem, { DragAndDropItemProps } from "../item/DragAndDropItem";
import { DragAndDropElement } from "../types";
import { DragAndDropProvider } from "./DragAndDrop.context";
@@ -22,54 +25,50 @@ interface DragAndDropProps<T> extends React.HTMLAttributes<HTMLUListElement> {
}
/**
* TODO
* [x] setItems on root
* [x] state : active element
* [x] pointer over listener / state, onPointerEnter, onPointerLeave
* [x] Overlay - Use floating component
* [x] Keyboard navigation
* [ ] UU - announce on drag start, item moved, drag end
* [x] Make overlay same width as the OG item, currently jumps to content width
* [x] Look into adding a cancel listener event
* [x] Make onClick work on drag handler button, currently blocked by pointer down/up listeners
* [x] Talk to design about what should happen on ESC key press, currently just cancels dragging, should it also reset position?
* [x] Make arrow icons into buttons that react to keyboard events, currently just decorative
* [x] Keep handler focus after clicking arrows for dragging
* [x] Look into data-based API vs component-based API
* [ ] Should we have hidden instructions for screen readers on how to use the drag and drop, and should we announce the position of the item while dragging?
* [x] Discuss if this component should be generic for drag and drop, or if it should be specifically for tables - just for table for now
* [x] Discuss items type
* [ ] Discuss how to implement label best
* [ ] Quick nav (< > samtidig) - få piltastene til å fungere
* [x] Implement new type for items - ColumnDefinitions<T>
* [ ] Remove announcer div and use a live region component instead
* [ ] Make ESC reset position, not just cancel dragging
* [ ] Make instructions for keyboard users (visible?)
* [ ] Ask design about visible keyboard instructions
* TODO:
*
* Backlog:
* [ ] Quick nav (< > samtidig) - få piltastene til å fungere - ignore?
* [ ] Look at instructions text
*
*/
const DRAG_THRESHOLD = 4; // Minimum movement in pixels to start dragging
function DragAndDropInner<T>(
{ items, setItems, renderItem }: DragAndDropProps<T>,
{ items, setItems, renderItem, className, ...rest }: DragAndDropProps<T>,
forwardedRef: React.ForwardedRef<HTMLUListElement>,
) {
const instructionsId = useId();
const [activeItem, setActiveItem] = useState<DragAndDropElement | null>(null);
const [dropTarget, setDropTarget] = useState<DragAndDropElement | null>(null);
const [dragHandlerActive, setDragHandlerActive] =
useState<DragAndDropElement | null>(null);
const [overlayWidth, setOverlayWidth] = useState<number | null>(null);
const [announcement, setAnnouncement] = useState("");
const activeData = items.find((item) => item.id === activeItem?.id);
const [announcer, setAnnouncer] = useState("");
const initialItemsRef = useRef<ColumnDefinitions<T> | null>(null);
const virtualPositionRef = useRef({ x: 0, y: 0 });
const itemsById = useMemo(
() => new Map(items.map((item) => [item.id, item] as const)),
[items],
);
const activeData = activeItem ? itemsById.get(activeItem.id) : undefined;
const activeItemRef = useRef<DragAndDropElement | null>(null);
const dropTargetRef = useRef<DragAndDropElement | null>(null);
const [virtualRef, setVirtualRef] = useState({
getBoundingClientRect: () =>
DOMRect.fromRect({ width: 0, height: 0, x: 0, y: 0 }),
});
const virtualRef = useMemo(
() => ({
getBoundingClientRect: () =>
DOMRect.fromRect({
width: 0,
height: 0,
x: virtualPositionRef.current.x,
y: virtualPositionRef.current.y,
}),
}),
[],
);
const pendingDragStartRef = useRef<{
item: DragAndDropElement;
@@ -79,6 +78,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,
@@ -103,6 +115,11 @@ function DragAndDropInner<T>(
const setCombinedDropTarget = useCallback(
(item: DragAndDropElement | null) => {
const previous = dropTargetRef.current;
if (previous?.id === item?.id && previous?.index === item?.index) {
return;
}
dropTargetRef.current = item;
setDropTarget(item);
},
@@ -112,8 +129,22 @@ function DragAndDropInner<T>(
const reorderItems = useCallback(
(fromIndex: number, toIndex: number) => {
setItems((currentItems) => {
if (
fromIndex === toIndex ||
fromIndex < 0 ||
toIndex < 0 ||
fromIndex >= currentItems.length ||
toIndex >= currentItems.length
) {
return currentItems;
}
const newItems = [...currentItems];
const [movedItem] = newItems.splice(fromIndex, 1);
if (!movedItem) {
return currentItems;
}
newItems.splice(toIndex, 0, movedItem);
return newItems;
});
@@ -121,9 +152,24 @@ function DragAndDropInner<T>(
[setItems],
);
const cancelDrag = useCallback(
(resetOrder = false) => {
if (resetOrder && initialItemsRef.current) {
setItems(initialItemsRef.current);
}
setOverlayWidth(null);
setDragHandlerActive(null);
setCombinedActiveItem(null);
setCombinedDropTarget(null);
pendingDragStartRef.current = null;
initialItemsRef.current = null;
},
[setItems, setCombinedActiveItem, setCombinedDropTarget],
);
useEffect(() => {
/* This useEffect is used to toggle a class on the html element when dragging,
to prevent cursor issues when dragging over interactive elements,
/* This useEffect is used to toggle a class on the html element when dragging,
to prevent cursor issues when dragging over interactive elements,
and to prevent text selection during dragging. */
if (activeItem) {
@@ -150,12 +196,13 @@ function DragAndDropInner<T>(
const deltaX = Math.abs(event.clientX - pendingStart.startX);
const deltaY = Math.abs(event.clientY - pendingStart.startY);
if (deltaX > DRAG_THRESHOLD || deltaY > DRAG_THRESHOLD) {
if (deltaX >= DRAG_THRESHOLD || deltaY >= DRAG_THRESHOLD) {
if (element) {
element.setPointerCapture(pendingStart.pointerId);
}
setOverlayWidth(element?.getBoundingClientRect().width ?? null);
saveInitialItems();
setCombinedActiveItem(pendingStart.item);
setCombinedDropTarget(pendingStart.item);
pendingDragStartRef.current = null;
@@ -166,15 +213,7 @@ function DragAndDropInner<T>(
const active = activeItemRef.current;
if (!active) return;
setVirtualRef({
getBoundingClientRect: () =>
DOMRect.fromRect({
width: 0,
height: 0,
x: event.clientX,
y: event.clientY,
}),
});
virtualPositionRef.current = { x: event.clientX, y: event.clientY };
const elements = document.elementsFromPoint(event.clientX, event.clientY);
@@ -221,25 +260,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 +280,13 @@ function DragAndDropInner<T>(
setCombinedDropTarget,
setCombinedActiveItem,
reorderItems,
items.length,
saveInitialItems,
cancelDrag,
]);
const onKeyboardDragEnd = (diff: number) => {
const describedBy = cl(rest["aria-describedby"], instructionsId);
const onKeyboardDragEnd = (diff: number, label: string) => {
if (!dragHandlerActive) return;
const targetIndex = dragHandlerActive.index + diff;
@@ -265,11 +294,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,19 +306,35 @@ 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={instructionsId} className="aksel-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="aksel-sr-only" aria-atomic>
{announcer}
</div>
<ul
{...rest}
ref={forwardedRef}
aria-label={rest["aria-label"] ?? "Kolonneinnstillinger"}
aria-describedby={describedBy}
className={cl("aksel-data-table__drag-and-drop-root", className)}
>
{items.map((item, index) => {
return (
<DragAndDropItem
key={item.id}
id={item.id}
index={index}
itemLabel={item.label}
itemLabel={item.header}
>
{renderItem(item, index)}
</DragAndDropItem>
@@ -306,6 +349,7 @@ function DragAndDropInner<T>(
<Floating.Content
align="start"
updatePositionStrategy="always"
aria-hidden
style={{
pointerEvents: "none",
boxSizing: "border-box",
@@ -316,16 +360,13 @@ function DragAndDropInner<T>(
id={activeItem.id}
index={activeItem.index}
isOverlay
itemLabel={activeData.label}
itemLabel={activeData.header}
>
{renderItem(activeData, activeItem.index)}
</DragAndDropItem>
</Floating.Content>
</Floating>
)}
<div aria-live="assertive" className="sr-only" aria-atomic="true">
{announcement}
</div>
</DragAndDropProvider>
);
}
@@ -2,8 +2,3 @@ export interface DragAndDropElement {
id: string;
index: number;
}
export interface DragAndDropItemData {
id: string;
label: string;
}
File diff suppressed because it is too large. Load diff
@@ -1,248 +1,88 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
Table,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import React, { useState } from "react";
import { expect, userEvent, within } from "storybook/test";
import { DataTable } from "../table";
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
import { DataTableProfiler } from "./DataTableProfiler";
import { PersonInfo, columns, sampleData } from "./dummy-data";
import { DataGrid } from "../../data-grid";
import type { ColumnDefinitions } from "../table/root/DataGridTable.types";
import { DataGridTable } from "../table/root/DataGridTableRoot";
const meta: Meta<typeof DataTable> = {
const meta: Meta<typeof DataGridTable> = {
title: "ds-react/Data/Keyboard Navigation",
component: DataTable,
component: DataGridTable,
parameters: {
chromatic: { disable: true },
layout: "padded",
},
decorators: [(Story) => <DataTableProfiler>{Story()}</DataTableProfiler>],
};
export default meta;
type Story = StoryObj<typeof DataTable>;
type Story = StoryObj<typeof DataGridTable>;
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",
bodyCell: ({ 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",
bodyCell: ({ 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",
bodyCell: ({ 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",
bodyCell: () => "Col 4",
},
];
export const Inputs: Story = {
render: () => (
<div style={{ padding: "4rem", display: "grid", gap: "2rem" }}>
<DataTable style={{ width: "100%" }} withKeyboardNav layout="auto">
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader>Col 1</DataTableColumnHeader>
<DataTableColumnHeader>Col 2</DataTableColumnHeader>
<DataTableColumnHeader>Col 3</DataTableColumnHeader>
<DataTableColumnHeader>Col 4</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
<DataTable.Tr>
<DataTable.Td>
<input type="checkbox" data-testid="checkbox-1" />
</DataTable.Td>
<DataTable.Td>
<input
type="text"
placeholder="Col 2"
data-testid="input-col-2"
/>
</DataTable.Td>
<DataTable.Td>Col 3</DataTable.Td>
<DataTable.Td>Col 4</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>
<input type="checkbox" />
</DataTable.Td>
<DataTable.Td>
<input
type="text"
placeholder="Col 2"
defaultValue="Test"
data-testid="input-2"
/>
</DataTable.Td>
<DataTable.Td>
<select data-testid="select">
<option value="">Select</option>
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
</select>
</DataTable.Td>
<DataTable.Td>Col 4</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>
<input type="checkbox" />
</DataTable.Td>
<DataTable.Td>
<textarea placeholder="Col 2" data-testid="textarea-col-2" />
</DataTable.Td>
<DataTable.Td>Col 3</DataTable.Td>
<DataTable.Td>Col 4</DataTable.Td>
</DataTable.Tr>
</DataTable.Tbody>
</DataTable>
</div>
<DataGrid
columns={inputsColumnDef}
data={inputsData}
getRowId={(row) => row.id.toString()}
>
<DataGrid.Table style={{ width: "100%" }} layout="auto" />
</DataGrid>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -308,62 +148,79 @@ 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",
bodyCell: ({ 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",
bodyCell: ({ 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",
bodyCell: ({ 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",
bodyCell: ({ id }) => {
if (id === 2)
return (
<textarea
disabled
defaultValue="Disabled textarea"
data-testid="disabled-textarea"
/>
);
if (id === 3) return "Aria Disabled 4";
return "Row 1 Col 4";
},
},
];
export const DisabledCells: Story = {
render: () => (
<div style={{ padding: "4rem", display: "grid", gap: "2rem" }}>
<DataTable style={{ width: "100%" }} withKeyboardNav layout="auto">
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader>Col 1</DataTableColumnHeader>
<DataTableColumnHeader>Col 2</DataTableColumnHeader>
<DataTableColumnHeader>Col 3</DataTableColumnHeader>
<DataTableColumnHeader>Col 4</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
<DataTable.Tr>
<DataTable.Td>Hidden 1</DataTable.Td>
<DataTable.Td>Hidden 2</DataTable.Td>
<DataTable.Td style={{ display: "none" }}>Hidden 3</DataTable.Td>
<DataTable.Td style={{ visibility: "hidden" }}>
Hidden 4
</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>
<button disabled>Disabled button</button>
</DataTable.Td>
<DataTable.Td data-testid="disabled-input">
<input type="text" disabled defaultValue="Disabled input" />
</DataTable.Td>
<DataTable.Td data-testid="disabled-select">
<select disabled>
<option>Disabled select</option>
</select>
</DataTable.Td>
<DataTable.Td data-testid="disabled-textarea">
<textarea disabled defaultValue="Disabled textarea" />
</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>
<button aria-disabled="true">Aria-Disabled button</button>
</DataTable.Td>
<DataTable.Td>Aria Disabled 2</DataTable.Td>
<DataTable.Td>Aria Disabled 3</DataTable.Td>
<DataTable.Td>Aria Disabled 4</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>Normal 1</DataTable.Td>
<DataTable.Td style={{ visibility: "hidden" }}>
<button disabled>Disabled button</button>
</DataTable.Td>
<DataTable.Td>Normal 3</DataTable.Td>
<DataTable.Td>Normal 4</DataTable.Td>
</DataTable.Tr>
</DataTable.Tbody>
</DataTable>
</div>
<DataGrid
columns={disabledColumnDef}
data={disabledData}
getRowId={(row) => row.id.toString()}
>
<DataGrid.Table layout="auto" />
</DataGrid>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -388,22 +245,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 +269,106 @@ export const DisabledCells: Story = {
},
};
type CacheRow = { id: number; col1: string };
const cacheColumnDef: ColumnDefinitions<CacheRow> = [
{ id: "col1", header: "Col 1", bodyCell: ({ col1 }) => col1 },
{ id: "col2", header: "Col 2", bodyCell: () => "Col 2" },
{
id: "col3",
header: "Col 3",
bodyCell: ({ id }) => (id === 1 ? <input type="checkbox" /> : "Col 3"),
},
{ id: "col4", header: "Col 4", bodyCell: () => "Col 4" },
];
export const Cache: Story = {
render: () => {
const [showThatSingleRow, setShowThatSingleRow] = useState(true);
const data: CacheRow[] = [
{ id: 1, col1: "Row 1" },
...(showThatSingleRow ? [{ id: 2, col1: "Custom row" }] : []),
{ id: 3, col1: "Row 3" },
];
return (
<div style={{ padding: "4rem", display: "grid", gap: "2rem" }}>
<button onClick={() => setShowThatSingleRow((s) => !s)}>
Toggle single row: {showThatSingleRow ? "ON" : "OFF"}
</button>
<DataTable style={{ width: "100%" }} layout="auto">
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader>Col 1</DataTableColumnHeader>
<DataTableColumnHeader>Col 2</DataTableColumnHeader>
<DataTableColumnHeader>Col 3</DataTableColumnHeader>
<DataTableColumnHeader>Col 4</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
<DataTable.Tr>
<DataTable.Td>Col 1</DataTable.Td>
<DataTable.Td>Col 2</DataTable.Td>
<DataTable.Td>
<input type="checkbox" />
</DataTable.Td>
<DataTable.Td>Col 4</DataTable.Td>
</DataTable.Tr>
{showThatSingleRow && (
<DataTable.Tr>
<DataTable.Td>Custom row</DataTable.Td>
<DataTable.Td>Col 2</DataTable.Td>
<DataTable.Td>Col 3</DataTable.Td>
<DataTable.Td>Col 4</DataTable.Td>
</DataTable.Tr>
)}
<DataTable.Tr>
<DataTable.Td>Col 1</DataTable.Td>
<DataTable.Td>Col 2</DataTable.Td>
<DataTable.Td>Col 3</DataTable.Td>
<DataTable.Td>Col 4</DataTable.Td>
</DataTable.Tr>
</DataTable.Tbody>
</DataTable>
<DataGrid
columns={cacheColumnDef}
data={data}
getRowId={(row) => row.id.toString()}
>
<DataGrid.Table layout="auto" />
</DataGrid>
</div>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const { down, up, expectNodeFocus } = keyboardUtils();
const table = canvas.getByRole("table");
const toggleButton = canvas.getByRole("button", {
name: /toggle single row/i,
});
table.focus();
await down();
expectNodeFocus("Row 1");
await down();
expectNodeFocus("Custom row");
await userEvent.click(toggleButton);
table.focus();
await down();
expectNodeFocus("Row 1");
await down();
expectNodeFocus("Row 3");
await userEvent.click(toggleButton);
table.focus();
await down();
expectNodeFocus("Row 3");
await up();
expectNodeFocus("Custom row");
},
};
export const FocusElementInsideTable: Story = {
render: () => {
return (
<div style={{ padding: "4rem", display: "grid", gap: "2rem" }}>
<DataTable style={{ width: "100%" }} withKeyboardNav layout="auto">
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader>Col 1</DataTableColumnHeader>
<DataTableColumnHeader>Col 2</DataTableColumnHeader>
<DataTableColumnHeader>Col 3</DataTableColumnHeader>
<DataTableColumnHeader>Col 4</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
<DataTable.Tr>
<DataTable.Td>Col 1</DataTable.Td>
<DataTable.Td>
<button>Focusable button</button>
</DataTable.Td>
<DataTable.Th>Col 3</DataTable.Th>
<DataTable.Td>Col 4</DataTable.Td>
</DataTable.Tr>
type FocusRow = { id: string };
<DataTable.Tr>
<DataTable.Td>Col 1</DataTable.Td>
<DataTable.Td>Col 2</DataTable.Td>
<DataTable.Td>Col 3</DataTable.Td>
<DataTable.Td>Col 4</DataTable.Td>
</DataTable.Tr>
</DataTable.Tbody>
</DataTable>
</div>
);
const focusData: FocusRow[] = [{ id: "1" }, { id: "2" }];
const focusColumnDef: ColumnDefinitions<FocusRow> = [
{ id: "col1", header: "Col 1", bodyCell: () => "Col 1" },
{
id: "col2",
header: "Col 2",
bodyCell: ({ id }) =>
id === "1" ? <button>Focusable button</button> : "Col 2",
},
{
id: "col3",
header: "Col 3",
bodyCell: () => "Col 3",
isRowHeader: true,
},
{ id: "col4", header: "Col 4", bodyCell: () => "Col 4" },
];
export const FocusElementInsideTable: Story = {
render: () => (
<DataGrid
columns={focusColumnDef}
data={focusData}
getRowId={(row) => row.id}
>
<DataGrid.Table layout="auto" />
</DataGrid>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -515,29 +388,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,12 +1,13 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import React from "react";
import { DataTable } from "../table";
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
import { sampleData } from "./dummy-data";
import { DataGrid } from "../../data-grid";
import { DataGridTable } from "../table";
import type { ColumnDefinitions } from "../table/root/DataGridTable.types";
import { type PersonInfo, sampleData } from "./dummy-data";
const meta: Meta<typeof DataTable> = {
const meta: Meta<typeof DataGridTable> = {
title: "ds-react/Data/Layout",
component: DataTable,
component: DataGridTable,
parameters: {
chromatic: { disable: true },
layout: "padded",
@@ -15,233 +16,225 @@ const meta: Meta<typeof DataTable> = {
export default meta;
type Story = StoryObj<typeof DataTable>;
const columns = [
{
header: "Id",
accessorKey: "id",
},
{
header: "Name",
accessorKey: "name",
},
const columns: ColumnDefinitions<PersonInfo> = [
{ id: "id", header: "Id", bodyCell: (row) => row.name },
{ id: "name", header: "Name", bodyCell: (row) => row.name },
{
id: "nationalId",
header: "National id",
accessorKey: "nationalId",
},
{
header: "Day job",
accessorKey: "dayJob",
},
{
header: "Supervisor",
accessorKey: "supervisor",
bodyCell: (row) => row.nationalId,
},
{ id: "dayJob", header: "Day job", bodyCell: (row) => row.dayJob },
{ id: "supervisor", header: "Supervisor", bodyCell: (row) => row.supervisor },
{
id: "dateReceived",
header: "Date received",
accessorKey: "dateReceived",
bodyCell: (row) => row.dateReceived,
},
{
id: "message",
header: "Message",
accessorKey: "message",
contentMaxWidth: 200,
},
{
header: "Age",
accessorKey: "age",
bodyCell: (row) => row.message,
},
{ id: "age", header: "Age", bodyCell: (row) => row.age },
{
id: "forceSensitive",
header: "Force sensitive",
accessorKey: "forceSensitive",
bodyCell: (row) => (row.forceSensitive ? "Yes" : "No"),
},
{
id: "homeSystem",
header: "Home system",
accessorKey: "homeSystem",
bodyCell: (row) => row.homeSystem,
},
{
id: "skills",
header: "Skills",
accessorKey: "skills",
bodyCell: (row) => row.skills.join(", "),
},
];
export const AutoLayoutMinimal: Story = {
render: () => {
type MinimalRow = {
col1: string;
col2: string;
col3: string;
};
const minimalData: MinimalRow[] = [
{ col1: "Test", col2: "Example", col3: "Demo of small table" },
];
const minimalColumns: ColumnDefinitions<MinimalRow> = [
{ id: "col1", header: "Column 1", bodyCell: (row) => row.col1 },
{ id: "col2", header: "Column 2", bodyCell: (row) => row.col2 },
{ id: "col3", header: "Column 3", bodyCell: (row) => row.col3 },
];
type SortableRow = {
left: string;
center: string;
right: string;
};
const sortableData: SortableRow[] = [
{ left: "Data 1", center: "Yes", right: "100 500" },
{ left: "Data 2", center: "No", right: "2 000 200" },
{ left: "Data 3", center: "Maybe", right: "1 000 200" },
];
export const AutoLayoutMinimal: StoryObj<{
table: DataGrid.Table.Props<MinimalRow>;
grid: Omit<DataGrid.Props<MinimalRow>, "children">;
}> = {
render: (args) => {
return (
<DataTable layout="auto">
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
<DataTable.Tr>
<DataTable.Td>Test</DataTable.Td>
<DataTable.Td>Example</DataTable.Td>
<DataTable.Td>Demo of small table</DataTable.Td>
</DataTable.Tr>
</DataTable.Tbody>
</DataTable>
<DataGrid {...args.grid}>
<DataGrid.Table {...args.table} />
</DataGrid>
);
},
parameters: {
a11y: { disable: true },
controls: { disable: true },
docs: { disable: true },
args: {
table: {
layout: "auto",
},
grid: { data: minimalData, columns: minimalColumns },
},
};
export const AutoLayoutOverflowX: Story = {
render: () => {
export const AutoLayoutOverflowX: StoryObj<{
table: DataGrid.Table.Props<PersonInfo>;
grid: Omit<DataGrid.Props<PersonInfo>, "children">;
}> = {
render: (args) => {
return (
<DataTable layout="auto">
<DataTable.Thead>
<DataTable.Tr>
{columns.map((column) => {
return (
<DataTableColumnHeader key={column.header}>
{column.header}
</DataTableColumnHeader>
);
})}
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
{sampleData.slice(0, 4).map((row) => (
<DataTable.Tr key={row.name}>
{columns.map((column) => (
<DataTable.Td
key={column.accessorKey}
contentMaxWidth={column.contentMaxWidth}
>
{row[column.accessorKey]}
</DataTable.Td>
))}
</DataTable.Tr>
))}
</DataTable.Tbody>
</DataTable>
<DataGrid {...args.grid}>
<DataGrid.Table {...args.table} />
</DataGrid>
);
},
args: {
table: { layout: "auto" },
grid: {
settings: { truncateContent: true },
data: sampleData.slice(0, 4),
columns: columns.map((col) =>
col.id === "message"
? {
...col,
bodyCell: (row: PersonInfo) => (
<div style={{ maxWidth: 200 }}>{row.message}</div>
),
}
: col,
),
},
},
};
export const AutoLayoutNoCellTruncation: Story = {
render: () => {
export const AutoLayoutNoCellTruncation: StoryObj<{
table: DataGrid.Table.Props<PersonInfo>;
grid: Omit<DataGrid.Props<PersonInfo>, "children">;
}> = {
render: (args) => {
return (
<DataTable layout="auto" truncateContent={false}>
<DataTable.Thead>
<DataTable.Tr>
{columns.map((column) => (
<DataTableColumnHeader key={column.header}>
{column.header}
</DataTableColumnHeader>
))}
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
{sampleData.slice(0, 3).map((row) => (
<DataTable.Tr key={row.name}>
{columns.map((column) => (
<DataTable.Td key={column.accessorKey}>
{column.accessorKey === "message"
? row[column.accessorKey].split(" ").slice(0, 4).join(" ") +
"."
: row[column.accessorKey]}
</DataTable.Td>
))}
</DataTable.Tr>
))}
</DataTable.Tbody>
</DataTable>
<DataGrid {...args.grid}>
<DataGrid.Table {...args.table} />
</DataGrid>
);
},
args: {
table: { layout: "auto" },
grid: {
settings: { truncateContent: false },
data: sampleData.slice(0, 3),
columns: columns.map((col) =>
col.id === "message"
? {
...col,
bodyCell: (row: PersonInfo) =>
row.message.split(" ").slice(0, 4).join(" ") + ".",
}
: col,
),
},
},
};
export const AutoLayoutSortable: Story = {
render: () => (
<DataTable layout="auto">
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader sortable>Left</DataTableColumnHeader>
<DataTableColumnHeader sortable>Center</DataTableColumnHeader>
<DataTableColumnHeader sortable>Right</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
<DataTable.Tr>
<DataTable.Td>Data 1</DataTable.Td>
<DataTable.Td>Yes</DataTable.Td>
<DataTable.Td>100 500</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>Data 2</DataTable.Td>
<DataTable.Td>No</DataTable.Td>
<DataTable.Td>2 000 200</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>Data 3</DataTable.Td>
<DataTable.Td>Maybe</DataTable.Td>
<DataTable.Td>1 000 200</DataTable.Td>
</DataTable.Tr>
</DataTable.Tbody>
</DataTable>
),
};
export const FixedLayoutMinimal: Story = {
render: () => {
export const AutoLayoutSortable: StoryObj<{
table: DataGrid.Table.Props<SortableRow>;
grid: Omit<DataGrid.Props<SortableRow>, "children">;
}> = {
render: (args) => {
return (
<DataTable layout="fixed">
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
<DataTable.Tr>
<DataTable.Td>Test</DataTable.Td>
<DataTable.Td>Example</DataTable.Td>
<DataTable.Td>Demo of small table</DataTable.Td>
</DataTable.Tr>
</DataTable.Tbody>
</DataTable>
<DataGrid {...args.grid}>
<DataGrid.Table {...args.table} />
</DataGrid>
);
},
parameters: {
a11y: { disable: true },
controls: { disable: true },
docs: { disable: true },
args: {
table: { layout: "auto" },
grid: {
data: sortableData,
columns: [
{
id: "left",
header: "Left",
isSortable: true,
bodyCell: (row) => row.left,
},
{
id: "center",
header: "Center",
isSortable: true,
bodyCell: (row) => row.center,
},
{
id: "right",
header: "Right",
isSortable: true,
bodyCell: (row) => row.right,
},
],
},
},
};
export const FixedLayoutDynamicWidth: Story = {
render: () => {
export const FixedLayoutMinimal: StoryObj<{
table: DataGrid.Table.Props<MinimalRow>;
grid: Omit<DataGrid.Props<MinimalRow>, "children">;
}> = {
render: (args) => {
return (
<DataTable layout="fixed">
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader width="100%">Column 1</DataTableColumnHeader>
<DataTableColumnHeader width="100%">Column 2</DataTableColumnHeader>
<DataTableColumnHeader width="100%">Column 3</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
<DataTable.Tr>
<DataTable.Td>Test</DataTable.Td>
<DataTable.Td>Example</DataTable.Td>
<DataTable.Td>Demo of small table</DataTable.Td>
</DataTable.Tr>
</DataTable.Tbody>
</DataTable>
<DataGrid {...args.grid}>
<DataGrid.Table {...args.table} />
</DataGrid>
);
},
parameters: {
a11y: { disable: true },
controls: { disable: true },
docs: { disable: true },
args: {
table: { layout: "fixed" },
grid: { data: minimalData, columns: minimalColumns },
},
};
export const FixedLayoutDynamicWidth: StoryObj<{
table: DataGrid.Table.Props<MinimalRow>;
grid: Omit<DataGrid.Props<MinimalRow>, "children">;
}> = {
render: (args) => {
return (
<DataGrid {...args.grid}>
<DataGrid.Table {...args.table} />
</DataGrid>
);
},
args: {
table: { layout: "fixed" },
grid: {
data: minimalData,
columns: minimalColumns.map((col) => ({
...col,
defaultWidth: "100%",
})),
},
},
};
@@ -1,164 +0,0 @@
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 { DataToolbar } from "../toolbar";
import { PersonInfo, sampleData } from "./dummy-data";
const meta: Meta<typeof DataTable> = {
title: "ds-react/Data/No framework",
component: DataTable,
parameters: {
chromatic: { disable: true },
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof DataTable>;
const columns = [
{
header: "Id",
accessorKey: "id",
},
{
header: "Name",
accessorKey: "name",
},
{
header: "National id",
accessorKey: "nationalId",
},
{
header: "Day job",
accessorKey: "dayJob",
},
{
header: "Supervisor",
accessorKey: "supervisor",
},
{
header: "Date received",
accessorKey: "dateReceived",
},
{
header: "Message",
accessorKey: "message",
},
{
header: "Age",
accessorKey: "age",
},
{
header: "Force sensitive",
accessorKey: "forceSensitive",
},
{
header: "Home system",
accessorKey: "homeSystem",
},
{
header: "Skills",
accessorKey: "skills",
},
];
export const WithoutTanstack: Story = {
render: () => {
const [globalFilter, setGlobalFilter] = useState("");
const deferredFilterString = useDeferredValue(globalFilter); // Perf: Makes input rerender independently of table
/* const [columnPinning, setColumnPinning] = useState<{
left: string[];
right: string[];
}>({ left: [], right: [] });*/
const globalFilterLower = deferredFilterString.toLowerCase();
// Perf: Memoize data to avoid rerendering table body on unrelated state changes (the filtering itself is not expensive)
const data = useMemo(
() =>
deferredFilterString
? sampleData.filter((item) =>
Object.values(item).some((value) =>
String(value).toLowerCase().includes(globalFilterLower),
),
)
: sampleData,
[deferredFilterString, globalFilterLower],
);
return (
<VStack gap="space-16">
<DataToolbar
renderInput={
<Search
label="Tekstfilter"
variant="simple"
size="small"
onChange={(e) => setGlobalFilter(e)}
/>
}
/>
<DataTable>
<DataTable.Thead>
<DataTable.Tr>
{columns.map((column) => {
return (
<DataTableColumnHeader
key={column.header}
defaultWidth={150}
//style={{ width: `var(--header-${header.id}-size)` }}
data-key={column.accessorKey}
/* pinningHandler={() => {
const isPinned = columnPinning.left.includes(
column.accessorKey,
);
const newLeft = isPinned
? columnPinning.left.filter(
(key) => key !== column.accessorKey,
)
: [...columnPinning.left, column.accessorKey];
setColumnPinning({
...columnPinning,
left: newLeft,
});
}}
isPinned={columnPinning.left.includes(column.accessorKey)} */
>
{column.header}
</DataTableColumnHeader>
);
})}
</DataTable.Tr>
</DataTable.Thead>
<MemoizedTableBody data={data} />
</DataTable>
</VStack>
);
},
parameters: {
a11y: { disable: true },
controls: { disable: true },
docs: { disable: true },
},
};
const TableBody = ({ data }: { data: PersonInfo[] }) => (
<DataTable.Tbody>
{data.map((row) => (
<DataTable.Tr key={row.name}>
{columns.map((column) => (
<DataTable.Td key={column.accessorKey}>
{row[column.accessorKey]}
</DataTable.Td>
))}
</DataTable.Tr>
))}
</DataTable.Tbody>
);
const MemoizedTableBody = React.memo(TableBody) as typeof TableBody;
@@ -1,173 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
Table,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
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 { DataToolbar } from "../toolbar";
import { PersonInfo, columns, sampleData } from "./dummy-data";
const meta: Meta<typeof DataTable> = {
title: "ds-react/Data/Scroll",
component: DataTable,
parameters: {
chromatic: { disable: true },
layout: "fullscreen",
},
};
export default meta;
type Story = StoryObj<typeof DataTable>;
export const NoPageScroll: Story = {
render: () => {
const table = useReactTable({
columns,
data: sampleData,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
globalFilterFn: "includesString",
getPaginationRowModel: getPaginationRowModel(),
initialState: {
pagination: {
pageIndex: 1,
pageSize: 20,
},
},
state: {},
columnResizeMode: "onChange",
debugTable: true,
debugHeaders: true,
debugColumns: true,
});
return (
<VStack padding="space-16" gap="space-16" height="100vh">
<Box
borderWidth="1"
padding="space-16"
borderColor="neutral"
borderRadius="12"
>
Aktive filtre her
</Box>
<DataToolbar
renderInput={
<Search
label="Tekstfilter"
variant="simple"
size="small"
onChange={(value) => table.setGlobalFilter(value)}
/>
}
/>
<Box flexGrow="1" overflow="hidden">
<DataTable withKeyboardNav>
<DataTable.Thead>
{table.getHeaderGroups().map((headerGroup) => {
return (
<DataTable.Tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<DataTableColumnHeader
key={header.id}
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>
<MemoizedTableBody table={table} />
<DataTable.Tfoot>
{table.getFooterGroups().map((footerGroup) => (
<DataTable.Tr key={footerGroup.id}>
{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>
</Box>
<Box
borderWidth="1"
padding="space-16"
borderColor="neutral"
borderRadius="12"
>
Paginering og sånn her
</Box>
</VStack>
);
},
parameters: {
a11y: { disable: true },
controls: { disable: true },
docs: { disable: true },
},
};
const TableBody = ({ table }: { table: Table<PersonInfo> }) => {
return (
<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;
@@ -1,11 +1,12 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import React from "react";
import { DataTable } from "../table";
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
import { expect, within } from "storybook/test";
import { DataGrid } from "../../data-grid";
import { DataGridTable } from "../table";
const meta: Meta<typeof DataTable> = {
const meta: Meta<typeof DataGridTable> = {
title: "ds-react/Data/Resize",
component: DataTable,
component: DataGridTable,
parameters: {
chromatic: { disable: true },
layout: "padded",
@@ -14,160 +15,274 @@ const meta: Meta<typeof DataTable> = {
export default meta;
type Story = StoryObj<typeof DataTable>;
type Story = StoryObj<DataGrid.Props<Row>>;
type Row = {
left: string;
center: string;
right: string;
};
const testData = [
{
left: "Data 1",
center: "Yes",
right: "100 500",
},
{
left: "Data 2",
center: "No",
right: "2 000 200",
},
{
left: "Data 3",
center: "Maybe",
right: "1 000 200",
},
];
export const Resize: Story = {
render: () => (
<DataTable withKeyboardNav>
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader textAlign="left">Left</DataTableColumnHeader>
<DataTableColumnHeader textAlign="center">
Center
</DataTableColumnHeader>
<DataTableColumnHeader textAlign="right">Right</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
<DataTable.Tr>
<DataTable.Td>Data 1</DataTable.Td>
<DataTable.Td textAlign="center">Yes</DataTable.Td>
<DataTable.Td textAlign="right">100 500</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>Data 2</DataTable.Td>
<DataTable.Td textAlign="center">No</DataTable.Td>
<DataTable.Td textAlign="right">2 000 200</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>Data 3</DataTable.Td>
<DataTable.Td textAlign="center">Maybe</DataTable.Td>
<DataTable.Td textAlign="right">1 000 200</DataTable.Td>
</DataTable.Tr>
</DataTable.Tbody>
</DataTable>
),
render: (args) => {
return (
<DataGrid {...args}>
<DataGrid.Table />
</DataGrid>
);
},
args: {
data: testData,
columns: [
{
id: "left",
header: "Left",
align: "left",
bodyCell: (row) => row.left,
},
{
id: "center",
header: "Center",
align: "center",
bodyCell: (row) => row.center,
},
{
id: "right",
header: "Right",
align: "right",
bodyCell: (row) => row.right,
},
],
},
};
export const ResizeMinMax: Story = {
render: () => (
<DataTable withKeyboardNav>
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader
maxWidth="400px"
minWidth="100px"
textAlign="left"
>
Left
</DataTableColumnHeader>
<DataTableColumnHeader
maxWidth="400px"
minWidth="50px"
textAlign="center"
>
Center
</DataTableColumnHeader>
<DataTableColumnHeader
maxWidth="400px"
minWidth="200px"
textAlign="right"
>
Right
</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
<DataTable.Tr>
<DataTable.Td>Data 1</DataTable.Td>
<DataTable.Td textAlign="center">Yes</DataTable.Td>
<DataTable.Td textAlign="right">100 500</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>Data 2</DataTable.Td>
<DataTable.Td textAlign="center">No</DataTable.Td>
<DataTable.Td textAlign="right">2 000 200</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>Data 3</DataTable.Td>
<DataTable.Td textAlign="center">Maybe</DataTable.Td>
<DataTable.Td textAlign="right">1 000 200</DataTable.Td>
</DataTable.Tr>
</DataTable.Tbody>
</DataTable>
),
args: {
data: testData,
columns: [
{
id: "left",
header: "Left",
align: "left",
width: {
defaultValue: 250,
resizeMin: 100,
resizeMax: 200,
},
bodyCell: (row) => row.left,
},
{
id: "center",
header: "Center",
align: "center",
width: {
resizeMin: 50,
resizeMax: 400,
},
bodyCell: (row) => row.center,
},
{
id: "right",
header: "Right",
align: "right",
width: {
defaultValue: 150,
resizeMin: 200,
resizeMax: 400,
},
bodyCell: (row) => row.right,
},
],
},
};
export const ResizeDefaultStaticWidth: Story = {
render: () => (
<DataTable withKeyboardNav>
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader defaultWidth="300px" textAlign="left">
Left
</DataTableColumnHeader>
<DataTableColumnHeader defaultWidth="300px" textAlign="center">
Center
</DataTableColumnHeader>
<DataTableColumnHeader defaultWidth="300px" textAlign="right">
Right
</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
<DataTable.Tr>
<DataTable.Td>Data 1</DataTable.Td>
<DataTable.Td textAlign="center">Yes</DataTable.Td>
<DataTable.Td textAlign="right">100 500</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>Data 2</DataTable.Td>
<DataTable.Td textAlign="center">No</DataTable.Td>
<DataTable.Td textAlign="right">2 000 200</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>Data 3</DataTable.Td>
<DataTable.Td textAlign="center">Maybe</DataTable.Td>
<DataTable.Td textAlign="right">1 000 200</DataTable.Td>
</DataTable.Tr>
</DataTable.Tbody>
</DataTable>
),
args: {
data: testData,
columns: [
{
id: "left",
header: "Left",
align: "left",
width: { defaultValue: "300px" },
bodyCell: (row) => row.left,
},
{
id: "center",
header: "Center",
align: "center",
width: { defaultValue: "300px" },
bodyCell: (row) => row.center,
},
{
id: "right",
header: "Right",
align: "right",
width: { defaultValue: "300px" },
bodyCell: (row) => row.right,
},
],
},
};
export const ResizeDefaultDynamicWidth: Story = {
render: () => (
<DataTable withKeyboardNav>
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader defaultWidth="100%" textAlign="left">
Left
</DataTableColumnHeader>
<DataTableColumnHeader defaultWidth="100%" textAlign="center">
Center
</DataTableColumnHeader>
<DataTableColumnHeader defaultWidth="100%" textAlign="right">
Right
</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
<DataTable.Tr>
<DataTable.Td>Data 1</DataTable.Td>
<DataTable.Td textAlign="center">Yes</DataTable.Td>
<DataTable.Td textAlign="right">100 500</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>Data 2</DataTable.Td>
<DataTable.Td textAlign="center">No</DataTable.Td>
<DataTable.Td textAlign="right">2 000 200</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>Data 3</DataTable.Td>
<DataTable.Td textAlign="center">Maybe</DataTable.Td>
<DataTable.Td textAlign="right">1 000 200</DataTable.Td>
</DataTable.Tr>
</DataTable.Tbody>
</DataTable>
),
args: {
data: testData,
columns: [
{
id: "left",
header: "Left",
align: "left",
width: { defaultValue: "100%" },
bodyCell: (row) => row.left,
},
{
id: "center",
header: "Center",
align: "center",
width: { defaultValue: "100%" },
bodyCell: (row) => row.center,
},
{
id: "right",
header: "Right",
align: "right",
width: { defaultValue: "100%" },
bodyCell: (row) => row.right,
},
],
},
};
export const ResizeAuto: Story = {
args: {
data: testData,
columns: [
{
id: "nested",
header: "Nested",
width: {
defaultValue: 200,
autoResizeOnce: true,
},
bodyCell: () => "Nested",
},
{
id: "left",
header: "L",
align: "left",
width: {
defaultValue: 200,
autoResizeOnce: true,
},
bodyCell: (row) => row.left,
},
{
id: "center",
header: "C",
align: "center",
width: {
defaultValue: 200,
autoResizeOnce: true,
},
bodyCell: (row) => row.center,
},
{
id: "right",
header: "R",
align: "right",
width: {
defaultValue: 200,
autoResizeOnce: true,
},
bodyCell: (row) => row.right,
},
{
id: "headingIsWidest",
header: "Heading is widest",
width: {
defaultValue: 50,
autoResizeOnce: true,
},
bodyCell: () => "Test",
},
{
id: "headingIsWidestSortable",
header: "Heading is widest + sortable",
width: {
defaultValue: 50,
autoResizeOnce: true,
},
isSortable: true,
bodyCell: () => "Test",
},
{
id: "blockContent",
header: "Block content",
width: {
defaultValue: 300,
autoResizeOnce: true,
},
bodyCell: () => <div>This is inside a div</div>,
},
],
getRowId: (row) => row.left,
},
render: (props) => {
const [showTable, setShowTable] = React.useState(false);
return showTable ? (
<DataGrid {...props}>
<DataGrid.Table<Row>
subRows={{
getRows: (row) =>
row.left.includes(".")
? []
: [
{
left: `${row.left}.1`,
center: "Yes",
right: "100",
},
],
}}
/>
</DataGrid>
) : (
<button onClick={() => setShowTable(true)}>Show table</button>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await new Promise((r) => setTimeout(r, 200)); // Wait for font to load, so that correct widths are calculated.
const button = canvas.getByRole("button", { name: "Show table" });
button.click();
await new Promise((r) => setTimeout(r, 100)); // Make sure auto resize has happened
const headers = canvas.getAllByRole("columnheader");
expect(headers.length).toBe(7);
expect(headers[0]).toHaveStyle({ width: "126px" });
expect(headers[1]).toHaveStyle({ width: "80px" });
expect(headers[2]).toHaveStyle({ width: "82px" });
expect(headers[3]).toHaveStyle({ width: "103px" });
expect(headers[4]).toHaveStyle({ width: "168px" });
expect(headers[5]).toHaveStyle({ width: "248px" });
expect(headers[6]).toHaveStyle({ width: "168px" });
},
};
@@ -1,85 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import React from "react";
import { DataTable } from "../table";
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
const meta: Meta<typeof DataTable> = {
title: "ds-react/Data/Settings",
component: DataTable,
parameters: {
chromatic: { disable: true },
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof DataTable>;
export const TextAlign: Story = {
render: () => (
<DataTable layout="auto" withKeyboardNav>
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader>Left</DataTableColumnHeader>
<DataTableColumnHeader textAlign="center">
Center
</DataTableColumnHeader>
<DataTableColumnHeader textAlign="right">Right</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
<DataTable.Tr>
<DataTable.Td>Data 1</DataTable.Td>
<DataTable.Td textAlign="center">Yes</DataTable.Td>
<DataTable.Td textAlign="right">100 500</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>Data 2</DataTable.Td>
<DataTable.Td textAlign="center">No</DataTable.Td>
<DataTable.Td textAlign="right">2 000 200</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>Data 3</DataTable.Td>
<DataTable.Td textAlign="center">Maybe</DataTable.Td>
<DataTable.Td textAlign="right">1 000 200</DataTable.Td>
</DataTable.Tr>
</DataTable.Tbody>
</DataTable>
),
};
export const TextAlignSortable: Story = {
render: () => (
<DataTable layout="auto" withKeyboardNav>
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader sortable>Left</DataTableColumnHeader>
<DataTableColumnHeader sortable textAlign="center">
Center
</DataTableColumnHeader>
<DataTableColumnHeader sortable textAlign="right">
Right
</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
<DataTable.Tr>
<DataTable.Td>Data 1</DataTable.Td>
<DataTable.Td textAlign="center">Yes</DataTable.Td>
<DataTable.Td textAlign="right">100 500</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>Data 2</DataTable.Td>
<DataTable.Td textAlign="center">No</DataTable.Td>
<DataTable.Td textAlign="right">2 000 200</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>Data 3</DataTable.Td>
<DataTable.Td textAlign="center">Maybe</DataTable.Td>
<DataTable.Td textAlign="right">1 000 200</DataTable.Td>
</DataTable.Tr>
</DataTable.Tbody>
</DataTable>
),
};
@@ -1,180 +0,0 @@
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";
const meta: Meta<typeof DataTable> = {
title: "ds-react/Data/Spans",
component: DataTable,
parameters: {
chromatic: { disable: true },
layout: "fullscreen",
},
};
export default meta;
type Story = StoryObj<typeof DataTable>;
export const Spans: Story = {
render: () => {
return (
<VStack height="100vh" padding="space-16">
<DataTable>
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader width="100%" colSpan={2}>
Group A
</DataTableColumnHeader>
<DataTableColumnHeader width="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>
</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>Test</DataTable.Td>
<DataTable.Td>Test</DataTable.Td>
<DataTable.Td>Test</DataTable.Td>
<DataTable.Td>Test</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>Test</DataTable.Td>
<DataTable.Td>Test</DataTable.Td>
<DataTable.Td>Test</DataTable.Td>
<DataTable.Td rowSpan={2}>Rowspan 2</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>Test</DataTable.Td>
<DataTable.Td>Test</DataTable.Td>
<DataTable.Td>Test</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>Test</DataTable.Td>
<DataTable.Td>Test</DataTable.Td>
<DataTable.Td>Test</DataTable.Td>
<DataTable.Td>Test</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>
</VStack>
);
},
parameters: {
a11y: { disable: true },
controls: { disable: true },
docs: { disable: true },
},
};
export const SpansSortable: Story = {
render: () => {
return (
<VStack height="100vh" padding="space-16">
<DataTable>
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader width="100%" colSpan={2} sortable>
Group A
</DataTableColumnHeader>
<DataTableColumnHeader width="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>
</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>Test</DataTable.Td>
<DataTable.Td>Test</DataTable.Td>
<DataTable.Td>Test</DataTable.Td>
<DataTable.Td>Test</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>Test</DataTable.Td>
<DataTable.Td>Test</DataTable.Td>
<DataTable.Td>Test</DataTable.Td>
<DataTable.Td rowSpan={2}>Rowspan 2</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>Test</DataTable.Td>
<DataTable.Td>Test</DataTable.Td>
<DataTable.Td>Test</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>Test</DataTable.Td>
<DataTable.Td>Test</DataTable.Td>
<DataTable.Td>Test</DataTable.Td>
<DataTable.Td>Test</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>
</VStack>
);
},
parameters: {
a11y: { disable: true },
controls: { disable: true },
docs: { disable: true },
},
};
@@ -1,92 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import React from "react";
import { Button } from "../../button";
import { DataTable } from "../table";
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
const meta: Meta<typeof DataTable> = {
title: "ds-react/Data/States",
component: DataTable,
parameters: {
chromatic: { disable: true },
layout: "fullscreen",
},
};
export default meta;
type Story = StoryObj<typeof DataTable>;
export const EmptyStateAuto: Story = {
render: () => (
<DataTable layout="auto" withKeyboardNav>
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
<DataTable.EmptyState>
<div>No data available</div>
<Button size="small">Create data</Button>
</DataTable.EmptyState>
</DataTable.Tbody>
</DataTable>
),
};
export const EmptyStateFixed: Story = {
render: () => (
<DataTable layout="fixed" withKeyboardNav>
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
<DataTable.EmptyState>
<div>No data available</div>
<Button size="small">Create data</Button>
</DataTable.EmptyState>
</DataTable.Tbody>
</DataTable>
),
};
export const LoadingStateAuto: Story = {
render: () => (
<DataTable layout="auto" withKeyboardNav>
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
<DataTable.LoadingState>Loading resources...</DataTable.LoadingState>
</DataTable.Tbody>
</DataTable>
),
};
export const LoadingStateFixed: Story = {
render: () => (
<DataTable layout="fixed" withKeyboardNav>
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
<DataTable.LoadingState>Loading resources...</DataTable.LoadingState>
</DataTable.Tbody>
</DataTable>
),
};
@@ -0,0 +1,169 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import React from "react";
import { DataGrid } from "../../data-grid";
import { Box } from "../../primitives/box";
import { VStack } from "../../primitives/stack";
import { TEST_DATA, columnDef_TEST_DATA } from "./Data.test-data";
const meta: Meta<typeof DataGrid> = {
title: "ds-react/Data/Sticky header",
component: DataGrid,
parameters: {
chromatic: { disable: true },
layout: "fullscreen",
a11y: { disable: true },
docs: { disable: true },
},
};
export default meta;
const getColDefs = (overflow: string) =>
overflow === "none" || overflow === "y"
? columnDef_TEST_DATA.slice(0, 5)
: columnDef_TEST_DATA;
const getData = (overflow: string) =>
overflow === "none" || overflow === "x" ? TEST_DATA.slice(0, 5) : TEST_DATA;
type Story = StoryObj<{ overflow: "none" | "x" | "y" | "both" }>;
export const Flex: Story = {
render: ({ overflow }) => {
return (
<VStack padding="space-16" gap="space-16" height="100vh">
<Box
borderWidth="1"
padding="space-16"
borderColor="neutral"
borderRadius="12"
>
Greier før tabell
</Box>
<div style={{ overflow: "hidden", display: "flex" }}>
<DataGrid columns={getColDefs(overflow)} data={getData(overflow)}>
<div>Greier inni DataGrid før tabell</div>
<DataGrid.Table />
<div>Greier inni DataGrid etter tabell</div>
</DataGrid>
</div>
<Box
borderWidth="1"
padding="space-16"
borderColor="neutral"
borderRadius="12"
>
Greier etter tabell
</Box>
</VStack>
);
},
argTypes: {
overflow: {
control: { type: "radio" },
options: ["none", "x", "y", "both"],
},
},
};
export const FlexNativeMinimal: Story = {
render: ({ overflow }) => {
return (
<div
style={{ display: "flex", flexDirection: "column", height: "100vh" }}
>
<div>Greier før tabell</div>
<DataGrid columns={getColDefs(overflow)} data={getData(overflow)}>
<DataGrid.Table />
</DataGrid>
<div>Greier etter tabell</div>
</div>
);
},
argTypes: {
overflow: {
control: { type: "radio" },
options: ["none", "x", "y", "both"],
},
},
};
export const FlexMinimal: Story = {
render: ({ overflow }) => {
return (
<VStack height="100vh">
<div>Greier før tabell</div>
<DataGrid columns={getColDefs(overflow)} data={getData(overflow)}>
<DataGrid.Table />
</DataGrid>
<div>Greier etter tabell</div>
</VStack>
);
},
argTypes: {
overflow: {
control: { type: "radio" },
options: ["none", "x", "y", "both"],
},
},
};
export const Maxheight: Story = {
render: ({ overflow }) => {
return (
<VStack padding="space-16" gap="space-16" height="100vh">
<Box
borderWidth="1"
padding="space-16"
borderColor="neutral"
borderRadius="12"
>
Greier før tabell
</Box>
<div style={{ display: "flex", maxHeight: "500px" }}>
<DataGrid columns={getColDefs(overflow)} data={getData(overflow)}>
<div>Greier inni DataGrid før tabell</div>
<DataGrid.Table />
<div>Greier inni DataGrid etter tabell</div>
</DataGrid>
</div>
<Box
borderWidth="1"
padding="space-16"
borderColor="neutral"
borderRadius="12"
>
Greier etter tabell
</Box>
</VStack>
);
},
argTypes: {
overflow: {
control: { type: "radio" },
options: ["none", "x", "y", "both"],
},
},
};
export const MaxheightMinimal: Story = {
render: ({ overflow }) => {
return (
<div style={{ display: "flex", maxHeight: "500px" }}>
<DataGrid columns={getColDefs(overflow)} data={getData(overflow)}>
<DataGrid.Table />
</DataGrid>
</div>
);
},
argTypes: {
overflow: {
control: { type: "radio" },
options: ["none", "x", "y", "both"],
},
},
};
@@ -1,14 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
type RowSelectionState,
Table,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import React, { useMemo, useState } from "react";
import {
ChevronLeftIcon,
@@ -16,30 +6,30 @@ import {
CogIcon,
} from "@navikt/aksel-icons";
import { Button } from "../../button";
import { DataGrid } from "../../data-grid";
import { Dialog } from "../../dialog";
import { CheckboxInput } from "../../form/checkbox/checkbox-input/CheckboxInput";
import { Checkbox } from "../../form/checkbox";
import { Radio, RadioGroup } from "../../form/radio";
import { RadioInput } from "../../form/radio/radio-input/RadioInput";
import { Search } from "../../form/search";
import { Select } from "../../form/select";
import { Switch } from "../../form/switch";
import { HStack, VStack } from "../../primitives/stack";
import { BodyShort } from "../../typography";
import DataDragAndDrop from "../drag-and-drop-old/root/DataDragAndDropRoot";
import { DataTable } from "../table";
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
import { BodyShort, Heading } from "../../typography";
import DragAndDrop from "../drag-and-drop/root/DragAndDropRoot";
import { DataGridTable } from "../table/root/DataGridTableRoot";
import { TokenFilter } from "../token-filter/TokenFilter";
import type { ExternalQuery } from "../token-filter/TokenFilter.types";
import { DataToolbar } from "../toolbar";
import {
PersonInfo,
columns,
homeSystemOptions,
sampleData,
} from "./dummy-data";
TEST_DATA,
TEST_DATA_NESTED,
columnDef_TEST_DATA,
columnDef_TEST_DATA_NESTED,
} from "./Data.test-data";
import { PersonInfo, homeSystemOptions } from "./dummy-data";
const meta: Meta<typeof DataTable> = {
const meta: Meta<typeof DataGridTable> = {
title: "ds-react/Data",
component: DataTable,
component: DataGridTable,
parameters: {
chromatic: { disable: true },
layout: "padded",
@@ -48,17 +38,23 @@ const meta: Meta<typeof DataTable> = {
export default meta;
type Story = StoryObj<typeof DataTable>;
type Story = StoryObj<typeof DataGridTable>;
export const KitchenSink: Story = {
/* export const KitchenSink: Story = {
render: () => {
const [rowDensity, setRowDensity] = React.useState<
"normal" | "condensed" | "spacious"
>("normal");
const [zebraStripes, setZebraStripes] = React.useState(false);
const [truncateContent, setTruncateContent] = React.useState(true);
const [columnOrder, setColumnOrder] = React.useState<string[]>(
columns.map((col) => col.accessorKey!),
const [columnOrder, setColumnOrder] = React.useState<
ColumnDefinitions<any, any>
>(
columns.map((col) => ({
id: col.accessorKey!,
label: col.accessorKey!,
cell: () => <></>,
})),
);
const table = useReactTable({
@@ -76,10 +72,9 @@ export const KitchenSink: Story = {
},
},
state: {
columnOrder,
columnOrder: columnOrder.map((col) => col.id),
},
enableRowSelection: false,
onColumnOrderChange: setColumnOrder,
columnResizeMode: "onChange",
debugTable: false,
debugHeaders: false,
@@ -183,30 +178,29 @@ export const KitchenSink: Story = {
>
Velg alle
</Switch>
<DataDragAndDrop setItems={setColumnOrder}>
{table.getAllLeafColumns().map((column, index) => {
<DragAndDrop
items={columnOrder}
setItems={setColumnOrder}
renderItem={(item) => {
const column = table
.getAllLeafColumns()
.find((col) => col.id === item.id);
return (
<DataDragAndDrop.Item
id={column.id}
index={index}
key={column.id}
<Switch
key={item.id}
size="small"
checked={column?.getIsVisible()}
onChange={(event) => {
const handler =
column?.getToggleVisibilityHandler();
handler?.(event);
}}
>
<Switch
key={column.id}
size="small"
checked={column.getIsVisible()}
onChange={(event) => {
const handler =
column.getToggleVisibilityHandler();
handler(event);
}}
>
{column.id}
</Switch>
</DataDragAndDrop.Item>
{item.id}
</Switch>
);
})}
</DataDragAndDrop>
}}
/>
</VStack>
</VStack>
</HStack>
@@ -216,28 +210,22 @@ export const KitchenSink: Story = {
}
/>
<DataTable
<DataTableLegacy
rowDensity={rowDensity}
zebraStripes={zebraStripes}
truncateContent={truncateContent}
withKeyboardNav
>
<DataTable.Thead>
<DataTableLegacy.Thead>
{table.getHeaderGroups().map((headerGroup) => {
return (
<DataTable.Tr key={headerGroup.id}>
<DataTableLegacy.Tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<DataTableColumnHeader
key={header.id}
style={{ width: `var(--header-${header.id}-size)` }}
defaultWidth={header.getSize()}
/* pinningHandler={
header.column.getIsPinned() === "left"
? () => header.column.pin(false)
: () => header.column.pin("left")
}
isPinned={header.column.getIsPinned() === "left"} */
sortable
sortDirection={header.column.getIsSorted() || "none"}
onSortClick={(event) => {
@@ -245,6 +233,9 @@ export const KitchenSink: Story = {
header.column.getToggleSortingHandler();
handler?.(event);
}}
label={(
header.column.columnDef.header || ""
).toString()}
>
{header.isPlaceholder
? null
@@ -255,32 +246,32 @@ export const KitchenSink: Story = {
</DataTableColumnHeader>
);
})}
</DataTable.Tr>
</DataTableLegacy.Tr>
);
})}
</DataTable.Thead>
</DataTableLegacy.Thead>
<MemoizedTableBody table={table} />
{table.getRowModel().rows.length > 0 && (
<DataTable.Tfoot>
<DataTableLegacy.Tfoot>
{table.getFooterGroups().map((footerGroup) => (
<DataTable.Tr key={footerGroup.id}>
<DataTableLegacy.Tr key={footerGroup.id}>
{footerGroup.headers.map((header) => (
<DataTable.Td key={header.id}>
<DataTableLegacy.Td key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.footer,
header.getContext(),
)}
</DataTable.Td>
</DataTableLegacy.Td>
))}
</DataTable.Tr>
</DataTableLegacy.Tr>
))}
</DataTable.Tfoot>
</DataTableLegacy.Tfoot>
)}
</DataTable>
</DataTableLegacy>
</VStack>
);
},
@@ -289,7 +280,7 @@ export const KitchenSink: Story = {
controls: { disable: true },
docs: { disable: true },
},
};
}; */
const dayJobValues = [
"Jedi Knight",
@@ -344,53 +335,64 @@ const filterOptions = [
propertyKey: "homeSystem",
value: v,
})),
...Array.from(new Set(sampleData.map((row) => row.supervisor))).map(
...Array.from(new Set(TEST_DATA.map((row) => row.supervisor))).map(
(value) => ({
propertyKey: "supervisor",
value,
}),
),
...Array.from(new Set(sampleData.map((row) => row.nationalId))).map(
...Array.from(new Set(TEST_DATA.map((row) => row.nationalId))).map(
(value) => ({
propertyKey: "nationalId",
value,
}),
),
...Array.from(new Set(sampleData.map((row) => row.name))).map((value) => ({
...Array.from(new Set(TEST_DATA.map((row) => row.name))).map((value) => ({
propertyKey: "name",
value,
})),
];
const allColumnIds = columnDef_TEST_DATA.map((col) => col.id);
export const KitchenSinkAdvancedFilter: Story = {
render: () => {
const [rowDensity, setRowDensity] = React.useState<
"normal" | "condensed" | "spacious"
>("normal");
"tight" | "standard" | "loose"
>("standard");
const [textSize, setTextSize] = React.useState<"small" | "medium">(
"medium",
);
const [zebraStripes, setZebraStripes] = React.useState(false);
const [truncateContent, setTruncateContent] = React.useState(true);
const [columnOrder, setColumnOrder] = React.useState<string[]>(
columns.map((col) => col.accessorKey!),
);
const [columnView, setColumnView] = React.useState(columnDef_TEST_DATA);
const [visibleColumns, setVisibleColumns] =
useState<string[]>(allColumnIds);
const [stickyColumns, setStickyColumns] = React.useState<{
first: "none" | "first";
last: "none" | "last";
}>({
first: "none",
last: "none",
});
const [showDetailsPanel, setShowDetailsPanel] = useState(false);
const [showNestedRows, setNestedRows] = useState(false);
const [query, setQuery] = useState<ExternalQuery>({
tokens: [],
operation: "and",
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [selectionMode, setSelectionMode] = useState<"single" | "multiple">(
"multiple",
);
console.log(rowSelection);
const [selectionMode, setSelectionMode] =
useState<DataGrid.Selection["mode"]>("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 +428,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 +456,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 +478,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,16 +515,25 @@ 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}
size="small"
value={rowDensity}
>
<Radio value="condensed">Tett</Radio>
<Radio value="normal">Normal</Radio>
<Radio value="spacious">Løs</Radio>
<Radio value="tight">Tett</Radio>
<Radio value="standard">Standard</Radio>
<Radio value="loose">Løs</Radio>
</RadioGroup>
<RadioGroup
legend="Velg tekststørrelse"
onChange={setTextSize}
size="small"
value={textSize}
>
<Radio value="small">Liten</Radio>
<Radio value="medium">Medium</Radio>
</RadioGroup>
<Switch
size="small"
@@ -538,57 +551,117 @@ export const KitchenSinkAdvancedFilter: Story = {
>
Stripede rader
</Switch>
<Switch
size="small"
checked={selectionMode === "multiple"}
onChangeCapture={(e) =>
setSelectionMode(
e.target.checked ? "multiple" : "single",
)
<RadioGroup
legend="Sticky første kolonne"
onChange={(value) =>
setStickyColumns((prev) => ({
...prev,
first: value as "none" | "first",
}))
}
description="Slår på flervalg"
size="small"
value={stickyColumns.first}
>
Flervalg
</Switch>
<Radio value="none">Ingen</Radio>
<Radio value="first">Første kolonne</Radio>
</RadioGroup>
<RadioGroup
legend="Sticky siste kolonne"
onChange={(value) =>
setStickyColumns((prev) => ({
...prev,
last: value as "none" | "last",
}))
}
size="small"
value={stickyColumns.last}
>
<Radio value="none">Ingen</Radio>
<Radio value="last">Siste kolonne</Radio>
</RadioGroup>
<VStack gap="space-8">
<Heading size="xsmall" spacing>
Demo innstillinger
</Heading>
<Select
label="Radvalg"
size="small"
value={selectionMode}
onChange={(e) =>
setSelectionMode(
e.target.value as DataGrid.Selection["mode"],
)
}
>
<option value="none">Ingen</option>
<option value="single">Enkeltvalg</option>
<option value="multiple">Flervalg</option>
</Select>
<Checkbox
onChange={(e) =>
setShowDetailsPanel(e.target.checked)
}
size="small"
checked={showDetailsPanel}
>
Vis detaljer
</Checkbox>
<Checkbox
onChange={(e) => {
setNestedRows(e.target.checked);
setColumnView(
e.target.checked
? columnDef_TEST_DATA_NESTED
: columnDef_TEST_DATA,
);
}}
size="small"
checked={showNestedRows}
>
Vis nestede rader
</Checkbox>
</VStack>
</VStack>
<VStack maxWidth="50%" gap="space-16">
<BodyShort weight="semibold">Vis kolonner</BodyShort>
<VStack gap="space-8">
<Switch
checked={table.getIsAllColumnsVisible()}
checked={
visibleColumns.length === allColumnIds.length
}
size="small"
onChange={(event) => {
const handler =
table.getToggleAllColumnsVisibilityHandler();
handler?.(event);
onChange={() => {
const allVisible =
visibleColumns.length === allColumnIds.length;
setVisibleColumns(allVisible ? [] : allColumnIds);
}}
>
Velg alle
</Switch>
<DataDragAndDrop setItems={setColumnOrder}>
{table.getAllLeafColumns().map((column, index) => {
<DragAndDrop
items={columnView}
setItems={setColumnView}
renderItem={(item) => {
return (
<DataDragAndDrop.Item
id={column.id}
index={index}
key={column.id}
<Switch
key={item.id}
size="small"
checked={visibleColumns.includes(item.id)}
onChange={(event) => {
const isChecked = event.target.checked;
setVisibleColumns((prev) => {
if (isChecked) {
return [...prev, item.id];
}
return prev.filter((id) => id !== item.id);
});
}}
>
<Switch
key={column.id}
size="small"
checked={column.getIsVisible()}
onChange={(event) => {
const handler =
column.getToggleVisibilityHandler();
handler(event);
}}
>
{column.id}
</Switch>
</DataDragAndDrop.Item>
{item.header}
</Switch>
);
})}
</DataDragAndDrop>
}}
/>
</VStack>
</VStack>
</HStack>
@@ -598,85 +671,41 @@ export const KitchenSinkAdvancedFilter: Story = {
}
/>
<DataTable
rowDensity={rowDensity}
zebraStripes={zebraStripes}
truncateContent={truncateContent}
withKeyboardNav
>
<DataTable.Thead>
{table.getHeaderGroups().map((headerGroup) => {
return (
<DataTable.Tr key={headerGroup.id}>
{table.options.enableMultiRowSelection ? (
<DataTableColumnHeader textAlign="center" width="64px">
<CheckboxInput
compact
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onChange={table.getToggleAllPageRowsSelectedHandler()}
aria-label="Velg alle rader"
/>
</DataTableColumnHeader>
) : (
<DataTableColumnHeader textAlign="center" width="64px" />
)}
{headerGroup.headers.map((header) => {
return (
<DataTableColumnHeader
key={header.id}
style={{ width: `var(--header-${header.id}-size)` }}
defaultWidth={header.getSize()}
/* pinningHandler={
header.column.getIsPinned() === "left"
? () => header.column.pin(false)
: () => header.column.pin("left")
}
isPinned={header.column.getIsPinned() === "left"} */
sortable
sortDirection={header.column.getIsSorted() || "none"}
onSortClick={(event) => {
const handler =
header.column.getToggleSortingHandler();
handler?.(event);
}}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</DataTableColumnHeader>
);
})}
</DataTable.Tr>
);
})}
</DataTable.Thead>
<MemoizedTableBody table={table} />
{table.getRowModel().rows.length > 0 && (
<DataTable.Tfoot>
{table.getFooterGroups().map((footerGroup) => (
<DataTable.Tr key={footerGroup.id}>
<DataTable.Td />
{footerGroup.headers.map((header) => (
<DataTable.Td key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.footer,
header.getContext(),
)}
</DataTable.Td>
))}
</DataTable.Tr>
))}
</DataTable.Tfoot>
<DataGrid
getRowId={(row) => row.name}
columns={columnView.filter((col) =>
visibleColumns.find((c) => c === col.id),
)}
</DataTable>
data={pagedData.paginatedData}
selection={{
mode: selectionMode,
}}
settings={{
rowDensity,
zebraStripes,
textSize,
truncateContent,
stickyColumns: {
start: stickyColumns.first === "first" ? 1 : undefined,
end: stickyColumns.last === "last" ? 1 : undefined,
},
}}
>
<DataGrid.Table<(typeof TEST_DATA)[number]>
stickyHeader
detailsPanel={
showDetailsPanel
? { getContent: (rowData) => <DetailsPanel row={rowData} /> }
: undefined
}
subRows={{
/* @ts-expect-error Test-data just hacked together now */
getRows: showNestedRows
? (rowData) => rowData.nestedRows
: undefined,
}}
/>
</DataGrid>
</VStack>
);
},
@@ -684,31 +713,69 @@ export const KitchenSinkAdvancedFilter: Story = {
a11y: { disable: true },
controls: { disable: true },
docs: { disable: true },
layout: "fullscreen",
},
};
const TableBody = ({ table }: { table: Table<PersonInfo> }) => {
function DetailsPanel({ row }: { row: PersonInfo }) {
return (
<VStack gap="space-16" padding="space-16">
<Heading size="small">{row.name}</Heading>
<BodyShort>National ID: {row.nationalId}</BodyShort>
<BodyShort>Day job: {row.dayJob}</BodyShort>
<BodyShort>Supervisor: {row.supervisor}</BodyShort>
<BodyShort>Home system: {row.homeSystem}</BodyShort>
</VStack>
);
}
const pageSize = 20;
function usePaginatedData<T extends any[]>(data: T) {
const [pageIndex, setPageIndex] = useState(0);
const paginatedData = useMemo(() => {
const start = pageIndex * pageSize;
return data.slice(start, start + pageSize);
}, [data, pageIndex]);
const pageCount = Math.ceil(data.length / pageSize);
const canNextPage = pageIndex < pageCount - 1;
const canPreviousPage = pageIndex > 0;
return {
paginatedData,
pageIndex,
setPageIndex,
canNextPage,
canPreviousPage,
pageCount,
};
}
/* const TableBody = ({ table }: { table: Table<PersonInfo> }) => {
const hasRowSelection = table.options.enableRowSelection;
const multiRowSelection = table.options.enableMultiRowSelection;
if (table.getRowModel().rows.length === 0) {
return (
<DataTable.Tbody>
<DataTable.EmptyState>
<DataTableLegacy.Tbody>
<DataTableLegacy.EmptyState>
<div>No data available</div>
<Button size="small">Create data</Button>
</DataTable.EmptyState>
</DataTable.Tbody>
</DataTableLegacy.EmptyState>
</DataTableLegacy.Tbody>
);
}
return (
<DataTable.Tbody>
<DataTableLegacy.Tbody>
{table.getRowModel().rows.map((row) => {
return (
<DataTable.Tr key={row.id} selected={row.getIsSelected()}>
<DataTableLegacy.Tr key={row.id} selected={row.getIsSelected()}>
{hasRowSelection && (
<DataTable.Td textAlign="center" UNSAFE_isSelection>
<DataTableLegacy.Td textAlign="center" cellType="action">
{multiRowSelection ? (
<CheckboxInput
compact
@@ -726,23 +793,23 @@ const TableBody = ({ table }: { table: Table<PersonInfo> }) => {
aria-label={`Velg rad ${row.id}`}
/>
)}
</DataTable.Td>
</DataTableLegacy.Td>
)}
{row.getVisibleCells().map((cell) => {
return (
<DataTable.Td key={cell.id}>
<DataTableLegacy.Td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataTable.Td>
</DataTableLegacy.Td>
);
})}
</DataTable.Tr>
</DataTableLegacy.Tr>
);
})}
</DataTable.Tbody>
</DataTableLegacy.Tbody>
);
};
}; */
/*
const MemoizedTableBody = React.memo(
TableBody,
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn,
) as typeof TableBody;
) as typeof TableBody; */
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,230 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import React from "react";
import { expect, userEvent, within } from "storybook/test";
import { DataGrid } from "../../data-grid";
import type { ColumnDefinitions } from "../table/root/DataGridTable.types";
import { DataGridTable } from "../table/root/DataGridTableRoot";
type TestRow = {
id: string;
name: string;
subRows?: TestRow[];
};
const meta: Meta<typeof DataGridTable> = {
title: "ds-react/Data/DataTable/Tests",
component: DataGridTable,
parameters: {
chromatic: { disable: true },
},
};
export default meta;
type Story = StoryObj<typeof DataGridTable>;
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",
header: "Name",
bodyCell: (row) => row.name,
},
];
const getSubRows = (row: TestRow) => row.subRows ?? [];
const getCheckboxes = (canvasElement: HTMLElement) =>
within(canvasElement).getAllByRole("checkbox") as HTMLInputElement[];
export const ExpandedChildRowsIncludedInSelectAll: Story = {
render: () => (
<DataGrid
columns={columns}
data={data}
getRowId={(row) => row.id}
selection={{ mode: "multiple" }}
>
<DataGrid.Table subRows={{ getRows: getSubRows }} />
</DataGrid>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.queryByText("Child")).not.toBeInTheDocument();
await userEvent.click(
canvas.getByRole("button", { name: "Vis under-rader" }),
);
expect(canvas.getByText("Child")).toBeInTheDocument();
expect(getCheckboxes(canvasElement)).toHaveLength(3);
await userEvent.click(
canvas.getByRole("checkbox", { name: "Velg alle synlige rader" }),
);
expect(
getCheckboxes(canvasElement).every((checkbox) => checkbox.checked),
).toBe(true);
},
};
export const FallbackIdsSelectAllVisibleRows: Story = {
render: () => (
<DataGrid
columns={columns}
data={fallbackIdData}
getRowId={(row) => row.id}
selection={{ mode: "multiple" }}
>
<DataGrid.Table
subRows={{
getRows: getSubRows,
defaultExpandedRowIds: ["0"],
}}
/>
</DataGrid>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("checkbox", { name: "Velg alle synlige rader" }),
);
const rowCheckboxes = getCheckboxes(canvasElement) as HTMLInputElement[];
expect(rowCheckboxes).toHaveLength(3);
expect(rowCheckboxes.every((checkbox) => checkbox.checked)).toBe(true);
},
};
export const CollapsedParentSelectionIncludesHiddenDescendants: Story = {
render: () => (
<DataGrid
columns={columns}
data={deepNestedData}
getRowId={(row) => row.id}
selection={{ mode: "multiple" }}
>
<DataGrid.Table subRows={{ getRows: getSubRows }} />
</DataGrid>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(getCheckboxes(canvasElement)[1]);
await userEvent.click(
canvas.getByRole("button", { name: "Vis under-rader" }),
);
expect(getCheckboxes(canvasElement)[1].checked).toBe(true);
expect(getCheckboxes(canvasElement)[2].checked).toBe(true);
await userEvent.click(
canvas.getByRole("button", { name: "Vis under-rader" }),
);
expect(getCheckboxes(canvasElement)[3].checked).toBe(true);
await userEvent.click(
canvas.getAllByRole("button", { name: "Skjul under-rader" })[0],
);
await userEvent.click(getCheckboxes(canvasElement)[1]);
await userEvent.click(
canvas.getByRole("button", { name: "Vis under-rader" }),
);
expect(getCheckboxes(canvasElement)[1].checked).toBe(false);
expect(getCheckboxes(canvasElement)[2].checked).toBe(false);
expect(getCheckboxes(canvasElement)[3].checked).toBe(false);
},
};
export const SelectAllIncludesHiddenDescendantsForCollapsedParents: Story = {
render: () => (
<DataGrid
columns={columns}
data={deepNestedData}
getRowId={(row) => row.id}
selection={{ mode: "multiple" }}
>
<DataGrid.Table subRows={{ getRows: getSubRows }} />
</DataGrid>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("checkbox", { name: "Velg alle synlige rader" }),
);
await userEvent.click(
canvas.getByRole("button", { name: "Vis under-rader" }),
);
expect(getCheckboxes(canvasElement)[1].checked).toBe(true);
expect(getCheckboxes(canvasElement)[2].checked).toBe(true);
await userEvent.click(
canvas.getByRole("button", { name: "Vis under-rader" }),
);
expect(getCheckboxes(canvasElement)[3].checked).toBe(true);
await userEvent.click(
canvas.getByRole("checkbox", {
name: "Fjern alle synlige valgte rader",
}),
);
expect(getCheckboxes(canvasElement)[0].checked).toBe(false);
await userEvent.click(
canvas.getAllByRole("button", { name: "Skjul under-rader" })[0],
);
await userEvent.click(
canvas.getByRole("button", { name: "Vis under-rader" }),
);
expect(getCheckboxes(canvasElement)[1].checked).toBe(false);
expect(getCheckboxes(canvasElement)[2].checked).toBe(false);
expect(getCheckboxes(canvasElement)[3].checked).toBe(false);
},
};
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import React from "react";
import { Switch } from "../../form/switch";
import DragAndDrop from "../drag-and-drop/root/DragAndDropRoot";
import { ColumnDefinition } from "../table/root/DataTable.types";
import { ColumnDefinition } from "../table/root/DataGridTable.types";
const meta: Meta<typeof DragAndDrop> = {
title: "ds-react/Data/DragAndDrop",
@@ -20,24 +20,28 @@ type Story = StoryObj<typeof DragAndDrop>;
export const Default: Story = {
render: () => {
const [items, setItems] = React.useState<ColumnDefinition<string>[]>([
{ id: "id", label: "Id", cell: (item) => item },
{ id: "name", label: "Name", cell: (item) => item },
{ id: "nationality", label: "Nationality", cell: (item) => item },
{ id: "dayJob", label: "Day job", cell: (item) => item },
{ id: "supervisor", label: "Supervisor", cell: (item) => item },
{ id: "dateReceived", label: "Date received", cell: (item) => item },
{ id: "message", label: "Message", cell: (item) => item },
{ id: "age", label: "Age", cell: (item) => item },
{ id: "forceSensitive", label: "Force sensitive", cell: (item) => item },
{ id: "homeSystem", label: "Home system", cell: (item) => item },
{ id: "skills", label: "Skills", cell: (item) => item },
{ id: "id", header: "Id", bodyCell: (item) => item },
{ id: "name", header: "Name", bodyCell: (item) => item },
{ id: "nationality", header: "Nationality", bodyCell: (item) => item },
{ id: "dayJob", header: "Day job", bodyCell: (item) => item },
{ id: "supervisor", header: "Supervisor", bodyCell: (item) => item },
{ id: "dateReceived", header: "Date received", bodyCell: (item) => item },
{ id: "message", header: "Message", bodyCell: (item) => item },
{ id: "age", header: "Age", bodyCell: (item) => item },
{
id: "forceSensitive",
header: "Force sensitive",
bodyCell: (item) => item,
},
{ id: "homeSystem", header: "Home system", bodyCell: (item) => item },
{ id: "skills", header: "Skills", bodyCell: (item) => item },
]);
return (
<DragAndDrop
setItems={setItems}
items={items}
renderItem={(item) => <Switch size="small">{item.label}</Switch>}
renderItem={(item) => <Switch size="small">{item.header}</Switch>}
/>
);
},
@@ -1,227 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import React from "react";
import { DragVerticalIcon } from "@navikt/aksel-icons";
import { Switch } from "../../form/switch";
import { HStack } from "../../primitives/stack";
import { Floating } from "../../utils/components/floating/Floating";
import DragAndDrop from "../drag-and-drop-old/root/DataDragAndDropRoot";
const meta: Meta<typeof DragAndDrop> = {
title: "ds-react/Data/OldDragAndDrop",
component: DragAndDrop,
parameters: {
chromatic: { disable: true },
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof DragAndDrop>;
export const Dndkit: Story = {
render: () => {
const [items, setItems] = React.useState([
{ id: "id", content: "Id" },
{ id: "name", content: "Name" },
{ id: "nationality", content: "Nationality" },
{ id: "dayJob", content: "Day job" },
{ id: "supervisor", content: "Supervisor" },
{ id: "dateReceived", content: "Date received" },
{ id: "message", content: "Message" },
{ id: "age", content: "Age" },
{ id: "forceSensitive", content: "Force sensitive" },
{ id: "homeSystem", content: "Home system" },
{ id: "skills", content: "Skills" },
]);
return (
<DragAndDrop setItems={setItems}>
{items.map((item, index) => (
<DragAndDrop.Item key={item.id} id={item.id} index={index}>
<Switch size="small" defaultChecked>
{item.content}
</Switch>
</DragAndDrop.Item>
))}
</DragAndDrop>
);
},
};
export const NativeHTMLDragAndDrop: Story = {
render: () => {
const [items, setItems] = React.useState([
{ id: "id", content: "Id" },
{ id: "name", content: "Name" },
{ id: "nationality", content: "Nationality" },
{ id: "dayJob", content: "Day job" },
{ id: "supervisor", content: "Supervisor" },
{ id: "dateReceived", content: "Date received" },
{ id: "message", content: "Message" },
{ id: "age", content: "Age" },
{ id: "forceSensitive", content: "Force sensitive" },
{ id: "homeSystem", content: "Home system" },
{ id: "skills", content: "Skills" },
]);
type Elem = { id: string; index: number };
const [draggingElem, setDraggingElem] = React.useState<Elem | null>(null);
const [dropTarget, setDropTarget] = React.useState<Elem | null>(null);
function handleDragStart(event, item, index) {
setDraggingElem({ id: item.id, index });
event.dataTransfer.setData("text/plain", item.id);
event.dataTransfer.effectAllowed = "move";
const preview = document.createElement("div");
preview.innerHTML = `
<div style="
padding: 12px 16px;
background: white;
width: 100%;
">
⠿ ${item.content}
</div>
`;
preview.style.position = "absolute";
preview.style.top = "-9999px";
preview.style.left = "-9999px";
document.body.appendChild(preview);
event.dataTransfer.setDragImage(preview, 12, 12);
requestAnimationFrame(() => {
document.body.removeChild(preview);
});
}
return (
<>
{items.map((item, index) => {
return (
<HStack key={item.id} gap="space-8" asChild>
{/** biome-ignore lint/a11y/noStaticElementInteractions: TEMP */}
<div
key={item.id}
style={{
padding: "8px",
marginBottom: "4px",
width: "200px",
backgroundColor:
dropTarget?.id === item.id ? "lightblue" : "white",
}}
onDragOver={(e) => {
e.preventDefault();
setDropTarget({ id: item.id, index });
}}
onDrop={() => {
if (draggingElem && dropTarget) {
const newItems = [...items];
const [removed] = newItems.splice(draggingElem.index, 1);
newItems.splice(dropTarget.index, 0, removed);
setItems(newItems);
}
}}
>
{/** biome-ignore lint/a11y/noStaticElementInteractions: TEMP */}
<div
draggable
onDragStart={(e) => handleDragStart(e, item, index)}
onDragEnd={() => {
setDraggingElem(null);
setDropTarget(null);
}}
>
<DragVerticalIcon
aria-hidden
title="Dra for å flytte"
fontSize="1.5rem"
/>
</div>
{item.content}
</div>
</HStack>
);
})}
</>
);
},
};
export const _DNDAnchor = () => {
const events = (src: string) => {
return {
onPointerMove: () => console.info(`${src} onPointerMove`),
style: {
paddibng: "1rem",
border: "1px solid black",
marginBottom: "0.5rem",
cursor: "move",
},
};
};
const [open, setOpen] = React.useState(false);
const [virtualRef, setVirtualRef] = React.useState({
getBoundingClientRect: () =>
DOMRect.fromRect({ width: 0, height: 0, x: 0, y: 0 }),
});
const handleOpen = (event: React.MouseEvent | React.PointerEvent) => {
setVirtualRef({
getBoundingClientRect: () =>
DOMRect.fromRect({
width: 0,
height: 0,
x: event.clientX,
y: event.clientY,
}),
});
setOpen(true);
function moveListener(_event: PointerEvent) {
setVirtualRef({
getBoundingClientRect: () =>
DOMRect.fromRect({
width: 0,
height: 0,
x: _event.clientX,
y: _event.clientY,
}),
});
}
window.addEventListener("pointermove", moveListener);
const handleClose = () => {
setOpen(false);
window.removeEventListener("pointermove", moveListener);
};
window.addEventListener("pointerup", handleClose, { once: true });
};
return (
<div>
<button onPointerDown={handleOpen}>Open</button>
<div {...events("One")}>Element 1</div>
{open && (
<div>
<Floating>
<Floating.Anchor virtualRef={virtualRef}>
<span />
</Floating.Anchor>
<Floating.Content>
<div style={{ ...events("one").style }}>Element 1 floating</div>
</Floating.Content>
</Floating>
</div>
)}
<div {...events("Two")}>Element 2</div>
<div {...events("Three")}>Element 3</div>
<div {...events("Four")}>Element 4</div>
</div>
);
};
@@ -1,248 +0,0 @@
import { Meta, StoryObj } from "@storybook/react-vite";
import React, { useState } from "react";
import { DataTable } from ".";
import { Checkbox } from "../../form/checkbox";
import { DataTableColumnHeader } from "./column-header/DataTableColumnHeader";
type SortDirection = "asc" | "desc" | "none";
const meta: Meta<typeof DataTable> = {
title: "ds-react/Data/DataTable",
component: DataTable,
parameters: {
chromatic: { disable: true },
},
};
type Story = StoryObj<typeof DataTable>;
export default meta;
export const Default: Story = {
render: (props) => (
<DataTable
rowDensity={props.rowDensity}
style={{ width: "500px" }}
zebraStripes={props.zebraStripes}
truncateContent={props.truncateContent}
>
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader>Header 1</DataTableColumnHeader>
<DataTableColumnHeader>Header 2</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
<DataTable.Tr>
<DataTable.Td>Data 1</DataTable.Td>
<DataTable.Td>Data 2</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>Data 1</DataTable.Td>
<DataTable.Td>Data 2</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr>
<DataTable.Td>Data 1</DataTable.Td>
<DataTable.Td>
Very long content that should be truncated
</DataTable.Td>
</DataTable.Tr>
</DataTable.Tbody>
</DataTable>
),
args: {
rowDensity: "normal",
zebraStripes: false,
truncateContent: true,
},
argTypes: {
rowDensity: {
control: { type: "radio" },
options: ["condensed", "normal", "spacious"],
defaultValue: "normal",
},
zebraStripes: {
control: { type: "boolean" },
},
truncateContent: {
control: { type: "boolean" },
},
},
};
export const Selected: Story = {
render: () => {
const useToggleList = (initialState) => {
const [list, setList] = useState(initialState);
return [
list,
(value) =>
setList((oldList) =>
oldList.includes(value)
? oldList.filter((id) => id !== value)
: [...oldList, value],
),
];
};
const [selectedRows, toggleSelectedRow] = useToggleList(["2"]);
return (
<DataTable style={{ width: "500px" }}>
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader>
<Checkbox
size="small"
indeterminate={
selectedRows.length > 0 && selectedRows.length < 3
}
onChange={() => {
if (selectedRows.length === 3) {
selectedRows.forEach((id) => {
toggleSelectedRow(id);
});
} else {
["1", "2", "3"].forEach((id) => {
if (!selectedRows.includes(id)) {
toggleSelectedRow(id);
}
});
}
}}
>
Velg alle
</Checkbox>
</DataTableColumnHeader>
<DataTableColumnHeader>Header 1</DataTableColumnHeader>
<DataTableColumnHeader>Header 2</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
<DataTable.Tr selected={selectedRows.includes("1")}>
<DataTable.Td>
<Checkbox
hideLabel
size="small"
checked={selectedRows.includes("1")}
onChange={() => toggleSelectedRow("1")}
>
{" "}
</Checkbox>
</DataTable.Td>
<DataTable.Td>Data 1</DataTable.Td>
<DataTable.Td>Data 2</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr selected={selectedRows.includes("2")}>
<DataTable.Td>
<Checkbox
hideLabel
size="small"
checked={selectedRows.includes("2")}
onChange={() => toggleSelectedRow("2")}
aria-labelledby="test"
>
{" "}
</Checkbox>
</DataTable.Td>
<DataTable.Td id="test">Data 1</DataTable.Td>
<DataTable.Td>Data 2</DataTable.Td>
</DataTable.Tr>
<DataTable.Tr selected={selectedRows.includes("3")}>
<DataTable.Td>
<Checkbox
hideLabel
size="small"
checked={selectedRows.includes("3")}
onChange={() => toggleSelectedRow("3")}
>
{" "}
</Checkbox>
</DataTable.Td>
<DataTable.Td>Data 1</DataTable.Td>
<DataTable.Td>Data 2</DataTable.Td>
</DataTable.Tr>
</DataTable.Tbody>
</DataTable>
);
},
};
const SORT_CYCLE: Record<SortDirection, SortDirection> = {
none: "asc",
asc: "desc",
desc: "none",
};
const DATA = [
{ name: "Arne", age: 42, city: "Oslo" },
{ name: "Bjørg", age: 31, city: "Bergen" },
{ name: "Carl", age: 55, city: "Trondheim" },
{ name: "Diana", age: 28, city: "Stavanger" },
];
export const Sortable: Story = {
render: () => {
const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<SortDirection>("none");
const handleSort = (key: string) => {
if (sortKey === key) {
const next = SORT_CYCLE[sortDir];
setSortDir(next);
if (next === "none") setSortKey(null);
} else {
setSortKey(key);
setSortDir("asc");
}
};
const sortedData = [...DATA].sort((a, b) => {
if (!sortKey || sortDir === "none") return 0;
const aVal = a[sortKey];
const bVal = b[sortKey];
const cmp =
typeof aVal === "number"
? aVal - bVal
: String(aVal).localeCompare(String(bVal));
return sortDir === "asc" ? cmp : -cmp;
});
const dirFor = (key: string): SortDirection =>
sortKey === key ? sortDir : "none";
return (
<DataTable style={{ width: "500px" }}>
<DataTable.Thead>
<DataTable.Tr>
<DataTableColumnHeader
sortable
sortDirection={dirFor("name")}
onSortClick={() => handleSort("name")}
>
Navn
</DataTableColumnHeader>
<DataTableColumnHeader
sortable
sortDirection={dirFor("age")}
onSortClick={() => handleSort("age")}
>
Alder
</DataTableColumnHeader>
<DataTableColumnHeader>By</DataTableColumnHeader>
</DataTable.Tr>
</DataTable.Thead>
<DataTable.Tbody>
{sortedData.map((row) => (
<DataTable.Tr key={row.name}>
<DataTable.Td>{row.name}</DataTable.Td>
<DataTable.Td>{row.age}</DataTable.Td>
<DataTable.Td>{row.city}</DataTable.Td>
</DataTable.Tr>
))}
</DataTable.Tbody>
</DataTable>
);
},
};
@@ -1,11 +0,0 @@
# Naming discussions
## Under same scope
DataTable
DataToolbar
DataFilter
DataSearchField
DataCombobox
DataOperator
@@ -2,23 +2,25 @@ import React, { forwardRef } from "react";
import { cl } from "../../../utils/helpers";
import { useDataTableContext } from "../root/DataTableRoot.context";
interface DataTableBaseCellProps extends React.HTMLAttributes<HTMLTableCellElement> {
interface DataTableBaseCellProps extends Omit<
React.TdHTMLAttributes<HTMLTableCellElement>,
"width"
> {
/**
* Content alignment inside cell
* Text alignment inside cell.
*
* Quantitative figures like amounts and percentages should be right‑aligned (but not phone numbers, postal codes etc.)
* @default "left"
*/
textAlign?: "left" | "center" | "right";
align?: "left" | "center" | "right";
/**
* TODO: Shouldnt be needed to declare these here... But getting type-errors if not
* Internal cell type that controls padding, alignment, row-click prevention, and resize behavior.
* - `"action"`: Centers content, removes cell padding, prevents row click, and disables column resize.
* Used for selection (checkbox/radio) and expansion (expand/collapse) cells.
*/
colSpan?: number;
rowSpan?: number;
cellType?: "action";
/**
* Temp hack to solve overflow and alignment
*/
UNSAFE_isSelection?: boolean;
/**
* When true, clicking this cell will not trigger `onRowClick` on the row.
* When true, clicking this cell will not trigger `onRowAction` on the row.
* Useful for cells that contain their own interactive content or actions
* that should be independent of row-level click handling.
*/
@@ -26,20 +28,15 @@ interface DataTableBaseCellProps extends React.HTMLAttributes<HTMLTableCellEleme
/**
* Sets a max-width on the content wrapper div inside the cell.
* This is only needed when using `layout="auto"` together with
* `truncateContent` on `<DataTable>` and you want the cell to be truncated.
* `truncateContent` on `<DataGrid.Table>` and you want the cell to be truncated.
*/
contentMaxWidth?: number | `${number}${string}`;
contentMaxWidth?: number | string;
/**
* Makes the cell sticky.
*/
isSticky?: "start" | "end" | false;
}
/**
* TODO:
* - Need a "type" or something that centers content instead of relying on isSelection prop.
* - Need a separate prop do disabled resizing instead of relying on isSelection prop.
*/
const DataTableBaseCell = forwardRef<
HTMLTableCellElement,
DataTableBaseCellProps & {
@@ -47,6 +44,12 @@ const DataTableBaseCell = forwardRef<
* Cell type
*/
as: "th" | "td";
/**
* Content to render before the main cell content.
*
* **WARNING: Adding content here that takes up space will probably break auto-resizing!**
*/
beforeContent?: React.ReactNode;
}
>(
(
@@ -54,13 +57,14 @@ const DataTableBaseCell = forwardRef<
className,
children,
as: Component,
textAlign = "left",
colSpan,
UNSAFE_isSelection,
beforeContent,
align = "left",
cellType,
preventRowClick,
contentMaxWidth,
rowSpan,
isSticky,
colSpan,
rowSpan,
...rest
},
forwardedRef,
@@ -73,16 +77,22 @@ const DataTableBaseCell = forwardRef<
ref={forwardedRef}
className={cl("aksel-data-table__cell", className)}
tabIndex={withKeyboardNav ? -1 : undefined}
data-align={textAlign}
data-selectable={UNSAFE_isSelection}
data-align={align}
data-cell-type={cellType || undefined}
data-prevent-row-click={
preventRowClick || UNSAFE_isSelection || undefined
preventRowClick || cellType === "action" || undefined
}
data-sticky={isSticky || undefined}
colSpan={colSpan}
rowSpan={rowSpan}
>
<div style={{ maxWidth: contentMaxWidth }}>{children}</div>
{beforeContent}
<div
className="aksel-data-table__cell-content"
style={{ maxWidth: contentMaxWidth }}
>
{children}
</div>
</Component>
);
},
@@ -1,4 +1,4 @@
import React, { forwardRef, useRef } from "react";
import React, { forwardRef, useMemo, useRef } from "react";
import {
ArrowsUpDownIcon,
CaretLeftCircleFillIcon,
@@ -6,41 +6,47 @@ import {
SortDownIcon,
SortUpIcon,
} from "@navikt/aksel-icons";
import { useDataGridContext } from "../../../data-grid/root/DataGridRoot.context";
import { cl } from "../../../utils/helpers";
import { useMergeRefs } from "../../../utils/hooks";
import {
DataTableBaseCell,
type DataTableBaseCellProps,
} from "../base-cell/DataTableBaseCell";
import type { SortDirection } from "../root/DataTable.types";
import { useDataTableContext } from "../root/DataTableRoot.context";
import { type ResizeProps, useTableColumnResize } from "./useTableColumnResize";
interface DataTableColumnHeaderProps
extends ResizeProps, DataTableBaseCellProps {
resizeHandler?: (
event:
| React.MouseEvent<HTMLButtonElement>
| React.TouchEvent<HTMLButtonElement>,
) => void;
interface DataTableColumnHeaderProps extends DataTableBaseCellProps {
/**
* Makes the column header sortable. The entire header cell content becomes
* a clickable button when true.
* Unique identifier for the column. Used when sorting to identify which column is being sorted.
*/
id: string;
/**
* Accessible name of the column.
*/
label: string;
/**
* Makes the column sortable by clicking on the header.
* The entire header cell content becomes a clickable button when true.
*/
sortable?: boolean;
/**
* Current sort direction. Only relevant when `sortable` is true.
* Uses values matching the `aria-sort` attribute directly.
* @default "none"
* Object with props related to column width and resizing. Summary:
*
* - `resizable?: boolean` - Whether the column should be resizable by the user.
* - `autoResizeOnce?: boolean` - Whether the column should automatically resize to fit its content.
* - `resizeMin?: number` - Minimum width of the column when resizing.
* - `resizeMax?: number` - Maximum width of the column when resizing.
* - `value?: number | string` - Controlled width of the column.
* - `defaultValue?: number | string` - Initial width of the column.
* - `onChange?: (width: number) => void` - Called when the column width changes.
*
* See individual props for details and defaults.
*/
sortDirection?: SortDirection;
/**
* Called when the user clicks the sortable header.
* The consumer is responsible for determining and setting the next sort state.
*/
onSortClick?: (event: React.MouseEvent<HTMLElement>) => void;
width?: ResizeProps;
}
const SORT_ICON: Record<SortDirection, React.ElementType | null> = {
const SORT_ICON: Record<"asc" | "desc" | "none", React.ElementType> = {
asc: SortUpIcon,
desc: SortDownIcon,
none: ArrowsUpDownIcon,
@@ -57,42 +63,40 @@ const DataTableColumnHeader = forwardRef<
>(
(
{
id,
label,
sortable = false,
width,
cellType,
className,
children,
sortable = false,
sortDirection = "none",
onSortClick,
style,
width,
defaultWidth,
autoWidth,
minWidth,
maxWidth,
onWidthChange,
colSpan,
rowSpan,
UNSAFE_isSelection,
...rest
},
forwardedRef,
) => {
const contentRef = React.useRef<HTMLDivElement>(null);
const { isLoading } = useDataGridContext();
const thRef = useRef<HTMLTableCellElement>(null);
const mergedRef = useMergeRefs(forwardedRef, thRef);
const { sortingState } = useDataTableContext();
const { onSortClick, sortState } = sortingState;
const resizeResult = useTableColumnResize({
...width,
thRef,
width,
defaultWidth,
autoWidth,
minWidth,
maxWidth,
onWidthChange,
style,
colSpan,
colSpan: rest.colSpan,
});
const SortIcon = sortable ? SORT_ICON[sortDirection] : null;
const sortDirection = useMemo(() => {
const sortEntry = sortState.find((s) => s.columnId === id);
return sortEntry?.direction ?? "none";
}, [id, sortState]);
const canSort = sortable && id !== undefined;
const SortIcon = canSort ? SORT_ICON[sortDirection] : null;
const contentId = `th-content-${id.replace(/\s/g, "-")}`;
return (
<DataTableBaseCell
@@ -100,19 +104,20 @@ const DataTableColumnHeader = forwardRef<
{...rest}
ref={mergedRef}
className={cl("aksel-data-table__column-header", className)}
data-sortable={sortable}
style={resizeResult.style}
aria-sort={sortable ? getAriaSort(sortDirection) : undefined}
UNSAFE_isSelection={UNSAFE_isSelection}
colSpan={colSpan}
rowSpan={rowSpan}
data-sortable={canSort}
style={{ ...style, width: resizeResult.width }}
aria-sort={canSort ? getAriaSort(sortDirection) : undefined}
cellType={cellType}
aria-labelledby={contentId} // Avoids VO announcing "Endre bredde" when navigating horizontally in tbody
>
{sortable ? (
{canSort ? (
<button
type="button"
className="aksel-data-table__th-sort-button"
onClick={onSortClick}
onClick={(event) => onSortClick(id, event)}
disabled={isLoading}
>
<div ref={contentRef} className="aksel-data-table__th-content">
<div id={contentId} className="aksel-data-table__th-content">
{children}
</div>
{SortIcon && (
@@ -126,46 +131,45 @@ const DataTableColumnHeader = forwardRef<
</button>
) : (
<div
ref={contentRef}
id={contentId}
className={cl({
"aksel-data-table__th-content": !UNSAFE_isSelection,
"aksel-data-table__th-content": cellType !== "action",
})}
>
{children}
</div>
)}
{resizeResult.enabled && !UNSAFE_isSelection && (
{resizeResult.enabled && cellType !== "action" && (
<button
{...resizeResult.resizeHandlerProps}
type="button"
className="aksel-data-table__th-resize-handle"
aria-label={
resizeResult.isResizingWithKeyboard
? "Bruk pil venstre/høyre"
: "Endre bredde"
: `Endre bredde ${label}`
} // TODO Translate
data-active={resizeResult.isResizingWithKeyboard}
data-disable-keyboard-nav={resizeResult.isResizingWithKeyboard}
data-block-keyboard-nav
role="slider"
aria-valuenow={
typeof resizeResult.style.width === "number"
? resizeResult.style.width
: 0
typeof resizeResult.width === "number" ? resizeResult.width : 0
}
aria-valuetext={
typeof resizeResult.style.width === "number" &&
typeof resizeResult.width === "number" &&
resizeResult.isResizingWithKeyboard
? resizeResult.style.width.toString()
: "" // Needs to be blank when not in keyboard resizing mode to avoid NVDA announcing the value as part of the column heading
? resizeResult.width.toString()
: " " // Needs to be blank when not in keyboard resizing mode to avoid NVDA announcing the value as part of the column heading
} // Need either this or aria-valuemax to get SR (at least NVDA) to announce the value
>
{resizeResult.isResizingWithKeyboard && (
<>
<span className="aksel-data-table__th-resize-handle-indicator aksel-data-table__th-resize-handle-indicator--start">
<span className="aksel-data-table__th-resize-handle-indicator">
<CaretLeftCircleFillIcon aria-hidden fontSize="1.5rem" />
</span>
<span className="aksel-data-table__th-resize-handle-indicator aksel-data-table__th-resize-handle-indicator--end">
<span className="aksel-data-table__th-resize-handle-indicator">
<CaretRightCircleFillIcon aria-hidden fontSize="1.5rem" />
</span>
</>
@@ -178,7 +182,7 @@ const DataTableColumnHeader = forwardRef<
);
function getAriaSort(
sortDirection: SortDirection | undefined,
sortDirection: "asc" | "desc" | "none" | undefined,
): "ascending" | "descending" | "none" | undefined {
if (sortDirection === "asc") return "ascending";
if (sortDirection === "desc") return "descending";
@@ -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.**
*
@@ -31,45 +27,48 @@ type ResizeProps = {
*
* **NB:** This can cause a layout shift. Set a good initial width with `width` or `defaultWidth` to mitigate this.
*/
autoWidth?: boolean;
autoResizeOnce?: 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 `autoResizeOnce` is enabled.
* @default 40
*/
minWidth?: ColumnWidth;
resizeMin?: 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 `autoResizeOnce` is enabled.
*/
maxWidth?: ColumnWidth;
resizeMax?: number;
// TODO: Consider "allowing" %-width on last column, if we find a solution to the overflow issue (width becomes 0px).
/**
* Controlled width of the column. (Does not respect `resizeMin` and `resizeMax`.)
*
* Should only be used to fully control column width state. Otherwise, use `default` and let the component handle resizing.
*
* **NB:** Percentage as initial width does not work well with resizing.
*/
value?: number | string;
/**
* Initial width of the column. Only used when `value` is not set.
* (Does not respect `resizeMin` and `resizeMax`.)
*
* **NB:** Percentage as initial width does not work well with resizing.
* @default 140px
*/
defaultValue?: number | string;
/**
* Called when the column width changes.
* @param width New width in pixels.
*/
onWidthChange?: (width: ColumnWidth) => void;
/**
* Forwarded styles
*/
style?: React.CSSProperties;
/**
* Forwarded colSpan
*/
colSpan?: number;
onChange?: (width: number) => void;
};
type WithUndefined<T> = {
[K in keyof T]: T[K] | undefined;
};
type Unomittable<T> = WithUndefined<Required<T>>;
type TableColumnResizeArgs = Unomittable<ResizeProps> & {
type TableColumnResizeArgs = ResizeProps & {
thRef: React.RefObject<HTMLTableCellElement | null>;
colSpan: number | undefined;
};
type TableColumnResizeResult =
| {
style: React.CSSProperties;
width: number | string;
resizeHandlerProps: {
onMouseDown: DOMAttributes<HTMLButtonElement>["onMouseDown"];
onTouchStart: DOMAttributes<HTMLButtonElement>["onTouchStart"];
@@ -82,83 +81,74 @@ type TableColumnResizeResult =
enabled: true;
}
| {
style?: React.CSSProperties;
width?: number | string;
enabled: false;
};
/**
* TODO:
* - Do we allow % widths?
* - Auto-width mode is hard now since that might cause layout-shifts on mount. But would be preferable to
* be able to set "1fr" or similar and have it fill remaining space.
*/
function useTableColumnResize(
args: TableColumnResizeArgs,
): TableColumnResizeResult {
const {
thRef,
width: userWidth,
defaultWidth,
autoWidth,
onWidthChange,
maxWidth = Infinity,
minWidth = 40,
style,
colSpan,
} = args;
function useTableColumnResize({
resizable = true,
autoResizeOnce,
resizeMin = 40,
resizeMax = Infinity,
value,
defaultValue,
onChange,
thRef,
colSpan,
}: TableColumnResizeArgs): TableColumnResizeResult {
const tableContext = useDataTableContext();
const [isResizingWithKeyboard, setIsResizingWithKeyboard] = useState(false);
const ignoreNextOnClick = useRef(false);
const [width, _setWidth] = useControllableState({
value: userWidth,
defaultValue: defaultWidth ?? (colSpan ?? 1) * 140,
const [width, setWidth] = useControllableState({
value,
defaultValue: defaultValue ?? (colSpan ?? 1) * 140,
/**
* TODO:
* - Potential optimization: Only call when width as "stopped" changing, e.g. on mouse up or after a debounce when resizing with keyboard.
* Otherwise, this could cause excessive calls when resizing quickly.
*/
onChange: onWidthChange,
onChange,
});
const setWidth = useCallback(
const setClampedWidth = useCallback(
(newWidth: number) => {
const min = parseWidth(minWidth) ?? 0;
const max = parseWidth(maxWidth) ?? Infinity;
const clamped = Math.min(Math.max(newWidth, min), max);
_setWidth(clamped);
setWidth(Math.min(Math.max(newWidth, resizeMin), resizeMax));
},
[minWidth, maxWidth, _setWidth],
[resizeMin, resizeMax, setWidth],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: We only want to run this on mount and when autoWidth changes
// biome-ignore lint/correctness/useExhaustiveDependencies: We only want to run this on mount and when autoResizeOnce changes
useEffect(
function autoResizeColumn() {
if (!autoWidth) {
if (!autoResizeOnce) {
return;
}
const newColumnWidth = getAutoColumnWidth(thRef);
if (newColumnWidth) {
setWidth(newColumnWidth);
setClampedWidth(newColumnWidth);
}
},
[autoWidth], // eslint-disable-line react-hooks/exhaustive-deps
[autoResizeOnce], // eslint-disable-line react-hooks/exhaustive-deps
);
const handleOnClick: DOMAttributes<HTMLButtonElement>["onClick"] =
useCallback(() => {
// We need to use the onClick event in order to support screen readers properly,
// since some of them only send a mouse click when pressing enter/space.
// since some of them only send a mouse click (no kbd events) when pressing enter/space.
// We detect a "screen reader click" by checking if we had a mouseUp event right before.
if (ignoreNextOnClick.current) {
ignoreNextOnClick.current = false;
return;
}
setIsResizingWithKeyboard((prev) => !prev);
}, []);
@@ -173,19 +163,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 +183,7 @@ function useTableColumnResize(
setIsResizingWithKeyboard(false);
}
},
[isResizingWithKeyboard, setWidth, thRef],
[isResizingWithKeyboard, setClampedWidth, thRef],
);
const startResize = useCallback(
@@ -204,19 +194,16 @@ function useTableColumnResize(
const currentWidth = thRef.current?.offsetWidth ?? 0;
const newWidth = startWidth + (clientX - startX);
const min = parseWidth(minWidth) ?? 0;
const max = parseWidth(maxWidth) ?? Infinity;
if (newWidth > max) {
if (newWidth > resizeMax) {
setWidth(newWidth < currentWidth ? newWidth : currentWidth);
return;
}
if (newWidth < min) {
if (newWidth < resizeMin) {
setWidth(newWidth > currentWidth ? newWidth : currentWidth);
return;
}
setWidth(newWidth);
setClampedWidth(newWidth);
}
function onMouseMove(e: MouseEvent) {
@@ -244,7 +231,7 @@ function useTableColumnResize(
document.addEventListener("touchend", cleanup, { once: true });
document.addEventListener("touchcancel", cleanup, { once: true });
},
[maxWidth, minWidth, setWidth, thRef],
[resizeMax, resizeMin, setWidth, setClampedWidth, thRef],
);
const handleMouseDown: DOMAttributes<HTMLButtonElement>["onMouseDown"] =
@@ -263,27 +250,31 @@ function useTableColumnResize(
[startResize],
);
// Auto-size column to fit content on double click. NB: Doesn't work with block content!
// Auto-size column to fit content on double click
const handleDoubleClick: DOMAttributes<HTMLButtonElement>["onDoubleClick"] =
useCallback(() => {
const newColumnWidth = getAutoColumnWidth(thRef);
if (newColumnWidth) {
setWidth(newColumnWidth);
setClampedWidth(newColumnWidth);
}
}, [setWidth, thRef]);
}, [setClampedWidth, thRef]);
if (tableContext.layout !== "fixed") {
return {
style,
enabled: false,
};
}
if (!resizable) {
return {
width,
enabled: false,
};
}
return {
style: {
...style,
width,
},
width,
resizeHandlerProps: {
onMouseDown: handleMouseDown,
onTouchStart: handleTouchStart,
@@ -297,33 +288,24 @@ function useTableColumnResize(
};
}
function parseWidth(width: ColumnWidth | undefined): number | undefined {
if (width == null) {
return undefined;
}
if (typeof width === "number") {
return width;
}
if (typeof width === "string") {
const parsed = parseInt(width, 10);
return Number.isNaN(parsed) ? undefined : parsed;
}
return undefined;
}
/**
* Figures out how wide the column needs to be to fit all the content without truncation.
*/
function getAutoColumnWidth(
thRef: React.RefObject<HTMLTableCellElement | null>,
) {
const th = thRef.current!;
const thContent = th.querySelector(".aksel-data-table__th-content");
const thPaddingEl = th.querySelector("div");
const thPaddingEl = th.querySelector(".aksel-data-table__cell-content");
const rows = th.closest("table")?.querySelectorAll("tbody tr, tfoot tr");
if (!thContent || !thPaddingEl || !rows) {
return;
}
// Find needed width for header cell
const contentWidth = thContent.scrollWidth;
const range = document.createRange();
range.selectNodeContents(thContent);
const contentWidth = range.getBoundingClientRect().width;
const paddingElStyle = window.getComputedStyle(thPaddingEl);
const thInlinePadding =
parseInt(paddingElStyle.paddingLeft, 10) +
@@ -339,7 +321,6 @@ function getAutoColumnWidth(
}
// Find needed width for each cell in column in tbody and tfoot
const range = document.createRange();
let skipRows = 0;
for (const row of rows) {
// Skip rows where the cell in this column is covered by a rowspan from a previous row
@@ -358,17 +339,26 @@ function getAutoColumnWidth(
skipRows = cell.rowSpan - 1;
// Find needed width
const cellContent = cell.firstChild as HTMLElement;
range.selectNodeContents(cellContent);
const cellContentWidth = range.getBoundingClientRect().width;
const contentElStyle = window.getComputedStyle(cellContent);
const inlinePadding =
parseInt(contentElStyle.paddingLeft, 10) +
parseInt(contentElStyle.paddingRight, 10);
const cellContent = cell.querySelector(
".aksel-data-table__cell-content",
) as HTMLElement | null;
if (!cellContent) {
continue;
}
cellContent.style.width = "fit-content";
const cellContentWidth = cellContent.scrollWidth;
cellContent.style.removeProperty("width");
let marginLeft = 0;
if (cell.dataset.nested === "true") {
const contentElStyle = window.getComputedStyle(cellContent);
marginLeft = parseInt(contentElStyle.marginLeft, 10);
}
const widthNeededForThisCell =
(cellContentWidth + inlinePadding) / cell.colSpan;
(cellContentWidth + marginLeft + 1) / cell.colSpan;
if (widthNeededForThisCell > newColumnWidth) {
newColumnWidth = widthNeededForThisCell;
newColumnWidth = Math.ceil(widthNeededForThisCell);
}
}
@@ -0,0 +1,57 @@
import React from "react";
import {
getDataTableDetailsPanelId,
useDataTableDetailsPanel,
} from "../hooks/useTableDetailsPanel";
import { useDataTableContext } from "../root/DataTableRoot.context";
function DataTableDetailsPanelRow<T>({
rowId,
rowData,
}: {
rowId: string;
rowData: T;
}) {
const { tableId, totalColSpan } = useDataTableContext();
const {
enableDetailsPanel,
isExpanded,
getDetailsPanelContent,
getDetailsPanelHeight,
} = useDataTableDetailsPanel();
if (!enableDetailsPanel) {
return null;
}
if (!isExpanded(rowId)) {
return null;
}
const content = getDetailsPanelContent?.(rowData);
const expansionId = getDataTableDetailsPanelId(tableId, rowId);
if (!content) {
return null;
}
const panelHeight = getDetailsPanelHeight?.(rowData);
const style: React.CSSProperties = panelHeight
? { height: panelHeight, overflow: "auto" }
: { height: "auto" };
return (
<tr className="aksel-data-table__details-panel-row">
<td
id={expansionId}
colSpan={totalColSpan}
className="aksel-data-table__details-panel-row-cell"
>
<div style={style}>{content}</div>
</td>
</tr>
);
}
export { DataTableDetailsPanelRow };
@@ -1,21 +1,23 @@
type TableRowEntryId = string | number;
import type { TableRowEntryId } from "../root/DataGridTable.types";
type CollectTableRowEntriesArgs<T> = {
items: T[];
getRowId?: (rowData: T, index: number) => TableRowEntryId;
getSubRows?: (rowData: T) => T[];
isSubRowExpandable?: (rowData: T) => boolean;
getRows?: (rowData: T) => T[];
isRowExpandable?: (rowData: T) => boolean;
};
interface ItemDetail<T> {
id: string | number;
id: TableRowEntryId;
rowData: T;
level: number;
parent: null | T;
children: readonly T[];
parentId: TableRowEntryId | null;
children: readonly TableRowEntryId[];
}
type CollectTableRowEntriesReturn<T> = {
itemDetails: Map<T, ItemDetail<T>>;
itemDetails: Map<TableRowEntryId, ItemDetail<T>>;
rootRowIds: TableRowEntryId[];
/**
* Direct child ids for each row, used to traverse nested selection groups
* without storing every descendant list on each ancestor.
@@ -26,65 +28,68 @@ type CollectTableRowEntriesReturn<T> = {
function collectTableRowEntries<T>({
items,
getRowId,
getSubRows,
isSubRowExpandable,
getRows,
isRowExpandable,
}: CollectTableRowEntriesArgs<T>): CollectTableRowEntriesReturn<T> {
const itemDetailsMap = new Map<T, ItemDetail<T>>();
const itemDetailsMap = new Map<TableRowEntryId, ItemDetail<T>>();
const childRowIdsById = new Map<TableRowEntryId, TableRowEntryId[]>();
const rootRowIds: TableRowEntryId[] = [];
const traverseRow = (
rowData: T,
rowIndex: number,
level: number,
parent: T | null,
parentId?: TableRowEntryId,
parentRowId: TableRowEntryId | null,
): TableRowEntryId => {
const rowId =
getRowId?.(rowData, rowIndex) ??
(parentId == null ? rowIndex : `${parentId}-${rowIndex}`);
const isRowExpandable = isSubRowExpandable?.(rowData) ?? true;
const children = (isRowExpandable ? getSubRows?.(rowData) : []) ?? [];
const rowId = getRowId
? getRowId(rowData, rowIndex)
: getFallbackTableRowId(rowIndex, parentRowId);
itemDetailsMap.set(rowData, {
id: rowId,
level,
parent,
children,
});
const children =
((isRowExpandable?.(rowData) ?? true) ? getRows?.(rowData) : []) ?? [];
const childRowIds: TableRowEntryId[] = [];
for (let childIndex = 0; childIndex < children.length; childIndex++) {
const childRow = children[childIndex];
const childRowId = traverseRow(
childRow,
childIndex,
level + 1,
rowData,
rowId,
);
const childRowId = traverseRow(childRow, childIndex, level + 1, rowId);
childRowIds.push(childRowId);
}
itemDetailsMap.set(rowId, {
id: rowId,
rowData,
level,
parentId: parentRowId,
children: childRowIds,
});
childRowIdsById.set(rowId, childRowIds);
return rowId;
};
for (let rowIndex = 0; rowIndex < items.length; rowIndex++) {
traverseRow(items[rowIndex], rowIndex, 0, null);
rootRowIds.push(traverseRow(items[rowIndex], rowIndex, 0, null));
}
return {
itemDetails: itemDetailsMap,
rootRowIds,
childRowIdsById,
};
}
function getFallbackTableRowId(
rowIndex: number,
parentRowId: TableRowEntryId | null,
): string {
return parentRowId == null ? String(rowIndex) : `${parentRowId}.${rowIndex}`;
}
export { collectTableRowEntries };
export type {
CollectTableRowEntriesArgs,
CollectTableRowEntriesReturn,
TableRowEntryId,
ItemDetail,
};
@@ -1,66 +0,0 @@
import { describe, expect, test } from "vitest";
import { SelectionSubtreeHelper } from "./SelectionSubtreeHelper";
const childRowIdsById = new Map<string | number, (string | number)[]>([
["a", ["a1", "a2"]],
["a1", []],
["a2", ["a2a"]],
["a2a", []],
["b", []],
]);
describe("SelectionSubtreeHelper", () => {
test("returns selectable subtree keys without duplicates", () => {
const helper = new SelectionSubtreeHelper({
childRowIdsById,
disabledKeysSet: new Set(["a2"]),
selectedKeysSet: new Set(),
});
expect(helper.getSelectableKeys(["a", "a1"])).toEqual(["a", "a1", "a2a"]);
});
test("returns cached subtree selection stats", () => {
const helper = new SelectionSubtreeHelper({
childRowIdsById,
disabledKeysSet: new Set(["a2"]),
selectedKeysSet: new Set(["a", "a1", "a2a"]),
});
const firstStats = helper.getSelectionStats("a");
const secondStats = helper.getSelectionStats("a");
expect(firstStats).toEqual({ selectableCount: 3, selectedCount: 3 });
expect(secondStats).toBe(firstStats);
expect(helper.isFullySelected("a")).toBe(true);
});
test("handles deep trees iteratively", () => {
const depth = 12000;
const deepChildRowIdsById = new Map<string | number, (string | number)[]>();
const selectedKeysSet = new Set<string | number>();
for (let index = 0; index < depth; index++) {
const key = `node-${index}`;
const childKey = `node-${index + 1}`;
deepChildRowIdsById.set(key, index === depth - 1 ? [] : [childKey]);
if (index % 2 === 0) {
selectedKeysSet.add(key);
}
}
const helper = new SelectionSubtreeHelper({
childRowIdsById: deepChildRowIdsById,
disabledKeysSet: new Set(),
selectedKeysSet,
});
expect(helper.getSelectableKeys(["node-0"])).toHaveLength(depth);
expect(helper.getSelectionStats("node-0")).toEqual({
selectableCount: depth,
selectedCount: Math.ceil(depth / 2),
});
});
});
@@ -1,162 +0,0 @@
type SelectionKey = string | number;
type SelectionStats = {
selectableCount: number;
selectedCount: number;
};
type SelectionSubtreeHelperArgs = {
childRowIdsById?: Map<SelectionKey, SelectionKey[]>;
disabledKeysSet: Set<SelectionKey>;
selectedKeysSet: Set<SelectionKey>;
};
/**
* Helper class for managing selection state in a tree structure.
* - It provides methods to get selectable keys in a subtree
* - Compute selection statistics for a subtree
* - Determine if a subtree is fully selected.
*
* Results of selection statistics are cached to optimize performance for repeated calls on the same subtree.
*/
class SelectionSubtreeHelper {
private childRowIdsById: Map<SelectionKey, SelectionKey[]>;
private disabledKeysSet: Set<SelectionKey>;
private selectedKeysSet: Set<SelectionKey>;
private selectionStatsCache = new Map<SelectionKey, SelectionStats>();
constructor({
childRowIdsById,
disabledKeysSet,
selectedKeysSet,
}: SelectionSubtreeHelperArgs) {
this.childRowIdsById = childRowIdsById ?? new Map();
this.disabledKeysSet = disabledKeysSet;
this.selectedKeysSet = selectedKeysSet;
}
getSelectableKeys(rootIds: SelectionKey[]): SelectionKey[] {
const visitedKeys = new Set<SelectionKey>();
const selectableKeys: SelectionKey[] = [];
const stack = [...rootIds].reverse();
while (stack.length > 0) {
const key = stack.pop();
if (key == null || visitedKeys.has(key)) {
continue;
}
visitedKeys.add(key);
if (!this.disabledKeysSet.has(key)) {
selectableKeys.push(key);
}
const childRowIds = this.childRowIdsById.get(key) ?? [];
for (
let childIndex = childRowIds.length - 1;
childIndex >= 0;
childIndex--
) {
stack.push(childRowIds[childIndex]);
}
}
return selectableKeys;
}
/**
* Returns the number of selectable and selected rows in the subtree of the given root ID.
* Results are cached after the first computation to optimize repeated calls for the same root ID.
*
* The selectable count excludes disabled rows, and the selected count excludes disabled rows that are selected.
* The method is implemented iteratively to handle deep trees without hitting call stack limits.
*
* How it works:
* - Manually add root ID to stack to get processing going. Note that the ready-flag is `false`.
* - Pop stack until empty. For each entry:
* - - If entry is already cached, skip it.
* - - If entry is not ready, push it back as ready and push all its children as not ready.
* - - If entry is ready, compute its stats based on its own state and the stats of its children, then cache the result.
* - Since we add all the children to the stack after pushing element with ready: true, while "popping" the stack we will always encounter the children before their parent is ready, ensuring that the stats for all children are computed and cached before computing the stats for their parent.
* - Finally, return the cached stats for the root ID.
*/
getSelectionStats(rootId: SelectionKey): SelectionStats {
const cachedStats = this.selectionStatsCache.get(rootId);
if (cachedStats) {
return cachedStats;
}
/* Compute subtree totals iteratively so deep trees do not depend on call stack depth. */
const stack: { key: SelectionKey; ready: boolean }[] = [
{ key: rootId, ready: false },
];
while (stack.length > 0) {
const entry = stack.pop();
if (!entry) {
continue;
}
if (this.selectionStatsCache.has(entry.key)) {
continue;
}
if (entry.ready) {
let selectableCount = this.disabledKeysSet.has(entry.key) ? 0 : 1;
let selectedCount =
!this.disabledKeysSet.has(entry.key) &&
this.selectedKeysSet.has(entry.key)
? 1
: 0;
for (const childKey of this.childRowIdsById.get(entry.key) ?? []) {
const childStats = this.selectionStatsCache.get(childKey);
if (!childStats) {
continue;
}
selectableCount += childStats.selectableCount;
selectedCount += childStats.selectedCount;
}
this.selectionStatsCache.set(entry.key, {
selectableCount,
selectedCount,
});
continue;
}
stack.push({ key: entry.key, ready: true });
for (const childKey of this.childRowIdsById.get(entry.key) ?? []) {
if (!this.selectionStatsCache.has(childKey)) {
stack.push({ key: childKey, ready: false });
}
}
}
return (
this.selectionStatsCache.get(rootId) ?? {
selectableCount: 0,
selectedCount: 0,
}
);
}
isFullySelected(rootId: SelectionKey): boolean {
const stats = this.getSelectionStats(rootId);
return (
stats.selectableCount > 0 && stats.selectedCount === stats.selectableCount
);
}
}
export { SelectionSubtreeHelper };
export type { SelectionKey, SelectionStats, SelectionSubtreeHelperArgs };
@@ -1,107 +1,93 @@
import type { ChangeEventHandler, SetStateAction } from "react";
import type { CheckboxInputProps } from "../../../../form/checkbox/checkbox-input/CheckboxInput";
import { SelectionSubtreeHelper } from "./SelectionSubtreeHelper";
import { consoleWarning } from "../../../../utils/helpers/consoleWarning";
import type { UseTableItemsReturn } from "../../hooks/useTableItems";
import type { TableRowEntryId } from "../../root/DataGridTable.types";
import type { SelectionProps } from "./selection.types";
import { canSelectTableRow, mutateRowSelection } from "./selection.utils";
type GetMultipleSelectPropsArgs = {
selectedKeysSet: Set<string | number>;
selectedKeys: (string | number)[];
setSelectedKeys: (keys: (string | number)[]) => void;
disabledKeysSet: Set<string | number>;
visibleRowIds: (string | number)[];
childRowIdsById?: Map<string | number, (string | number)[]>;
};
type GetMultipleSelectPropsArgs<T> = {
selectedKeysSet: Set<TableRowEntryId>;
selectedKeys: string[];
setSelectedKeys: (next: SetStateAction<string[]>) => void;
tableItems: UseTableItemsReturn<T>;
isLoading?: boolean;
} & Pick<SelectionProps<T>, "enableRowSelection">;
function getMultipleSelectProps({
function getMultipleSelectProps<T>({
selectedKeysSet,
selectedKeys,
setSelectedKeys,
disabledKeysSet,
visibleRowIds,
childRowIdsById,
}: GetMultipleSelectPropsArgs) {
const subtreeHelper = new SelectionSubtreeHelper({
childRowIdsById,
disabledKeysSet,
selectedKeysSet,
});
enableRowSelection,
tableItems,
isLoading,
}: GetMultipleSelectPropsArgs<T>) {
const selectableIdsSet: Set<TableRowEntryId> = new Set();
// Header selection traverses the visible roots and skips already visited
// descendants, so expanded trees stay linear in the number of rows.
const headerSelectableKeys = subtreeHelper.getSelectableKeys(visibleRowIds);
const headerSelectableKeysSet = new Set(headerSelectableKeys);
for (const [id, { rowData }] of tableItems.itemDetails) {
if (canSelectTableRow(enableRowSelection, { row: rowData, id })) {
selectableIdsSet.add(id);
}
}
const selectedSelectableCount = headerSelectableKeys.filter((k) =>
selectedKeysSet.has(k),
).length;
let selectedOnPageCount = 0;
for (const id of selectableIdsSet) {
selectedKeysSet.has(id) && selectedOnPageCount++;
}
const allSelectableSelected =
headerSelectableKeys.length > 0 &&
selectedSelectableCount === headerSelectableKeys.length;
const isAllSelected =
selectableIdsSet.size > 0 && selectedOnPageCount === selectableIdsSet.size;
const someSelected = selectedOnPageCount > 0;
const indeterminate =
selectedSelectableCount > 0 &&
selectedSelectableCount < headerSelectableKeys.length;
const handleToggleRow = (key: TableRowEntryId, row: T) => {
if (!row) {
consoleWarning(
`DataGrid.Table: Row data is undefined for key ${key}. This may cause issues with selection if enableRowSelection is used.`,
);
}
const selectedKeysNotInView = selectedKeys.filter(
(k) => !headerSelectableKeysSet.has(k),
);
const disabledSelected = selectedKeys.filter((k) => disabledKeysSet.has(k));
const preservedKeys = [
...new Set([...selectedKeysNotInView, ...disabledSelected]),
];
const isGroupFullySelected = (key: string | number) => {
const groupStats = subtreeHelper.getSelectionStats(key);
return (
groupStats.selectableCount > 0 &&
groupStats.selectedCount === groupStats.selectableCount
);
};
const handleToggleAll = () => {
if (allSelectableSelected) {
setSelectedKeys(preservedKeys);
} else {
setSelectedKeys([
...new Set([...preservedKeys, ...headerSelectableKeys]),
]);
const checked = !selectedKeysSet.has(key);
const nextSet = new Set(selectedKeysSet);
const changed = mutateRowSelection({
selectedRowIds: nextSet,
rowId: key,
checked,
childRowIdsById: tableItems.childRowIdsById,
itemDetails: tableItems.itemDetails,
enableRowSelection,
});
if (changed) {
setSelectedKeys([...nextSet]);
}
};
const handleToggleRow = (key: string | number) => {
if (disabledKeysSet.has(key)) {
const toggleAllRowSelected: ChangeEventHandler<HTMLInputElement> = (
event,
) => {
if (isLoading) {
return;
}
const groupKeys = subtreeHelper.getSelectableKeys([key]);
if (isGroupFullySelected(key)) {
const groupKeysSet = new Set(groupKeys);
setSelectedKeys(
selectedKeys.filter((selectedKey) => !groupKeysSet.has(selectedKey)),
);
if (event.target.checked) {
const preserved = selectedKeys.filter((k) => !selectableIdsSet.has(k));
setSelectedKeys([...preserved, ...selectableIdsSet]);
} else {
setSelectedKeys([...new Set([...selectedKeys, ...groupKeys])]);
setSelectedKeys(selectedKeys.filter((k) => !selectableIdsSet.has(k)));
}
};
return {
getTheadCheckboxProps: (): CheckboxInputProps => ({
onChange: handleToggleAll,
checked: allSelectableSelected,
indeterminate,
disabled: headerSelectableKeys.length === 0,
checked: isAllSelected,
indeterminate: !isAllSelected && someSelected,
onChange: toggleAllRowSelected,
disabled: selectableIdsSet.size === 0 || isLoading,
}),
getRowCheckboxProps: (key: string | number): CheckboxInputProps => {
const groupStats = subtreeHelper.getSelectionStats(key);
getRowCheckboxProps: (key: TableRowEntryId, row: T): CheckboxInputProps => {
return {
onChange: () => handleToggleRow(key),
checked: isGroupFullySelected(key),
indeterminate:
groupStats.selectedCount > 0 &&
groupStats.selectedCount < groupStats.selectableCount,
disabled: disabledKeysSet.has(key),
onChange: () => handleToggleRow(key, row),
checked: selectedKeysSet.has(key),
indeterminate: false,
disabled: !canSelectTableRow(enableRowSelection, { row, id: key }),
};
},
toggleSelection: handleToggleRow,
@@ -1,33 +1,51 @@
import type { RadioInputProps } from "../../../../form/radio/radio-input/RadioInput";
import { consoleWarning } from "../../../../utils/helpers/consoleWarning";
import type { TableRowEntryId } from "../../root/DataGridTable.types";
import type { SelectionProps } from "./selection.types";
import { canSelectTableRow } from "./selection.utils";
type GetSingleSelectPropsArgs = {
selectedKeysSet: Set<string | number>;
setSelectedKeys: (keys: (string | number)[]) => void;
disabledKeysSet: Set<string | number>;
type GetSingleSelectPropsArgs<T> = {
selectedKeysSet: Set<TableRowEntryId>;
setSelectedKeys: (keys: string[]) => void;
name: string;
};
} & Pick<SelectionProps<T>, "enableRowSelection">;
function getSingleSelectProps({
function getSingleSelectProps<T>({
selectedKeysSet,
setSelectedKeys,
disabledKeysSet,
name,
}: GetSingleSelectPropsArgs) {
const handleSelectionChange = (key: string | number) => {
if (disabledKeysSet.has(key)) {
enableRowSelection,
}: GetSingleSelectPropsArgs<T>) {
const handleSelectionChange = (key: TableRowEntryId, row: T) => {
if (!row) {
consoleWarning(
`DataGrid.Table: Row data is undefined for key ${key}. This may cause issues with selection if enableRowSelection is used.`,
);
}
if (!canSelectTableRow(enableRowSelection, { row, id: key })) {
return;
}
setSelectedKeys([key]);
};
return {
getRowRadioProps: (key: string | number): RadioInputProps => ({
checked: selectedKeysSet.has(key),
onChange: () => handleSelectionChange(key),
disabled: disabledKeysSet.has(key),
value: key,
name,
}),
getRowRadioProps: (key: TableRowEntryId, row: T): RadioInputProps => {
const isSelectionDisabled = !canSelectTableRow(enableRowSelection, {
row,
id: key,
});
return {
checked: selectedKeysSet.has(key),
onChange: isSelectionDisabled
? () => null
: () => handleSelectionChange(key, row),
disabled: isSelectionDisabled,
value: key,
name,
};
},
toggleSelection: handleSelectionChange,
};
}
@@ -1,9 +1,7 @@
import type { CheckboxInputProps } from "../../../../form/checkbox/checkbox-input/CheckboxInput";
import type { RadioInputProps } from "../../../../form/radio/radio-input/RadioInput";
type SelectedKeysT = (string | number)[];
type SelectionProps = {
type SelectionProps<T = unknown> = {
/**
* Enables selection of rows.
*
@@ -14,59 +12,51 @@ type SelectionProps = {
*
* @default "none"
*/
selectionMode?: "none" | "single" | "multiple";
mode: "none" | "single" | "multiple";
/**
* Controlled selected keys. Should be used in conjunction with `onSelectionChange`.
* Controlled selected keys. Should be used in conjunction with `onSelectedRowIdsChange`.
*/
selectedKeys?: SelectedKeysT;
selectedRowIds?: string[];
/**
* Default selected keys when using uncontrolled selection. Should not be used together with `selectedKeys`.
*/
defaultSelectedKeys?: SelectedKeysT;
defaultSelectedRowIds?: string[];
/**
* Callback with array of selected keys.
*/
onSelectionChange?: (keys: SelectedKeysT) => void;
onSelectedRowIdsChange?: (ids: string[]) => void;
/**
* Keys that should be disabled for selection. These keys will not be selectable and will be styled as disabled.
* Callback to determine if a row should be enabled for selection.
*
*
* TODO: Consider making this optionally a callback with (rowData:T) => boolean, to allow for more dynamic disabling of selection based on row data.
* If set to a boolean, it will enable selection for all rows when true, and disable selection for all rows when false.
*/
disabledSelectionKeys?: SelectedKeysT;
/**
* If true, stops clicking a row from toggling its selection state. This can be used if you want to only allow selection through the checkboxes/radios, and not have the entire row be clickable for selection.
*
* @default false
*/
disableRowSelectionOnClick?: boolean;
enableRowSelection?:
| (({ row, id }: { row: T; id: string }) => boolean)
| boolean;
};
type NoneSelection = {
selectionMode: "none";
selectedKeys: SelectedKeysT;
disabledSelectionKeys: SelectedKeysT;
mode: "none";
selectedKeys: string[];
};
type SingleSelection = {
selectionMode: "single";
selectedKeys: SelectedKeysT;
disabledSelectionKeys: SelectedKeysT;
getRowRadioProps: (key: string | number) => RadioInputProps;
toggleSelection: (key: string | number) => void;
mode: "single";
selectedKeys: string[];
getRowRadioProps: (key: string, row: any) => RadioInputProps;
toggleSelection: (key: string, row: any) => void;
};
type MultipleSelection = {
selectionMode: "multiple";
selectedKeys: SelectedKeysT;
disabledSelectionKeys: SelectedKeysT;
mode: "multiple";
selectedKeys: string[];
getTheadCheckboxProps: () => CheckboxInputProps;
getRowCheckboxProps: (key: string | number) => CheckboxInputProps;
toggleSelection: (key: string | number) => void;
getRowCheckboxProps: (key: string, row: any) => CheckboxInputProps;
toggleSelection: (key: string, row: any) => void;
};
type TableSelectionBase = {
isRowSelected: (rowId: string | number) => boolean;
isRowSelected: (rowId: string) => boolean;
};
type TableSelection = TableSelectionBase &
@@ -78,5 +68,4 @@ export type {
SelectionProps,
SingleSelection,
TableSelection,
SelectedKeysT,
};
@@ -0,0 +1,161 @@
import { describe, expect, test } from "vitest";
import type { TableRowEntryId } from "../../root/DataGridTable.types";
import type { ItemDetail } from "../collectTableRowEntries";
import { mutateRowSelection } from "./selection.utils";
function makeItemDetails<T>(
entries: { id: TableRowEntryId; rowData: T }[],
): Map<TableRowEntryId, ItemDetail<T>> {
return new Map(
entries.map(({ id, rowData }) => [
id,
{ id, rowData, level: 0, parentId: null, children: [] },
]),
);
}
describe("mutateRowSelection", () => {
test("adds rowId to set when checked", () => {
const set = new Set<TableRowEntryId>();
const itemDetails = makeItemDetails([{ id: "a", rowData: {} }]);
const changed = mutateRowSelection({
selectedRowIds: set,
rowId: "a",
checked: true,
childRowIdsById: new Map(),
itemDetails,
});
expect(set.has("a")).toBe(true);
expect(changed).toBe(true);
});
test("removes rowId from set when unchecked", () => {
const set = new Set<TableRowEntryId>(["a"]);
const itemDetails = makeItemDetails([{ id: "a", rowData: {} }]);
const changed = mutateRowSelection({
selectedRowIds: set,
rowId: "a",
checked: false,
childRowIdsById: new Map(),
itemDetails,
});
expect(set.has("a")).toBe(false);
expect(changed).toBe(true);
});
test("returns false and does not mutate when row is already in desired state", () => {
const set = new Set<TableRowEntryId>(["a"]);
const itemDetails = makeItemDetails([{ id: "a", rowData: {} }]);
const changed = mutateRowSelection({
selectedRowIds: set,
rowId: "a",
checked: true,
childRowIdsById: new Map(),
itemDetails,
});
expect(set.size).toBe(1);
expect(changed).toBe(false);
});
test("recursively selects all children", () => {
const set = new Set<TableRowEntryId>();
const itemDetails = makeItemDetails([
{ id: "parent", rowData: {} },
{ id: "child1", rowData: {} },
{ id: "child2", rowData: {} },
]);
const childRowIdsById = new Map<TableRowEntryId, TableRowEntryId[]>([
["parent", ["child1", "child2"]],
]);
mutateRowSelection({
selectedRowIds: set,
rowId: "parent",
checked: true,
childRowIdsById,
itemDetails,
});
expect(set).toEqual(new Set(["parent", "child1", "child2"]));
});
test("recursively deselects all children", () => {
const set = new Set<TableRowEntryId>(["parent", "child1", "child2"]);
const itemDetails = makeItemDetails([
{ id: "parent", rowData: {} },
{ id: "child1", rowData: {} },
{ id: "child2", rowData: {} },
]);
const childRowIdsById = new Map<TableRowEntryId, TableRowEntryId[]>([
["parent", ["child1", "child2"]],
]);
mutateRowSelection({
selectedRowIds: set,
rowId: "parent",
checked: false,
childRowIdsById,
itemDetails,
});
expect(set.size).toBe(0);
});
test("handles deeply nested children", () => {
const set = new Set<TableRowEntryId>();
const itemDetails = makeItemDetails([
{ id: "a", rowData: {} },
{ id: "a1", rowData: {} },
{ id: "a1a", rowData: {} },
]);
const childRowIdsById = new Map<TableRowEntryId, TableRowEntryId[]>([
["a", ["a1"]],
["a1", ["a1a"]],
]);
mutateRowSelection({
selectedRowIds: set,
rowId: "a",
checked: true,
childRowIdsById,
itemDetails,
});
expect(set).toEqual(new Set(["a", "a1", "a1a"]));
});
test("skips disabled rows and their children", () => {
const set = new Set<TableRowEntryId>();
const itemDetails = makeItemDetails([
{ id: "parent", rowData: { disabled: false } },
{ id: "child1", rowData: { disabled: true } },
{ id: "child1a", rowData: { disabled: false } },
{ id: "child2", rowData: { disabled: false } },
]);
const childRowIdsById = new Map<TableRowEntryId, TableRowEntryId[]>([
["parent", ["child1", "child2"]],
["child1", ["child1a"]],
]);
mutateRowSelection({
selectedRowIds: set,
rowId: "parent",
checked: true,
childRowIdsById,
itemDetails,
enableRowSelection: ({ row }) => !row.disabled,
});
expect(set.has("parent")).toBe(true);
expect(set.has("child1")).toBe(false);
expect(set.has("child1a")).toBe(true);
expect(set.has("child2")).toBe(true);
});
test("returns false when all matching rows were already disabled", () => {
const set = new Set<TableRowEntryId>();
const itemDetails = makeItemDetails([{ id: "a", rowData: {} }]);
const changed = mutateRowSelection({
selectedRowIds: set,
rowId: "a",
checked: true,
childRowIdsById: new Map(),
itemDetails,
enableRowSelection: false,
});
expect(set.size).toBe(0);
expect(changed).toBe(false);
});
});
@@ -0,0 +1,73 @@
import type { TableRowEntryId } from "../../root/DataGridTable.types";
import type { ItemDetail } from "../collectTableRowEntries";
import type { SelectionProps } from "./selection.types";
function canSelectTableRow<T>(
enableRowSelection: SelectionProps<T>["enableRowSelection"],
args: { row: T; id: TableRowEntryId },
): boolean {
if (typeof enableRowSelection === "function") {
return enableRowSelection(args);
}
return enableRowSelection ?? true;
}
type MutateRowSelectionArgs<T> = {
selectedRowIds: Set<TableRowEntryId>;
rowId: TableRowEntryId;
checked: boolean;
childRowIdsById: Map<TableRowEntryId, TableRowEntryId[]>;
itemDetails: Map<TableRowEntryId, ItemDetail<T>>;
enableRowSelection?: SelectionProps<T>["enableRowSelection"];
};
/**
* Traverses the row and its children and updates selected-state directly on given selectedRowIds set.
* Returns true if any changes were made to the set, false otherwise.
*/
function mutateRowSelection<T>({
selectedRowIds,
rowId,
checked,
childRowIdsById,
itemDetails,
enableRowSelection,
}: MutateRowSelectionArgs<T>): boolean {
let changed = false;
const item = itemDetails.get(rowId);
if (
item &&
canSelectTableRow(enableRowSelection, { row: item.rowData, id: rowId })
) {
if (checked && !selectedRowIds.has(rowId)) {
selectedRowIds.add(rowId);
changed = true;
} else if (!checked && selectedRowIds.has(rowId)) {
selectedRowIds.delete(rowId);
changed = true;
}
}
const children = childRowIdsById.get(rowId);
if (children) {
for (const childId of children) {
if (
mutateRowSelection({
selectedRowIds,
rowId: childId,
checked,
childRowIdsById,
itemDetails,
enableRowSelection,
})
) {
changed = true;
}
}
}
return changed;
}
export { canSelectTableRow, mutateRowSelection };
@@ -121,16 +121,70 @@ function prepareCellFocus(cell: Element): HTMLElement | null {
/**
* Applies focus and scroll to an element.
*/
function applyFocusAndScroll(element: HTMLElement): void {
element.focus({
preventScroll: true,
});
function getStickyOffsets(element: HTMLElement): {
stickyOffsetStart: number;
stickyOffsetEnd: number;
stickyHeaderHeight: number;
} {
const table = element.closest(".aksel-data-table");
element.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "nearest",
});
if (!table) {
return {
stickyOffsetStart: 0,
stickyOffsetEnd: 0,
stickyHeaderHeight: 0,
};
}
const stickyHeader = table.querySelector<HTMLElement>(
`.aksel-data-table__thead[data-sticky="true"]`,
);
const stickyNodesStart = table.querySelectorAll<HTMLElement>(
`.aksel-data-table__column-header[data-sticky="start"]`,
);
const stickyNodesEnd = table.querySelectorAll<HTMLElement>(
`.aksel-data-table__column-header[data-sticky="end"]`,
);
return {
stickyOffsetStart: Array.from(stickyNodesStart).reduce(
(offset, node) => offset + node.getBoundingClientRect().width,
0,
),
stickyOffsetEnd: Array.from(stickyNodesEnd).reduce(
(offset, node) => offset + node.getBoundingClientRect().width,
0,
),
stickyHeaderHeight: stickyHeader?.getBoundingClientRect().height ?? 0,
};
}
function applyFocusAndScroll(element: HTMLElement): void {
const { stickyOffsetStart, stickyOffsetEnd, stickyHeaderHeight } =
getStickyOffsets(element);
const originalScrollMarginInline = element.style.scrollMarginInline;
const originalScrollMarginBlockStart = element.style.scrollMarginBlockStart;
element.style.scrollMarginInline = `${stickyOffsetStart}px ${stickyOffsetEnd}px`;
element.style.scrollMarginBlockStart = `${stickyHeaderHeight}px`;
try {
element.focus({
preventScroll: true,
});
element.scrollIntoView({
behavior: "auto",
block: "nearest",
inline: "nearest",
});
} finally {
element.style.scrollMarginBlockStart = originalScrollMarginBlockStart;
element.style.scrollMarginInline = originalScrollMarginInline;
}
}
/**
@@ -16,8 +16,7 @@ type NavigationAction =
/**
* Maps keyboard events to navigation actions.
* Supports arrow keys, Home/End (row navigation), Ctrl/Cmd+Home/End (table navigation),
* and PageUp/PageDown (multi-row navigation).
* Supports arrow keys, Home/End (row navigation), Ctrl/Cmd+Home/End (table navigation).
*/
function getNavigationAction(event: KeyboardEvent): NavigationAction | null {
const key = event.key;
@@ -1,115 +0,0 @@
import { act, renderHook } from "@testing-library/react";
import React from "react";
import { describe, expect, test, vi } from "vitest";
import {
DataTableExpansionProvider,
useDataTableExpansion,
} from "../useTableExpansion";
import { useTableItems } from "../useTableItems";
type TestRow = {
id: number;
subRows?: TestRow[];
};
function createWrapper(
options: {
onDetailsPanelChange?: (ids: (string | number)[]) => void;
isDetailsPanelExpandable?: (row: TestRow) => boolean;
} = {},
) {
const rows: TestRow[] = [{ id: 1, subRows: [{ id: 10 }] }, { id: 2 }];
return function Wrapper({ children }: { children: React.ReactNode }) {
const tableItems = useTableItems({
items: rows,
getRowId: (row) => row.id,
getSubRows: (row) => row.subRows ?? [],
});
return (
<DataTableExpansionProvider<TestRow>
itemDetails={tableItems.itemDetails}
getDetailsPanelContent={(row) => row.id}
isDetailsPanelExpandable={options.isDetailsPanelExpandable}
onDetailsPanelChange={options.onDetailsPanelChange}
>
{children}
</DataTableExpansionProvider>
);
};
}
describe("useTableExpansion", () => {
test("does not allow toggling rows that are not expandable", () => {
const onDetailsPanelChange = vi.fn();
const { result } = renderHook(() => useDataTableExpansion(), {
wrapper: createWrapper({
onDetailsPanelChange,
isDetailsPanelExpandable: (row) => row.id === 1,
}),
});
expect(result.current.isDetailsPanelExpandable(1)).toBe(true);
expect(result.current.isDetailsPanelExpandable(2)).toBe(false);
expect(result.current.isDetailsPanelExpandable(10)).toBe(false);
act(() => {
result.current.toggleExpansion(2);
});
expect(result.current.isExpanded(2)).toBe(false);
expect(onDetailsPanelChange).not.toHaveBeenCalled();
act(() => {
result.current.toggleExpansion(1);
});
expect(result.current.isExpanded(1)).toBe(true);
expect(onDetailsPanelChange).toHaveBeenCalledWith([1]);
});
test("expand all only expands expandable rows", () => {
const onDetailsPanelChange = vi.fn();
const { result } = renderHook(() => useDataTableExpansion(), {
wrapper: createWrapper({
onDetailsPanelChange,
isDetailsPanelExpandable: (row) => row.id === 1,
}),
});
act(() => {
result.current.toggleAll();
});
expect(result.current.isExpanded(1)).toBe(true);
expect(result.current.isExpanded(2)).toBe(false);
expect(result.current.isAllExpanded).toBe(true);
expect(onDetailsPanelChange).toHaveBeenCalledWith([1]);
});
test("expand all only targets top-level table items", () => {
const onDetailsPanelChange = vi.fn();
const { result } = renderHook(() => useDataTableExpansion(), {
wrapper: createWrapper({
onDetailsPanelChange,
}),
});
expect(result.current.isDetailsPanelExpandable(1)).toBe(true);
expect(result.current.isDetailsPanelExpandable(2)).toBe(true);
expect(result.current.isDetailsPanelExpandable(10)).toBe(false);
act(() => {
result.current.toggleAll();
});
expect(result.current.isExpanded(1)).toBe(true);
expect(result.current.isExpanded(2)).toBe(true);
expect(result.current.isExpanded(10)).toBe(false);
expect(onDetailsPanelChange).toHaveBeenCalledWith([1, 2]);
});
});
@@ -1,5 +1,6 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, test } from "vitest";
import type { TableRowEntryId } from "../../root/DataGridTable.types";
import { useTableItems } from "../useTableItems";
type TestRow = {
@@ -45,6 +46,12 @@ const fallbackRows: FallbackTestRow[] = [
},
];
const duplicatedRowObject: TestRow = {
id: "shared",
name: "Shared",
subRows: [{ id: "shared-child", name: "Child" }],
};
const getSubRows = (row: TestRow) => row.subRows ?? [];
const getVisibleIds = (rows: TestRow[]) => rows.map((row) => row.id);
@@ -59,16 +66,18 @@ describe("useTableItems", () => {
);
expect(getVisibleIds(result.current.items)).toEqual(["a", "b"]);
expect(result.current.itemDetails.get(plainRows[0])).toMatchObject({
expect(result.current.itemDetails.get("a")).toMatchObject({
id: "a",
rowData: plainRows[0],
level: 0,
parent: null,
parentId: null,
children: [],
});
expect(result.current.itemDetails.get(plainRows[1])).toMatchObject({
expect(result.current.itemDetails.get("b")).toMatchObject({
id: "b",
rowData: plainRows[1],
level: 0,
parent: null,
parentId: null,
children: [],
});
});
@@ -78,8 +87,10 @@ describe("useTableItems", () => {
useTableItems({
items: nestedRows,
getRowId: (row) => row.id,
getSubRows,
defaultExpandedSubRowIds: ["a"],
subRows: {
getRows: getSubRows,
defaultExpandedRowIds: ["a"],
},
}),
);
@@ -91,7 +102,7 @@ describe("useTableItems", () => {
useTableItems({
items: nestedRows,
getRowId: (row) => row.id,
getSubRows,
subRows: { getRows: getSubRows },
}),
);
@@ -100,12 +111,14 @@ describe("useTableItems", () => {
expect(result.current.childRowIdsById.get("b")).toEqual(["b1"]);
});
test("uses the same fallback root id to reveal child rows when getRowId is omitted", () => {
test("uses unique fallback ids to reveal child rows when getRowId is omitted", () => {
const { result } = renderHook(() =>
useTableItems({
items: fallbackRows,
getSubRows: (row) => row.subRows ?? [],
defaultExpandedSubRowIds: [0],
subRows: {
getRows: (row: any) => row.subRows ?? [],
defaultExpandedRowIds: ["0"],
},
}),
);
@@ -113,6 +126,7 @@ describe("useTableItems", () => {
"Parent",
"Child",
]);
expect(result.current.childRowIdsById.get("0")).toEqual(["0.0"]);
});
test("updates visible rows in depth-first order for controlled expanded ids", () => {
@@ -121,11 +135,13 @@ describe("useTableItems", () => {
useTableItems({
items: nestedRows,
getRowId: (row) => row.id,
getSubRows,
expandedSubRowIds: expandedIds,
subRows: {
getRows: getSubRows,
expandedRowIds: expandedIds,
},
}),
{
initialProps: { expandedIds: [] as (string | number)[] },
initialProps: { expandedIds: [] as TableRowEntryId[] },
},
);
@@ -142,4 +158,35 @@ describe("useTableItems", () => {
"b1",
]);
});
test("tracks duplicated row objects by row id instead of object identity", () => {
const { result } = renderHook(() =>
useTableItems({
items: [duplicatedRowObject, duplicatedRowObject],
subRows: {
getRows: getSubRows,
defaultExpandedRowIds: ["0"],
},
}),
);
expect(result.current.visibleRowIds).toEqual(["0", "0.0", "1"]);
expect(getVisibleIds(result.current.items)).toEqual([
"shared",
"shared-child",
"shared",
]);
expect(result.current.itemDetails.get("0")).toMatchObject({
id: "0",
rowData: duplicatedRowObject,
parentId: null,
children: ["0.0"],
});
expect(result.current.itemDetails.get("1")).toMatchObject({
id: "1",
rowData: duplicatedRowObject,
parentId: null,
children: ["1.0"],
});
});
});
@@ -1,475 +0,0 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, test, vi } from "vitest";
import type {
MultipleSelection,
SingleSelection,
} from "../../helpers/selection/selection.types";
import {
type UseTableSelectionReturn,
useTableSelection,
} from "../useTableSelection";
type Item = { id: string; name: string };
const items: Item[] = [
{ id: "a", name: "Alpha" },
{ id: "b", name: "Beta" },
{ id: "c", name: "Charlie" },
];
const visibleRowIds = items.map((item) => item.id);
const childRowIdsById = new Map<string | number, (string | number)[]>([
["a", ["a1", "a2"]],
["a1", []],
["a2", ["a2a"]],
["a2a", []],
]);
function asSingle(result: {
current: UseTableSelectionReturn;
}): SingleSelection {
return result.current.selection as SingleSelection;
}
function asMultiple(result: {
current: UseTableSelectionReturn;
}): MultipleSelection {
return result.current.selection as MultipleSelection;
}
describe("useTableSelection", () => {
describe('selectionMode="none"', () => {
test("returns empty selectedKeys and no prop getters", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "none",
visibleRowIds,
}),
);
expect(result.current.selection.selectionMode).toBe("none");
expect(result.current.selection.selectedKeys).toEqual([]);
});
});
describe('selectionMode="single"', () => {
test("returns getRowRadioProps", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "single",
visibleRowIds,
}),
);
expect(result.current.selection.selectionMode).toBe("single");
expect(asSingle(result).getRowRadioProps).toBeDefined();
});
test("selecting a row via radio onChange", () => {
const onChange = vi.fn();
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "single",
visibleRowIds,
onSelectionChange: onChange,
}),
);
const radioProps = asSingle(result).getRowRadioProps("a");
expect(radioProps.checked).toBe(false);
act(() => {
radioProps.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
});
expect(asSingle(result).selectedKeys).toEqual(["a"]);
});
test("toggling the same row keeps it selected", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "single",
visibleRowIds,
defaultSelectedKeys: ["a"],
}),
);
act(() => {
asSingle(result)
.getRowRadioProps("a")
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
});
expect(asSingle(result).selectedKeys).toEqual(["a"]);
});
test("selecting a new row replaces the previous", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "single",
visibleRowIds,
defaultSelectedKeys: ["a"],
}),
);
act(() => {
asSingle(result)
.getRowRadioProps("b")
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
});
expect(asSingle(result).selectedKeys).toEqual(["b"]);
});
test("disabled rows have disabled prop", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "single",
visibleRowIds,
disabledSelectionKeys: ["b"],
}),
);
expect(asSingle(result).getRowRadioProps("a").disabled).toBe(false);
expect(asSingle(result).getRowRadioProps("b").disabled).toBe(true);
});
test("controlled selectedKeys", () => {
const { result, rerender } = renderHook(
({ selectedKeys }) =>
useTableSelection({
selectionMode: "single",
visibleRowIds,
selectedKeys,
}),
{ initialProps: { selectedKeys: ["a"] as (string | number)[] } },
);
expect(asSingle(result).selectedKeys).toEqual(["a"]);
rerender({ selectedKeys: ["b"] });
expect(asSingle(result).selectedKeys).toEqual(["b"]);
});
});
describe('selectionMode="multiple"', () => {
test("returns getTheadCheckboxProps and getRowCheckboxProps", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
visibleRowIds,
}),
);
expect(result.current.selection.selectionMode).toBe("multiple");
expect(asMultiple(result).getTheadCheckboxProps).toBeDefined();
expect(asMultiple(result).getRowCheckboxProps).toBeDefined();
});
test("selecting individual rows", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
visibleRowIds,
}),
);
act(() => {
asMultiple(result)
.getRowCheckboxProps("a")
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
});
expect(asMultiple(result).selectedKeys).toEqual(["a"]);
act(() => {
asMultiple(result)
.getRowCheckboxProps("c")
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
});
expect(asMultiple(result).selectedKeys).toEqual(["a", "c"]);
});
test("deselecting a row", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
visibleRowIds,
defaultSelectedKeys: ["a", "b"],
}),
);
act(() => {
asMultiple(result)
.getRowCheckboxProps("a")
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
});
expect(asMultiple(result).selectedKeys).toEqual(["b"]);
});
test("select all via thead checkbox", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
visibleRowIds,
}),
);
act(() => {
asMultiple(result)
.getTheadCheckboxProps()
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
});
expect(asMultiple(result).selectedKeys).toEqual(["a", "b", "c"]);
});
test("select all via thead includes hidden descendants for visible parents", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
visibleRowIds: ["a"],
childRowIdsById,
}),
);
act(() => {
asMultiple(result)
.getTheadCheckboxProps()
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
});
expect(asMultiple(result).selectedKeys).toEqual(["a", "a1", "a2", "a2a"]);
});
test("deselect all when all are selected", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
visibleRowIds,
defaultSelectedKeys: ["a", "b", "c"],
}),
);
act(() => {
asMultiple(result)
.getTheadCheckboxProps()
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
});
expect(asMultiple(result).selectedKeys).toEqual([]);
});
test("deselect all clears hidden descendants for visible parents but preserves unrelated keys", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
visibleRowIds: ["a"],
childRowIdsById,
defaultSelectedKeys: ["a", "a1", "a2", "a2a", "external"],
}),
);
act(() => {
asMultiple(result)
.getTheadCheckboxProps()
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
});
expect(asMultiple(result).selectedKeys).toEqual(["external"]);
});
test("select all skips disabled keys", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
visibleRowIds,
disabledSelectionKeys: ["b"],
}),
);
act(() => {
asMultiple(result)
.getTheadCheckboxProps()
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
});
expect(asMultiple(result).selectedKeys).toEqual(["a", "c"]);
});
test("deselect all preserves disabled-but-selected rows", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
visibleRowIds,
defaultSelectedKeys: ["a", "b", "c"],
disabledSelectionKeys: ["b"],
}),
);
act(() => {
asMultiple(result)
.getTheadCheckboxProps()
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
});
expect(asMultiple(result).selectedKeys).toEqual(["b"]);
});
test("thead checkbox shows indeterminate when partially selected", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
visibleRowIds,
defaultSelectedKeys: ["a"],
}),
);
const theadProps = asMultiple(result).getTheadCheckboxProps();
expect(theadProps.indeterminate).toBe(true);
expect(theadProps.checked).toBe(false);
});
test("thead checkbox shows checked when all selected", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
visibleRowIds,
defaultSelectedKeys: ["a", "b", "c"],
}),
);
const theadProps = asMultiple(result).getTheadCheckboxProps();
expect(theadProps.indeterminate).toBe(false);
expect(theadProps.checked).toBe(true);
});
test("thead checkbox shows checked when all selectable rows are selected", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
visibleRowIds,
defaultSelectedKeys: ["a", "c"],
disabledSelectionKeys: ["b"],
}),
);
const theadProps = asMultiple(result).getTheadCheckboxProps();
expect(theadProps.indeterminate).toBe(false);
expect(theadProps.checked).toBe(true);
});
test("deselecting one row when all rows are selected", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
visibleRowIds,
defaultSelectedKeys: ["a", "b", "c"],
}),
);
act(() => {
asMultiple(result)
.getRowCheckboxProps("b")
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
});
expect(asMultiple(result).selectedKeys).toEqual(["a", "c"]);
});
test("disabled rows have disabled prop", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
visibleRowIds,
disabledSelectionKeys: ["b"],
}),
);
expect(asMultiple(result).getRowCheckboxProps("a").disabled).toBe(false);
expect(asMultiple(result).getRowCheckboxProps("b").disabled).toBe(true);
});
test("thead checkbox disabled when all rows disabled", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
visibleRowIds,
disabledSelectionKeys: ["a", "b", "c"],
}),
);
expect(asMultiple(result).getTheadCheckboxProps().disabled).toBe(true);
});
test("parent rows show indeterminate when visible descendants are partially selected", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
visibleRowIds: ["a", "a1", "a2"],
childRowIdsById,
defaultSelectedKeys: ["a1"],
}),
);
const parentProps = asMultiple(result).getRowCheckboxProps("a");
expect(parentProps.checked).toBe(false);
expect(parentProps.indeterminate).toBe(true);
});
test("toggling a parent row selects and deselects its descendants", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
visibleRowIds: ["a", "a1", "a2"],
childRowIdsById,
}),
);
act(() => {
asMultiple(result)
.getRowCheckboxProps("a")
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
});
expect(asMultiple(result).selectedKeys).toEqual(["a", "a1", "a2", "a2a"]);
act(() => {
asMultiple(result)
.getRowCheckboxProps("a")
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
});
expect(asMultiple(result).selectedKeys).toEqual([]);
});
test("toggling a collapsed parent selects and deselects hidden descendants", () => {
const { result } = renderHook(() =>
useTableSelection({
selectionMode: "multiple",
visibleRowIds: ["a"],
childRowIdsById,
}),
);
act(() => {
asMultiple(result)
.getRowCheckboxProps("a")
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
});
expect(asMultiple(result).selectedKeys).toEqual(["a", "a1", "a2", "a2a"]);
act(() => {
asMultiple(result)
.getRowCheckboxProps("a")
.onChange?.({} as React.ChangeEvent<HTMLInputElement>);
});
expect(asMultiple(result).selectedKeys).toEqual([]);
});
});
});
@@ -1,48 +1,131 @@
import { useMemo } from "react";
import { consoleWarning } from "../../../utils/helpers/consoleWarning";
import type {
ColumnDefinition,
ColumnDefinitions,
} from "../root/DataTable.types";
import type { SelectionProps } from "./useTableSelection";
} from "../root/DataGridTable.types";
import { ACTION_CELL_WIDTH } from "../tr/DataTableTr";
type UseColumnOptions = {
stickyColumns?: {
first?: "1";
last?: "1";
start?: 1;
end?: 1;
};
selectionMode: SelectionProps["selectionMode"];
hasSelection: boolean;
hasDetailsPanel: boolean;
layout: "fixed" | "auto";
columnDisplay?: {
id: string;
visible: boolean;
}[];
};
type StickyStartState = {
selection: boolean;
expansion: boolean;
selectionOffset: number;
firstColumnOffset: number;
};
type UseColumnOptionsResult<T> = {
columns: {
isSticky: "start" | "end" | false;
isStickyLast?: boolean;
stickyLeftOffset?: number;
colDef: ColumnDefinition<T>;
}[];
stickySelection: boolean;
stickyStart: StickyStartState;
totalColSpan: number;
};
function useColumnOptions<T>(
columnDefinitions: ColumnDefinitions<T>,
options: UseColumnOptions,
): UseColumnOptionsResult<T> {
const { stickyColumns, selectionMode } = options;
const {
stickyColumns,
hasSelection,
hasDetailsPanel,
layout,
columnDisplay,
} = options;
const hasSelection = selectionMode !== "none";
const hasStickyStart = stickyColumns?.start === 1;
return {
stickySelection: selectionMode !== "none" && stickyColumns?.first === "1",
columns: columnDefinitions.map((colDef, index) => {
const isFirstSticky =
stickyColumns?.first === "1" && index === 0 && !hasSelection;
const stickyExpansion = hasStickyStart && hasDetailsPanel;
const stickySelection = hasStickyStart && hasSelection;
const stickySelectionOffset = stickyExpansion ? ACTION_CELL_WIDTH : 0;
const stickyFirstColumnOffset =
(stickyExpansion ? ACTION_CELL_WIDTH : 0) +
(stickySelection ? ACTION_CELL_WIDTH : 0);
const visibleColumns = useMemo(() => {
return orderColumnsAndFilterByVisibility(columnDefinitions, columnDisplay);
}, [columnDefinitions, columnDisplay]);
const columns = useMemo(() => {
return visibleColumns.map((colDef, index) => {
const isFirstSticky = hasStickyStart && index === 0;
const isLastSticky =
stickyColumns?.last === "1" && index === columnDefinitions.length - 1;
stickyColumns?.end === 1 && index === visibleColumns.length - 1;
return {
isSticky: isFirstSticky ? "start" : isLastSticky ? "end" : false,
isSticky: isFirstSticky
? ("start" as const)
: isLastSticky
? ("end" as const)
: (false as const),
isStickyLast: isFirstSticky && !isLastSticky,
stickyLeftOffset: isFirstSticky ? stickyFirstColumnOffset : undefined,
colDef,
};
}),
});
}, [visibleColumns, hasStickyStart, stickyColumns, stickyFirstColumnOffset]);
const totalColSpan =
columns.length +
(layout === "fixed" ? 1 : 0) +
(hasSelection ? 1 : 0) +
(hasDetailsPanel ? 1 : 0);
return {
stickyStart: {
selection: stickySelection,
expansion: stickyExpansion,
selectionOffset: stickySelectionOffset,
firstColumnOffset: stickyFirstColumnOffset,
},
columns,
totalColSpan,
};
}
function orderColumnsAndFilterByVisibility<T>(
columns: ColumnDefinition<T>[],
columnDisplay?: { id: string; visible: boolean }[],
): ColumnDefinition<T>[] {
if (!columnDisplay) {
return columns;
}
const columnMap = new Map(columns.map((col) => [col.id, col]));
return columnDisplay.reduce<ColumnDefinition<T>[]>((acc, { id, visible }) => {
const col = columnMap.get(id);
if (!col) {
consoleWarning(
`DataGrid: Column with id "${id}" not found in column definitions. Please check your columnDisplay configuration.`,
);
}
if (col && visible) {
acc.push(col);
}
return acc;
}, []);
}
export { useColumnOptions };
export type { UseColumnOptionsResult };
export type { StickyStartState, UseColumnOptionsResult };
@@ -14,7 +14,7 @@ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
});
const [activeCell, setActiveCell] = useState<Element | null>(null);
const activeCellRef = useValueAsRef(activeCell).current;
const activeCellRef = useValueAsRef(activeCell);
const observerRef = useRef<MutationObserver | null>(null);
useEffect(() => {
@@ -24,7 +24,8 @@ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
observerRef.current = new MutationObserver(() => {
gridCacheRef.current.dirty = true;
if (activeCellRef && !activeCellRef.isConnected) {
if (activeCellRef.current && !activeCellRef.current.isConnected) {
setActiveCell(null);
}
});
@@ -0,0 +1,178 @@
import React, { useCallback } from "react";
import { createStrictContext } from "../../../utils/helpers";
import { useControllableState } from "../../../utils/hooks";
import { useDataTableContext } from "../root/DataTableRoot.context";
type DetailsPanelProps<T> = {
/**
* Function to get the content to show in the details panel for a given row.
* When provided, an expand toggle column is added automatically.
*/
getContent: (rowData: T) => React.ReactNode;
/**
* Determines whether a row can be expanded to show details panel content.
* @default () => true
*/
isRowExpandable?: (rowData: T) => boolean;
/**
* Controlled list of expanded row IDs.
* Use with `onExpandedRowIdsChange` for controlled usage, or `defaultExpandedRowIds` for uncontrolled.
*/
expandedRowIds?: string[];
/**
* Initial list of expanded row IDs for uncontrolled usage.
* @default []
*/
defaultExpandedRowIds?: string[];
/**
* Called when the list of expanded row IDs changes.
*/
onExpandedRowIdsChange?: (ids: string[]) => void; // TODO: Docs: This pattern is called "Master / Detail" in general terms
/**
* Returns the height (in px) or `"auto"` for a row's details panel.
* When a number is returned, the panel scrolls within that fixed height.
* @default "auto"
*/
getHeight?: (rowData: T) => number | "auto";
/**
* Shows an expand-all toggle button in the expand column header.
* @default false
*/
showExpandAll?: boolean;
};
type DataTableDetailsPanelContextT = {
isExpanded: (id: string) => boolean;
isDetailsPanelExpandable: (id: string) => boolean;
toggleExpansion: (id: string) => void;
toggleAll: () => void;
isAllExpanded: boolean;
getDetailsPanelContent?: (row: unknown) => React.ReactNode;
getDetailsPanelHeight?: (row: unknown) => number | "auto";
showExpandAll: boolean;
enableDetailsPanel: boolean;
};
const {
Provider: DataTableDetailsPanelContextProvider,
useContext: useDataTableDetailsPanel,
} = createStrictContext<DataTableDetailsPanelContextT>({
name: "DataTableDetailsPanelContext",
errorMessage:
"useDataTableDetailsPanel must be used within a DataTableDetailsPanelProvider.",
});
function DataTableDetailsPanelProvider<T>({
children,
detailsPanel,
}: { detailsPanel?: DetailsPanelProps<T> } & { children: React.ReactNode }) {
const {
expandedRowIds,
defaultExpandedRowIds = [],
onExpandedRowIdsChange,
getContent,
isRowExpandable,
getHeight,
showExpandAll = false,
} = detailsPanel || {};
const [expandedIds, setExpandedIds] = useControllableState({
value: expandedRowIds,
defaultValue: defaultExpandedRowIds,
onChange: onExpandedRowIdsChange,
});
/* TODO: False is just fallback until auto and root is merged */
const tableContext = useDataTableContext(false);
const { itemDetails } = tableContext?.tableItems ?? {
itemDetails: new Map<string, { rowData: T; id: string; level: number }>(),
};
const expandableIds = React.useMemo(() => {
if (!getContent) {
return new Set<string>();
}
const ids = new Set<string>();
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) => expandableIds.has(id),
[expandableIds],
);
const isExpanded = useCallback(
(id: string) =>
isDetailsPanelExpandableById(id) && expandedIds.includes(id),
[expandedIds, isDetailsPanelExpandableById],
);
const toggleExpansion = useCallback(
(id: string) => {
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) {
return `${tableId}-expansion-${rowId}`;
}
export {
DataTableDetailsPanelProvider,
getDataTableDetailsPanelId,
useDataTableDetailsPanel,
};
export type { DetailsPanelProps };
@@ -1,146 +0,0 @@
import React, { useCallback } from "react";
import { createStrictContext } from "../../../utils/helpers";
import { useControllableState } from "../../../utils/hooks";
import { useTableItemsContext } from "./useTableItems";
type DataTableExpansionContextT = {
isExpanded: (id: string | number) => boolean;
isDetailsPanelExpandable: (id: string | number) => boolean;
toggleExpansion: (id: string | number) => void;
toggleAll: () => void;
isAllExpanded: boolean;
getDetailsPanelContent?: (row: unknown) => React.ReactNode;
getDetailsPanelHeight?: (row: unknown) => number | "auto";
showExpandAll: boolean;
enableDetailsPanel: boolean;
};
const {
Provider: DataTableExpansionContextProvider,
useContext: useDataTableExpansion,
} = createStrictContext<DataTableExpansionContextT>({
name: "DataTableExpansionContext",
errorMessage:
"useDataTableExpansion must be used within a DataTableExpansionProvider.",
});
type TableExpansionOptions<T> = {
detailsPanelRowIds?: (string | number)[];
defaultDetailsPanelRowIds?: (string | number)[];
onDetailsPanelChange?: (ids: (string | number)[]) => void;
getDetailsPanelContent?: (row: T) => React.ReactNode;
isDetailsPanelExpandable?: (rowData: T) => boolean;
getDetailsPanelHeight?: (row: T) => number | "auto";
showExpandAll?: boolean;
};
function getDataTableExpansionId(tableId: string, rowId: string | number) {
return `${tableId}-expansion-${rowId}`;
}
function DataTableExpansionProvider<T>({
children,
detailsPanelRowIds,
defaultDetailsPanelRowIds = [],
onDetailsPanelChange,
getDetailsPanelContent,
isDetailsPanelExpandable,
getDetailsPanelHeight,
showExpandAll = false,
}: TableExpansionOptions<T> & { children: React.ReactNode }) {
const [expandedIds, setExpandedIds] = useControllableState({
value: detailsPanelRowIds,
defaultValue: defaultDetailsPanelRowIds,
onChange: onDetailsPanelChange,
});
/* TODO: False is just fallback until auto and root is merged */
const tableItemsContext = useTableItemsContext(false);
const { itemDetails } = tableItemsContext ?? {
itemDetails: new Map(),
};
const expandableIds = React.useMemo(() => {
if (!getDetailsPanelContent) {
return new Set<string | number>();
}
const ids = new Set<string | number>();
for (const [rowData, { id, level }] of itemDetails.entries()) {
/* We only allow Master - Details pattern on top level rows */
if (level > 0) {
continue;
}
if (!isDetailsPanelExpandable || isDetailsPanelExpandable(rowData)) {
ids.add(id);
}
}
return ids;
}, [getDetailsPanelContent, isDetailsPanelExpandable, itemDetails]);
const isDetailsPanelExpandableById = useCallback(
(id: string | number) => expandableIds.has(id),
[expandableIds],
);
const isExpanded = useCallback(
(id: string | number) =>
isDetailsPanelExpandableById(id) && expandedIds.includes(id),
[expandedIds, isDetailsPanelExpandableById],
);
const toggleExpansion = useCallback(
(id: string | number) => {
if (!isDetailsPanelExpandableById(id)) {
return;
}
setExpandedIds((currentExpandedIds) =>
currentExpandedIds.includes(id)
? currentExpandedIds.filter((expandedId) => expandedId !== id)
: [...currentExpandedIds, id],
);
},
[isDetailsPanelExpandableById, setExpandedIds],
);
const isAllExpanded =
expandableIds.size > 0 &&
Array.from(expandableIds).every((key) => expandedIds.includes(key));
const toggleAll = useCallback(() => {
setExpandedIds(isAllExpanded ? [] : Array.from(expandableIds));
}, [expandableIds, isAllExpanded, setExpandedIds]);
return (
<DataTableExpansionContextProvider
isExpanded={isExpanded}
isDetailsPanelExpandable={isDetailsPanelExpandableById}
toggleExpansion={toggleExpansion}
toggleAll={toggleAll}
isAllExpanded={isAllExpanded}
getDetailsPanelContent={
getDetailsPanelContent as
| ((row: unknown) => React.ReactNode)
| undefined
}
getDetailsPanelHeight={
getDetailsPanelHeight as ((row: unknown) => number | "auto") | undefined
}
showExpandAll={showExpandAll}
enableDetailsPanel={!!getDetailsPanelContent}
>
{children}
</DataTableExpansionContextProvider>
);
}
export {
DataTableExpansionProvider,
getDataTableExpansionId,
useDataTableExpansion,
};
@@ -1,62 +1,68 @@
import { useCallback, useMemo } from "react";
import { createStrictContext } from "../../../utils/helpers";
import { useControllableState } from "../../../utils/hooks";
import {
type ItemDetail,
type TableRowEntryId,
collectTableRowEntries,
} from "../helpers/collectTableRowEntries";
type SubRowsProps<T> = {
/**
* Function to get sub-rows for a given row.
*/
getRows: (rowData: T) => T[];
/**
* Controlled list of IDs of rows that should be expanded.
*/
expandedRowIds?: string[];
/**
* IDs of rows that should be initially expanded.
* Only used when `expandedRowIds` is not provided, i.e. when the expanded state is uncontrolled.
*/
defaultExpandedRowIds?: string[];
/**
* Called when the list of expanded row IDs changes.
*/
onExpandedRowIdsChange?: (ids: string[]) => void;
/**
* Determines whether a row should be expandable.
* By default, all rows are expandable when `getRows` is provided.
*/
isRowExpandable?: (rowData: T) => boolean;
};
type UseTableItemsArgs<T> = {
items: T[];
getRowId?: (rowData: T, index: number) => string | number;
/**
* Master - Detail pattern props
*/
/* expandedDetailsPanelIds?: (string | number)[];
defaultExpandedDetailsPanelIds?: (string | number)[];
isDetailsPanelExpandable?: (rowData: T) => boolean;
onDetailsPanelChange?: (ids: (string | number)[]) => void;
getDetailsPanelHeight?: (row: T) => number | "auto";
getDetailsPanelContent?: (row: T) => React.ReactNode; */
/**
* Expanded/Nested rows pattern props
*/
getSubRows?: (rowData: T) => T[];
expandedSubRowIds?: (string | number)[];
defaultExpandedSubRowIds?: (string | number)[];
isSubRowExpandable?: (rowData: T) => boolean;
onExpandedSubRowIdsChange?: (ids: (string | number)[]) => void;
getRowId?: (rowData: T) => string;
subRows?: SubRowsProps<T>;
};
type useTableItemsReturn<T> = {
type UseTableItemsReturn<T> = {
items: T[];
itemDetails: Map<T, ItemDetail<T>>;
itemDetails: Map<string, ItemDetail<T>>;
/** Row ids for the rows currently rendered in the table body. */
visibleRowIds: TableRowEntryId[];
visibleRowIds: string[];
/** Direct child ids for each row, used to traverse selection groups lazily. */
childRowIdsById: Map<TableRowEntryId, TableRowEntryId[]>;
onExpandedSubRowIdsChange: (id: string | number) => void;
isSubRowExpanded: (id: string | number) => boolean;
childRowIdsById: Map<string, string[]>;
onExpandedRowIdsChange: (id: string) => void;
isSubRowExpanded: (id: string) => boolean;
};
function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
function useTableItems<T>(args: UseTableItemsArgs<T>): UseTableItemsReturn<T> {
const { items, subRows, getRowId } = args;
const {
items,
expandedSubRowIds,
defaultExpandedSubRowIds,
getSubRows,
getRowId,
onExpandedSubRowIdsChange,
isSubRowExpandable,
} = args;
expandedRowIds,
defaultExpandedRowIds,
getRows,
onExpandedRowIdsChange,
isRowExpandable,
} = subRows || {};
const [nestedSubRowsExpandedIds, setNestedSubRowsExpandedIds] =
useControllableState({
value: expandedSubRowIds,
defaultValue: defaultExpandedSubRowIds ?? [],
onChange: onExpandedSubRowIdsChange,
value: expandedRowIds,
defaultValue: defaultExpandedRowIds ?? [],
onChange: onExpandedRowIdsChange,
});
const expandedIdsSet = useMemo(
@@ -66,32 +72,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 localVisibleRowIds: string[] = [];
const addVisibleRows = (rowData: T): TableRowEntryId[] => {
const details = rowEntriesMap.get(rowData);
const addVisibleRows = (rowId: string): string[] => {
const details = rowEntriesMap.get(rowId);
if (!details) {
return [];
}
localVisibleItems.push(rowData);
localVisibleItems.push(details.rowData);
localVisibleRowIds.push(details.id);
const visibleDescendantRowIds: TableRowEntryId[] = [];
const visibleDescendantRowIds: string[] = [];
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 +108,8 @@ function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
return [details.id, ...visibleDescendantRowIds];
};
for (const rowData of items) {
addVisibleRows(rowData);
for (const rowId of rootRowIds) {
addVisibleRows(rowId);
}
return {
@@ -109,10 +118,10 @@ function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
childRowIdsById: _childRowIdsById,
itemDetails: rowEntriesMap,
};
}, [getSubRows, items, getRowId, isSubRowExpandable, expandedIdsSet]);
}, [getRows, items, getRowId, isRowExpandable, expandedIdsSet]);
const handleExpandedSubRowIdChange = useCallback(
(id: string | number) => {
(id: string) => {
setNestedSubRowsExpandedIds((prev) =>
prev.includes(id)
? prev.filter((expandedId) => expandedId !== id)
@@ -122,25 +131,30 @@ function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
[setNestedSubRowsExpandedIds],
);
return {
items: visibleItems,
itemDetails,
visibleRowIds,
childRowIdsById,
onExpandedSubRowIdsChange: handleExpandedSubRowIdChange,
isSubRowExpanded: (id: string | number) => expandedIdsSet.has(id),
};
const isSubRowExpanded = useCallback(
(id: string) => expandedIdsSet.has(id),
[expandedIdsSet],
);
return useMemo(
() => ({
items: visibleItems,
itemDetails,
visibleRowIds,
childRowIdsById,
onExpandedRowIdsChange: handleExpandedSubRowIdChange,
isSubRowExpanded,
}),
[
visibleItems,
itemDetails,
visibleRowIds,
childRowIdsById,
handleExpandedSubRowIdChange,
isSubRowExpanded,
],
);
}
const { Provider: TableItemsProvider, useContext: useTableItemsContext } =
/* TODO: Can we type this better? */
createStrictContext<
Omit<useTableItemsReturn<any>, "visibleRowIds" | "childRowIdsById">
>({
name: "TableItemsContext",
errorMessage:
"useTableItemsContext must be used within a TableItemsProvider",
});
export { useTableItems, TableItemsProvider, useTableItemsContext };
export type { ItemDetail };
export { useTableItems };
export type { ItemDetail, SubRowsProps, UseTableItemsReturn };
Loaded 100 of 262 files, more files were not shown because too many files have changed in this diff. Show more