mirror of
https://github.com/navikt/aksel.git
synced 2026-10-15 18:22:02 +00:00
Compare commits
90
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed6c7e999d | ||
|
|
cf9992ca3e | ||
|
|
4db5cbfe54 | ||
|
|
54a077fbb7 | ||
|
|
a6e2a2f4d4 | ||
|
|
7e31ee6822 | ||
|
|
35295e7c01 | ||
|
|
0522dfae59 | ||
|
|
53d40657bb | ||
|
|
334770de25 | ||
|
|
faec264163 | ||
|
|
fbfdeba70b | ||
|
|
76c3143d68 | ||
|
|
bec61c3743 | ||
|
|
3fbba87f06 | ||
|
|
62197a107b | ||
|
|
4f55967cf8 | ||
|
|
335b7b7af2 | ||
|
|
1073f5d502 | ||
|
|
ffab045fcb | ||
|
|
8bb8083be3 | ||
|
|
ba7d2be630 | ||
|
|
4d3839e9b5 | ||
|
|
3ff4d17fa0 | ||
|
|
53497224fa | ||
|
|
1284fefa7b | ||
|
|
2f264b7cb7 | ||
|
|
4359fb3aba | ||
|
|
632fcff376 | ||
|
|
08b1ef6d72 | ||
|
|
f7c9bde6d2 | ||
|
|
66d7a204cd | ||
|
|
6d56ec8f51 | ||
|
|
2ecf0ee03c | ||
|
|
ee8a3be487 | ||
|
|
4a4100d0b2 | ||
|
|
1da8a06783 | ||
|
|
88f96906c7 | ||
|
|
a8ae8d2c54 | ||
|
|
045dbda89a | ||
|
|
193b23151e | ||
|
|
b54380304d | ||
|
|
5706c959b2 | ||
|
|
9442ac558b | ||
|
|
e8080c269b | ||
|
|
c35a9cd6a2 | ||
|
|
0d0beae02d | ||
|
|
2eacd71805 | ||
|
|
be22673419 | ||
|
|
b2f7dd4e52 | ||
|
|
3284cee8a9 | ||
|
|
34fbdc9cd3 | ||
|
|
2f1aa0988c | ||
|
|
7bfacc698d | ||
|
|
d02df8313c | ||
|
|
5c94d6ec7f | ||
|
|
3c9d24ba87 | ||
|
|
5e426e9d64 | ||
|
|
7a85f0d27f | ||
|
|
8f75f4426a | ||
|
|
71c16eb6d8 | ||
|
|
776aa84fa8 | ||
|
|
f985e1e83f | ||
|
|
48c7794cac | ||
|
|
10b1892f54 | ||
|
|
193200ec9c | ||
|
|
01fc444219 | ||
|
|
cb5b73c1d5 | ||
|
|
077eae7365 | ||
|
|
aa3f58c00d | ||
|
|
4368a14896 | ||
|
|
45b0939c52 | ||
|
|
d2fb1d8bc1 | ||
|
|
363ee8e483 | ||
|
|
ff454dbc06 | ||
|
|
92f6d9e93b | ||
|
|
516634b852 | ||
|
|
48939bcee0 | ||
|
|
5ea6069010 | ||
|
|
5eee3136a0 | ||
|
|
21521b892e | ||
|
|
f563c18a59 | ||
|
|
ca1bd3f5c0 | ||
|
|
a71f326426 | ||
|
|
fb79fd775c | ||
|
|
8a0afad10c | ||
|
|
92e6128cc9 | ||
|
|
ff0221736c | ||
|
|
abe4a768e1 | ||
|
|
c94d801b45 |
No files matched your search
@@ -62,7 +62,7 @@ jobs:
|
||||
name: Playwright shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-noble
|
||||
services:
|
||||
testapp:
|
||||
image: ${{ needs.prebuild-app-for-playwright.outputs.image }}
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-noble
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @navikt/aksel-icons
|
||||
|
||||
## 8.10.5
|
||||
|
||||
## 8.10.4
|
||||
|
||||
## 8.10.3
|
||||
|
||||
## 8.10.2
|
||||
|
||||
## 8.10.1
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"dependencies": {
|
||||
"@navikt/ds-css": "*",
|
||||
"@navikt/ds-react": "*",
|
||||
"fuse.js": "^7.0.0",
|
||||
"fuse.js": "^7.3.0",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5"
|
||||
},
|
||||
@@ -23,8 +23,8 @@
|
||||
"@types/node": "^24.10.9",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"typescript": "6.0.2",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^7.3.2",
|
||||
"vite-plugin-singlefile": "^2.3.0",
|
||||
"vite-plugin-svgr": "^4.2.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel-icons",
|
||||
"version": "8.10.2",
|
||||
"version": "8.10.5",
|
||||
"author": "Aksel | Nav",
|
||||
"homepage": "https://aksel.nav.no/ikoner",
|
||||
"repository": {
|
||||
@@ -88,8 +88,8 @@
|
||||
"rehype-parse": "9.0.1",
|
||||
"tsc-alias": "1.8.16",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "6.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"unified": "11.0.5",
|
||||
"vitest": "4.1.4"
|
||||
"vitest": "4.1.5"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
# @navikt/aksel-stylelint
|
||||
|
||||
## 8.10.5
|
||||
|
||||
## 8.10.4
|
||||
|
||||
## 8.10.3
|
||||
|
||||
## 8.10.2
|
||||
|
||||
## 8.10.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel-stylelint",
|
||||
"version": "8.10.2",
|
||||
"version": "8.10.5",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
@@ -42,14 +42,14 @@
|
||||
"dev": "yarn watch:lint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@navikt/ds-css": "^8.10.2",
|
||||
"@navikt/ds-tokens": "^8.10.2",
|
||||
"@navikt/ds-css": "^8.10.5",
|
||||
"@navikt/ds-tokens": "^8.10.5",
|
||||
"concurrently": "9.2.1",
|
||||
"postcss-selector-parser": "^7.1.0",
|
||||
"postcss-value-parser": "^4.2.0",
|
||||
"stylelint": "^17.2.0",
|
||||
"stylelint-test-rule-node": "^1.0.0",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "6.0.2"
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,29 @@
|
||||
# @navikt/aksel
|
||||
|
||||
## 8.10.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`35295e7`](https://github.com/navikt/aksel/commit/35295e7c010bd4866c3cd4ae9ff72641a234b089), [`0522dfa`](https://github.com/navikt/aksel/commit/0522dfae59320477b10cab2941d0b004d280d79a)]:
|
||||
- @navikt/ds-css@8.10.5
|
||||
- @navikt/ds-tokens@8.10.5
|
||||
|
||||
## 8.10.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-css@8.10.4
|
||||
- @navikt/ds-tokens@8.10.4
|
||||
|
||||
## 8.10.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`92f6d9e`](https://github.com/navikt/aksel/commit/92f6d9e93b879962df8415f7fad22203174dd3a8)]:
|
||||
- @navikt/ds-css@8.10.3
|
||||
- @navikt/ds-tokens@8.10.3
|
||||
|
||||
## 8.10.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel",
|
||||
"version": "8.10.2",
|
||||
"version": "8.10.5",
|
||||
"description": "Aksel command line interface. Codemods and other utilities for Aksel users.",
|
||||
"author": "Aksel | Nav designsystem team",
|
||||
"license": "MIT",
|
||||
@@ -40,8 +40,8 @@
|
||||
"url": "https://github.com/navikt/aksel/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@navikt/ds-css": "^8.10.2",
|
||||
"@navikt/ds-tokens": "^8.10.2",
|
||||
"@navikt/ds-css": "^8.10.5",
|
||||
"@navikt/ds-tokens": "^8.10.5",
|
||||
"chalk": "5.6.2",
|
||||
"cli-progress": "3.12.0",
|
||||
"clipboardy": "5.0.1",
|
||||
@@ -59,8 +59,8 @@
|
||||
"@types/is-git-clean": "1.1.2",
|
||||
"@types/jscodeshift": "17.3.0",
|
||||
"rimraf": "6.1.3",
|
||||
"typescript": "6.0.2",
|
||||
"vitest": "4.1.4"
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.5"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"engines": {
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# @navikt/ds-css
|
||||
|
||||
## 8.10.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Pagination: Fix selected-styling in forced-colors mode ([#4858](https://github.com/navikt/aksel/pull/4858))
|
||||
|
||||
- Button: Fix issue where secondary and tertiary variants did not have correct text color is certain cases ([#4857](https://github.com/navikt/aksel/pull/4857))
|
||||
|
||||
## 8.10.4
|
||||
|
||||
## 8.10.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add module declaration to avoid TypeScript error when importing ([`92f6d9e`](https://github.com/navikt/aksel/commit/92f6d9e93b879962df8415f7fad22203174dd3a8))
|
||||
|
||||
## 8.10.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-css",
|
||||
"version": "8.10.2",
|
||||
"version": "8.10.5",
|
||||
"description": "CSS for Nav Designsystem",
|
||||
"author": "Aksel | Nav designsystem team",
|
||||
"keywords": [
|
||||
@@ -14,7 +14,9 @@
|
||||
"files": [
|
||||
"*",
|
||||
"!dist/version",
|
||||
"!config/bundle.js"
|
||||
"!config",
|
||||
"!.gitignore",
|
||||
"!CHANGELOG.md"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -30,7 +32,7 @@
|
||||
"css:get-version": "node config/get-version.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@navikt/ds-tokens": "^8.10.2",
|
||||
"@navikt/ds-tokens": "^8.10.5",
|
||||
"browserslist": "^4.25.0",
|
||||
"esbuild": "^0.27.4",
|
||||
"fast-glob": "3.3.3",
|
||||
@@ -46,9 +48,13 @@
|
||||
],
|
||||
"main": "./dist/index.css",
|
||||
"exports": {
|
||||
".": "./dist/index.css",
|
||||
"./dist/*": "./dist/*",
|
||||
"./tokens.json": "./tokens.json",
|
||||
"./config/_mappings": "./config/_mappings.js"
|
||||
".": {
|
||||
"types": "./types.d.ts",
|
||||
"default": "./dist/index.css"
|
||||
},
|
||||
"./dist/*": {
|
||||
"types": "./types.d.ts",
|
||||
"default": "./dist/*"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,14 +42,22 @@
|
||||
--__axc-button-border-color: var(--ax-border-default);
|
||||
|
||||
background-color: transparent;
|
||||
color: var(--ax-text-subtle);
|
||||
|
||||
/* secondary + neutral should use "default" text color.
|
||||
Check for data-color=neutral on both the button itself and the parents. */
|
||||
&[data-color="neutral"],
|
||||
[data-color="neutral"] > &:not([data-color]),
|
||||
[data-color="neutral"] :not([data-color]) &:not([data-color]) {
|
||||
[data-color="neutral"] &:not([data-color]) {
|
||||
color: var(--ax-text-default);
|
||||
}
|
||||
|
||||
/* secondary + NOT neutral should use "subtle" text color.
|
||||
We need to explicitly target cases where a parent has data-color=neutral but
|
||||
a closer parent has a different data-color (overriding the selector above). */
|
||||
&,
|
||||
[data-color="neutral"] [data-color]:not([data-color="neutral"]) & {
|
||||
color: var(--ax-text-subtle);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--__axc-button-border-color: var(--ax-border-strong);
|
||||
|
||||
@@ -73,14 +81,22 @@
|
||||
|
||||
&[data-variant="tertiary"] {
|
||||
background-color: transparent;
|
||||
color: var(--ax-text-subtle);
|
||||
|
||||
/* tertiary + neutral should use "default" text color.
|
||||
Check for data-color=neutral on both the button itself and the parents. */
|
||||
&[data-color="neutral"],
|
||||
[data-color="neutral"] > &:not([data-color], [data-pressed="true"]),
|
||||
[data-color="neutral"] :not([data-color]) &:not([data-color], [data-pressed="true"]) {
|
||||
[data-color="neutral"] &:not([data-color], [data-pressed="true"]) {
|
||||
color: var(--ax-text-default);
|
||||
}
|
||||
|
||||
/* tertiary + NOT neutral should use "subtle" text color.
|
||||
We need to explicitly target cases where a parent has data-color=neutral but
|
||||
a closer parent has a different data-color (overriding the selector above). */
|
||||
&,
|
||||
[data-color="neutral"] [data-color]:not([data-color="neutral"]) &:not([data-pressed="true"]) {
|
||||
color: var(--ax-text-subtle);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--ax-bg-moderate-hoverA);
|
||||
}
|
||||
@@ -231,6 +247,15 @@
|
||||
color: highlight;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&[data-variant="tertiary"][data-pressed="true"] {
|
||||
background-color: selecteditem;
|
||||
color: selecteditemtext;
|
||||
|
||||
& span {
|
||||
forced-color-adjust: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.aksel-button:not(:disabled):hover span {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
scroll-timeline: --horizontal-table-scroll inline;
|
||||
}
|
||||
|
||||
.aksel-data-table {
|
||||
@@ -104,15 +105,17 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
box-shadow: 1px 1px 0 0 var(--ax-border-neutral-subtle);
|
||||
|
||||
&::after {
|
||||
/* This breaks filler-cells ⬇️. Trying box-shadow instead ⬆️ */
|
||||
/* &::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
bottom: -1px;
|
||||
pointer-events: none;
|
||||
border-bottom: 1px solid var(--ax-border-neutral-subtle);
|
||||
}
|
||||
} */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +149,27 @@
|
||||
box-shadow: 0 1px 0 0 var(--ax-border-neutral-subtle);
|
||||
}
|
||||
|
||||
@keyframes scroll-shadow-fade-start {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
10%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scroll-shadow-fade-end {
|
||||
90% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.aksel-data-table__cell {
|
||||
/* border doesn't work on sticky thead when we have collapsed borders, so using box-shadow instead */
|
||||
box-shadow: inset 1px 1px 0 0 var(--ax-border-neutral-subtle);
|
||||
@@ -195,6 +219,24 @@
|
||||
&[data-sticky="start"] {
|
||||
left: 0;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
box-shadow: 4px 0 8px 1px var(--ax-border-neutral-subtleA);
|
||||
clip-path: inset(0 -24px 0 0);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.aksel-data-table[data-scroll] &::before {
|
||||
opacity: 1;
|
||||
animation: scroll-shadow-fade-start 1s linear;
|
||||
animation-timeline: --horizontal-table-scroll;
|
||||
}
|
||||
|
||||
&::after {
|
||||
inset-inline: 0 -1px;
|
||||
border-right-width: 1px;
|
||||
@@ -203,10 +245,32 @@
|
||||
|
||||
&[data-sticky="end"] {
|
||||
right: 0;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
box-shadow: -4px 0 8px 1px var(--ax-border-neutral-subtleA);
|
||||
clip-path: inset(0 0 0 -24px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.aksel-data-table[data-scroll] &::before {
|
||||
opacity: 1;
|
||||
animation: scroll-shadow-fade-end 1s linear;
|
||||
animation-timeline: --horizontal-table-scroll;
|
||||
}
|
||||
}
|
||||
|
||||
background-color: var(--ax-bg-raised);
|
||||
|
||||
.aksel-data-table__tr[data-selected="true"] & {
|
||||
background-color: var(--ax-bg-soft);
|
||||
}
|
||||
|
||||
.aksel-data-table__thead & {
|
||||
background-color: var(--ax-bg-neutral-soft);
|
||||
}
|
||||
@@ -234,11 +298,11 @@
|
||||
&:not([data-selectable="true"]) > div {
|
||||
padding-block: var(--__axc-data-table-density);
|
||||
padding-inline: var(--__axc-data-table-pi-cell);
|
||||
overflow: hidden;
|
||||
|
||||
.aksel-data-table[data-truncate-content="true"] & {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -444,9 +508,14 @@
|
||||
}
|
||||
|
||||
.aksel-data-table__filler-cell {
|
||||
width: auto;
|
||||
display: table-cell;
|
||||
padding: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.aksel-data-table__drag-and-drop-root {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.aksel-data-drag-and-drop__drag-handler__button {
|
||||
@@ -469,12 +538,11 @@
|
||||
}
|
||||
|
||||
.aksel-data-table__drag-and-drop-item {
|
||||
background-color: var(--ax-bg-default);
|
||||
|
||||
&[data-overlay="true"] {
|
||||
width: 100%;
|
||||
opacity: 0.8;
|
||||
box-shadow: var(--ax-shadow-dialog);
|
||||
background-color: var(--ax-bg-default);
|
||||
}
|
||||
|
||||
/* TODO - might be removable */
|
||||
@@ -491,7 +559,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.aksel-data-drag-and-drop__drag-handler__alt,
|
||||
.aksel-data-drag-and-drop__drag-handler__legacy,
|
||||
.aksel-data-drag-and-drop__drag-handler {
|
||||
position: relative;
|
||||
height: 1.5rem;
|
||||
@@ -593,3 +661,14 @@ html[data-dragging-cursor="true"] *::after {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.aksel-data-table__details-panel-row {
|
||||
box-shadow: inset 1px 1px 0 0 var(--ax-border-neutral-subtle);
|
||||
}
|
||||
|
||||
.aksel-data-table__details-panel-row-cell {
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--ax-border-focus);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module "@navikt/ds-css" {}
|
||||
@@ -1,5 +1,35 @@
|
||||
# @navikt/ds-react
|
||||
|
||||
## 8.10.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Dependencies: Bumped `@floating-ui/react` from v0.27.8 -> v0.27.19 ([#4851](https://github.com/navikt/aksel/pull/4851))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@8.10.5
|
||||
- @navikt/aksel-icons@8.10.5
|
||||
|
||||
## 8.10.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ActionMenu: Now locked to bottom/top side placement, left/right for sub-menus. ([#4843](https://github.com/navikt/aksel/pull/4843))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@8.10.4
|
||||
- @navikt/aksel-icons@8.10.4
|
||||
|
||||
## 8.10.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Radio, Checkbox: Fix regression where `className` was mistakenly passed to `input`-element ([#4837](https://github.com/navikt/aksel/pull/4837))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@8.10.3
|
||||
- @navikt/aksel-icons@8.10.3
|
||||
|
||||
## 8.10.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-react",
|
||||
"version": "8.10.2",
|
||||
"version": "8.10.5",
|
||||
"description": "React components from the Norwegian Labour and Welfare Administration.",
|
||||
"author": "Aksel, a team part of the Norwegian Labour and Welfare Administration.",
|
||||
"license": "MIT",
|
||||
@@ -698,16 +698,16 @@
|
||||
"clean": "rimraf cjs esm",
|
||||
"build": "yarn i18n-jsdoc && concurrently \"tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json\" \"tsc -p tsconfig.esm.json && tsc-alias -p tsconfig.esm.json && yarn write-packagejson\" && yarn i18n-jsdoc --cleanup && yarn copy-types",
|
||||
"watch": "tsc --watch -p tsconfig.esm.json",
|
||||
"test": "concurrently \"TZ=UTC vitest run -c tests/vitest.config.ts\" \"yarn test-sb-build\"",
|
||||
"test": "concurrently \"cross-env TZ=UTC vitest run -c tests/vitest.config.ts\" \"yarn test-sb-build\"",
|
||||
"test-sb-build": "tsc -p tsconfig.sb.json",
|
||||
"test:watch": "TZ=UTC vitest watch -c tests/vitest.config.ts",
|
||||
"copy-types": "copyfiles -f ./src/types/theme.d.ts ./esm/types"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "0.27.8",
|
||||
"@floating-ui/react": "0.27.19",
|
||||
"@floating-ui/react-dom": "^2.1.8",
|
||||
"@navikt/aksel-icons": "^8.10.2",
|
||||
"@navikt/ds-tokens": "^8.10.2",
|
||||
"@navikt/aksel-icons": "^8.10.5",
|
||||
"@navikt/ds-tokens": "^8.10.5",
|
||||
"date-fns": "^4.0.0",
|
||||
"react-day-picker": "9.14.0"
|
||||
},
|
||||
@@ -723,6 +723,7 @@
|
||||
"aksel": "workspace:^",
|
||||
"concurrently": "9.2.1",
|
||||
"copyfiles": "^2.4.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"fast-glob": "3.3.3",
|
||||
"jscodeshift": "17.3.0",
|
||||
"jsdom": "27.1.0",
|
||||
@@ -733,8 +734,8 @@
|
||||
"swr": "^2.4.1",
|
||||
"tsc-alias": "1.8.16",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "6.0.2",
|
||||
"vitest": "4.1.4"
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^17.0.30 || ^18 || ^19",
|
||||
|
||||
@@ -384,7 +384,7 @@ export const ActionMenuContent = forwardRef<
|
||||
{...rest}
|
||||
align={align}
|
||||
sideOffset={4}
|
||||
collisionPadding={10}
|
||||
collisionPadding={5}
|
||||
returnFocus={context.triggerRef}
|
||||
safeZone={{
|
||||
anchor: context.triggerRef.current,
|
||||
|
||||
@@ -184,126 +184,66 @@ export const ColorRole = () => (
|
||||
</HStack>
|
||||
<h3>data-color=neutral on wrapper and danger on button</h3>
|
||||
<HStack gap="space-8" data-color="neutral">
|
||||
<Button data-color="danger" variant="secondary" icon={<StarIcon />}>
|
||||
<Button data-color="danger" variant="secondary">
|
||||
Button
|
||||
</Button>
|
||||
<Button data-color="danger" variant="tertiary" icon={<StarIcon />}>
|
||||
<Button data-color="danger" variant="tertiary">
|
||||
Button
|
||||
</Button>
|
||||
<Button
|
||||
data-color="danger"
|
||||
variant="secondary"
|
||||
icon={<StarIcon />}
|
||||
disabled
|
||||
>
|
||||
<Button data-color="danger" variant="tertiary" data-pressed>
|
||||
Pressed
|
||||
</Button>
|
||||
<Button data-color="danger" variant="secondary" disabled>
|
||||
Button
|
||||
</Button>
|
||||
<Button
|
||||
data-color="danger"
|
||||
variant="tertiary"
|
||||
icon={<StarIcon />}
|
||||
disabled
|
||||
>
|
||||
<Button data-color="danger" variant="tertiary" disabled>
|
||||
Button
|
||||
</Button>
|
||||
</HStack>
|
||||
<h3>data-color=neutral on parent wrapper and danger on button</h3>
|
||||
<div data-color="neutral">
|
||||
<HStack gap="space-8">
|
||||
<Button data-color="danger" variant="secondary" icon={<StarIcon />}>
|
||||
Button
|
||||
</Button>
|
||||
<Button data-color="danger" variant="tertiary" icon={<StarIcon />}>
|
||||
Button
|
||||
</Button>
|
||||
<Button
|
||||
data-color="danger"
|
||||
variant="secondary"
|
||||
icon={<StarIcon />}
|
||||
disabled
|
||||
>
|
||||
Button
|
||||
</Button>
|
||||
<Button
|
||||
data-color="danger"
|
||||
variant="tertiary"
|
||||
icon={<StarIcon />}
|
||||
disabled
|
||||
>
|
||||
Button
|
||||
</Button>
|
||||
</HStack>
|
||||
</div>
|
||||
<h3>data-color=neutral on button</h3>
|
||||
<HStack gap="space-8">
|
||||
<Button data-color="neutral" variant="secondary" icon={<StarIcon />}>
|
||||
<Button data-color="neutral" variant="secondary">
|
||||
Button
|
||||
</Button>
|
||||
<Button data-color="neutral" variant="tertiary" icon={<StarIcon />}>
|
||||
<Button data-color="neutral" variant="tertiary">
|
||||
Button
|
||||
</Button>
|
||||
<Button
|
||||
data-color="neutral"
|
||||
variant="secondary"
|
||||
icon={<StarIcon />}
|
||||
disabled
|
||||
>
|
||||
<Button data-color="neutral" variant="tertiary" data-pressed>
|
||||
Pressed
|
||||
</Button>
|
||||
<Button data-color="neutral" variant="secondary" disabled>
|
||||
Button
|
||||
</Button>
|
||||
<Button
|
||||
data-color="neutral"
|
||||
variant="tertiary"
|
||||
icon={<StarIcon />}
|
||||
disabled
|
||||
>
|
||||
<Button data-color="neutral" variant="tertiary" disabled>
|
||||
Button
|
||||
</Button>
|
||||
</HStack>
|
||||
<h3>data-color=neutral on wrapper</h3>
|
||||
<HStack gap="space-8" data-color="neutral">
|
||||
<Button variant="secondary" icon={<StarIcon />}>
|
||||
<Button variant="secondary">Button</Button>
|
||||
<Button variant="tertiary">Button</Button>
|
||||
<Button variant="tertiary" data-pressed>
|
||||
Pressed
|
||||
</Button>
|
||||
<Button variant="secondary" disabled>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="tertiary" icon={<StarIcon />}>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="secondary" icon={<StarIcon />} disabled>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="tertiary" icon={<StarIcon />} disabled>
|
||||
<Button variant="tertiary" disabled>
|
||||
Button
|
||||
</Button>
|
||||
</HStack>
|
||||
<h3>data-color=neutral on parent wrapper</h3>
|
||||
<div data-color="neutral">
|
||||
<HStack gap="space-8">
|
||||
<Button variant="secondary" icon={<StarIcon />}>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="tertiary" icon={<StarIcon />}>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="secondary" icon={<StarIcon />} disabled>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="tertiary" icon={<StarIcon />} disabled>
|
||||
Button
|
||||
</Button>
|
||||
</HStack>
|
||||
</div>
|
||||
<h3>data-color=neutral on parent wrapper and danger on wrapper</h3>
|
||||
<div data-color="neutral">
|
||||
<HStack gap="space-8" data-color="danger">
|
||||
<Button variant="secondary" icon={<StarIcon />}>
|
||||
<Button variant="secondary">Button</Button>
|
||||
<Button variant="tertiary">Button</Button>
|
||||
<Button variant="tertiary" data-pressed>
|
||||
Pressed
|
||||
</Button>
|
||||
<Button variant="secondary" disabled>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="tertiary" icon={<StarIcon />}>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="secondary" icon={<StarIcon />} disabled>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="tertiary" icon={<StarIcon />} disabled>
|
||||
<Button variant="tertiary" disabled>
|
||||
Button
|
||||
</Button>
|
||||
</HStack>
|
||||
@@ -311,16 +251,15 @@ export const ColorRole = () => (
|
||||
<h3>data-color=neutral on both wrappers</h3>
|
||||
<div data-color="neutral">
|
||||
<HStack gap="space-8" data-color="neutral">
|
||||
<Button variant="secondary" icon={<StarIcon />}>
|
||||
<Button variant="secondary">Button</Button>
|
||||
<Button variant="tertiary">Button</Button>
|
||||
<Button variant="tertiary" data-pressed>
|
||||
Pressed
|
||||
</Button>
|
||||
<Button variant="secondary" disabled>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="tertiary" icon={<StarIcon />}>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="secondary" icon={<StarIcon />} disabled>
|
||||
Button
|
||||
</Button>
|
||||
<Button variant="tertiary" icon={<StarIcon />} disabled>
|
||||
<Button variant="tertiary" disabled>
|
||||
Button
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
+5
-5
@@ -6,7 +6,7 @@ import {
|
||||
} from "@navikt/aksel-icons";
|
||||
import { Floating } from "../../../utils/components/floating/Floating";
|
||||
|
||||
export interface DataDragAndDropDragHandlerProps {
|
||||
export interface DragAndDropDragHandlerLegacyProps {
|
||||
/**
|
||||
* Whether the drag handler is disabled
|
||||
*/
|
||||
@@ -24,18 +24,18 @@ export interface DataDragAndDropDragHandlerProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* DataDragAndDropDragHandler
|
||||
* DragAndDropDragHandlerLegacy
|
||||
*
|
||||
* A button component that serves as a drag handle for drag and drop operations.
|
||||
* Can be used to initiate dragging of elements in a data table or list.
|
||||
*/
|
||||
export const DataDragAndDropDragHandler = React.forwardRef<
|
||||
export const DragAndDropDragHandlerLegacy = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
DataDragAndDropDragHandlerProps
|
||||
DragAndDropDragHandlerLegacyProps
|
||||
>(({ keyboardDragging, handleRef, alt }) => {
|
||||
if (alt) {
|
||||
return (
|
||||
<div className="aksel-data-drag-and-drop__drag-handler__alt">
|
||||
<div className="aksel-data-drag-and-drop__drag-handler__legacy">
|
||||
{keyboardDragging && (
|
||||
<span
|
||||
className="aksel-data-drag-and-drop__drag-handler__arrow"
|
||||
+13
-13
@@ -3,10 +3,10 @@ import React, { useRef } from "react";
|
||||
import { HStack } from "../../../primitives/stack";
|
||||
import { cl } from "../../../utils/helpers";
|
||||
import { useMergeRefs } from "../../../utils/hooks";
|
||||
import { DataDragAndDropDragHandler } from "../drag-handler/DataDragAndDropDragHandler";
|
||||
import { DataDragAndDropContext } from "../root/DataDragAndDrop.context";
|
||||
import { DragAndDropDragHandlerLegacy } from "../drag-handler/DragAndDropDragHandlerLegacy";
|
||||
import { DragAndDropLegacyContext } from "../root/DragAndDropLegacy.context";
|
||||
|
||||
interface DataDragAndDropItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
interface DragAndDropItemLegacyProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Unique id
|
||||
@@ -21,17 +21,17 @@ interface DataDragAndDropItemProps extends React.HTMLAttributes<HTMLDivElement>
|
||||
/**
|
||||
* TODO
|
||||
*
|
||||
* @see 🏷️ {@link DataDragAndDropItemProps}
|
||||
* @see 🏷️ {@link DragAndDropItemLegacyProps}
|
||||
* @example
|
||||
* ```tsx
|
||||
* <DragAndDrop.Item numOfSelectedRows={selectedRows.length} onClear={handleClear}>
|
||||
* <DragAndDropLegacy.Item numOfSelectedRows={selectedRows.length} onClear={handleClear}>
|
||||
* TODO
|
||||
* </DragAndDrop.Item>
|
||||
* </DragAndDropLegacy.Item>
|
||||
* ```
|
||||
*/
|
||||
const DataDragAndDropItem = React.forwardRef<
|
||||
const DragAndDropItemLegacy = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
DataDragAndDropItemProps
|
||||
DragAndDropItemLegacyProps
|
||||
>(({ children, id, index, className, ...rest }, forwardedRef) => {
|
||||
const handleRef = useRef<HTMLDivElement>(null);
|
||||
const { ref, isDragging, isDropTarget } = useSortable({
|
||||
@@ -40,7 +40,7 @@ const DataDragAndDropItem = React.forwardRef<
|
||||
handle: handleRef,
|
||||
});
|
||||
const mergedRef = useMergeRefs(ref, forwardedRef);
|
||||
const context = React.useContext(DataDragAndDropContext);
|
||||
const context = React.useContext(DragAndDropLegacyContext);
|
||||
const mouseDragging = isDragging && context?.inputMethod === "mouse";
|
||||
const mouseDropTarget = isDropTarget && context?.inputMethod === "mouse";
|
||||
const keyboardDragging = isDragging && context?.inputMethod === "keyboard";
|
||||
@@ -58,7 +58,7 @@ const DataDragAndDropItem = React.forwardRef<
|
||||
data-drop-target={mouseDropTarget}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<DataDragAndDropDragHandler
|
||||
<DragAndDropDragHandlerLegacy
|
||||
handleRef={handleRef}
|
||||
keyboardDragging={keyboardDragging}
|
||||
alt
|
||||
@@ -69,6 +69,6 @@ const DataDragAndDropItem = React.forwardRef<
|
||||
);
|
||||
});
|
||||
|
||||
export default DataDragAndDropItem;
|
||||
export { DataDragAndDropItem };
|
||||
export type { DataDragAndDropItemProps };
|
||||
export default DragAndDropItemLegacy;
|
||||
export { DragAndDropItemLegacy };
|
||||
export type { DragAndDropItemLegacyProps };
|
||||
+3
-3
@@ -1,11 +1,11 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
interface DataDragAndDropContextType {
|
||||
interface DragAndDropContextLegacyType {
|
||||
inputMethod: "mouse" | "keyboard" | null;
|
||||
// setInputMethod: (method: "mouse" | "keyboard" | null) => void;
|
||||
// setItems: React.Dispatch<React.SetStateAction<any[]>>;
|
||||
}
|
||||
|
||||
export const DataDragAndDropContext = createContext<
|
||||
DataDragAndDropContextType | undefined
|
||||
export const DragAndDropLegacyContext = createContext<
|
||||
DragAndDropContextLegacyType | undefined
|
||||
>(undefined);
|
||||
+19
-21
@@ -2,34 +2,32 @@ import { DragDropProvider, DragOverlay } from "@dnd-kit/react";
|
||||
import { isSortable } from "@dnd-kit/react/sortable";
|
||||
import React, { forwardRef, isValidElement } from "react";
|
||||
import { VStack } from "../../../primitives/stack";
|
||||
import DataDragAndDropItem, {
|
||||
DataDragAndDropItemProps,
|
||||
} from "../item/DataDragAndDropItem";
|
||||
import { DataDragAndDropContext } from "./DataDragAndDrop.context";
|
||||
import DragAndDropItemLegacy from "../item/DragAndDropItemLegacy";
|
||||
import { DragAndDropLegacyContext } from "./DragAndDropLegacy.context";
|
||||
|
||||
interface DataDragAndDropProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
interface DragAndDropLegacyProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: any[];
|
||||
setItems: React.Dispatch<React.SetStateAction<any[]>>;
|
||||
}
|
||||
|
||||
interface DataDragAndDropRootComponent extends React.ForwardRefExoticComponent<
|
||||
DataDragAndDropProps & React.RefAttributes<HTMLDivElement>
|
||||
interface DragAndDropLegacyRootComponent extends React.ForwardRefExoticComponent<
|
||||
DragAndDropLegacyProps & React.RefAttributes<HTMLDivElement>
|
||||
> {
|
||||
/**
|
||||
* @see 🏷️ {@link DataDragAndDropItemProps}
|
||||
* @see 🏷️ {@link DragAndDropItemLegacyProps}
|
||||
* * @example
|
||||
* ```jsx
|
||||
* <DragAndDrop>
|
||||
* <DragAndDrop.Item id="1" index={0}>
|
||||
* <DragAndDropLegacy>
|
||||
* <DragAndDropLegacy.Item id="1" index={0}>
|
||||
* ...
|
||||
* </DragAndDrop.Item>
|
||||
* </DragAndDrop>
|
||||
* </DragAndDropLegacy.Item>
|
||||
* </DragAndDropLegacy>
|
||||
* ```
|
||||
*/
|
||||
Item: typeof DataDragAndDropItem;
|
||||
Item: typeof DragAndDropItemLegacy;
|
||||
}
|
||||
|
||||
const DataDragAndDrop = forwardRef<HTMLDivElement, DataDragAndDropProps>(
|
||||
const DragAndDropLegacy = forwardRef<HTMLDivElement, DragAndDropLegacyProps>(
|
||||
({ setItems, children, ...rest }, forwardedRef) => {
|
||||
const [inputMethod, setInputMethod] = React.useState<
|
||||
"mouse" | "keyboard" | null
|
||||
@@ -45,7 +43,7 @@ const DataDragAndDrop = forwardRef<HTMLDivElement, DataDragAndDropProps>(
|
||||
};
|
||||
|
||||
return (
|
||||
<DataDragAndDropContext.Provider value={{ inputMethod }}>
|
||||
<DragAndDropLegacyContext.Provider value={{ inputMethod }}>
|
||||
<DragDropProvider
|
||||
// TODO Look into overriding default keybinds, might eliminate context need
|
||||
onBeforeDragStart={(event) =>
|
||||
@@ -84,13 +82,13 @@ const DataDragAndDrop = forwardRef<HTMLDivElement, DataDragAndDropProps>(
|
||||
}}
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</DataDragAndDropContext.Provider>
|
||||
</DragAndDropLegacyContext.Provider>
|
||||
);
|
||||
},
|
||||
) as DataDragAndDropRootComponent;
|
||||
) as DragAndDropLegacyRootComponent;
|
||||
|
||||
DataDragAndDrop.Item = DataDragAndDropItem;
|
||||
DragAndDropLegacy.Item = DragAndDropItemLegacy;
|
||||
|
||||
export { DataDragAndDrop, DataDragAndDropItem };
|
||||
export default DataDragAndDrop;
|
||||
export type { DataDragAndDropItemProps, DataDragAndDropProps };
|
||||
export { DragAndDropLegacy, DragAndDropItemLegacy };
|
||||
export default DragAndDropLegacy;
|
||||
export type { DragAndDropLegacyProps };
|
||||
@@ -49,7 +49,7 @@ export const DragAndDropDragHandler = React.forwardRef<
|
||||
<button
|
||||
className="aksel-data-drag-and-drop__drag-handler__arrow"
|
||||
data-direction="up"
|
||||
onClick={() => context?.onKeyboardDragEnd(-1)}
|
||||
onClick={() => context?.onKeyboardDragEnd(-1, itemLabel)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
disabled={context?.dragHandlerActive?.index === 0}
|
||||
type="button"
|
||||
@@ -58,13 +58,7 @@ export const DragAndDropDragHandler = React.forwardRef<
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
// TODO - Bedre formulering av aria-label?
|
||||
//aria-label={`Flytt element ${item.index + 1}. Trykk Enter eller Mellomrom for å aktivere, deretter piltastene for å flytte elementet.`}
|
||||
aria-label={
|
||||
active
|
||||
? `Flytt element ${itemLabel || item.index + 1}. Bruk piltastene for å flytte elementet.`
|
||||
: `Flytt element ${itemLabel || item.index + 1}. Trykk Enter eller Mellomrom for å aktivere flytting.`
|
||||
}
|
||||
aria-label={`${itemLabel}. Plass ${item.index + 1}.`}
|
||||
aria-pressed={Boolean(active)}
|
||||
aria-roledescription="draggable"
|
||||
type="button"
|
||||
@@ -77,18 +71,18 @@ export const DragAndDropDragHandler = React.forwardRef<
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (!active) {
|
||||
context?.setDragHandlerActive(item);
|
||||
context?.onKeyboardDragStart(item);
|
||||
event.currentTarget.focus();
|
||||
} else {
|
||||
context?.setDragHandlerActive(null);
|
||||
context?.onKeyboardDragStart(null);
|
||||
}
|
||||
}}
|
||||
onBlur={() => context?.setDragHandlerActive(null)}
|
||||
onBlur={() => context?.cancelDrag()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
// Enter or space, currently active item - end keyboard dragging
|
||||
event.preventDefault();
|
||||
context?.setDragHandlerActive(active ? null : item);
|
||||
context?.onKeyboardDragStart(active ? null : item);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -98,17 +92,18 @@ export const DragAndDropDragHandler = React.forwardRef<
|
||||
// Cancel dragging
|
||||
// TODO Handle reset
|
||||
event.preventDefault();
|
||||
context?.setDragHandlerActive(null);
|
||||
context?.setAnnouncer(`Flytting av ${itemLabel} avbrutt.`);
|
||||
context?.cancelDrag(true);
|
||||
return;
|
||||
} else if (event.key === "ArrowUp") {
|
||||
// Move item up
|
||||
event.preventDefault();
|
||||
context?.onKeyboardDragEnd(-1);
|
||||
context?.onKeyboardDragEnd(-1, itemLabel);
|
||||
return;
|
||||
} else if (event.key === "ArrowDown") {
|
||||
// Move item down
|
||||
event.preventDefault();
|
||||
context?.onKeyboardDragEnd(1);
|
||||
context?.onKeyboardDragEnd(1, itemLabel);
|
||||
return;
|
||||
}
|
||||
}}
|
||||
@@ -120,7 +115,7 @@ export const DragAndDropDragHandler = React.forwardRef<
|
||||
className="aksel-data-drag-and-drop__drag-handler__arrow"
|
||||
data-direction="down"
|
||||
type="button"
|
||||
onClick={() => context?.onKeyboardDragEnd(1)}
|
||||
onClick={() => context?.onKeyboardDragEnd(1, itemLabel)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
disabled={
|
||||
context?.dragHandlerActive?.index === context?.itemAmount - 1
|
||||
|
||||
@@ -7,14 +7,16 @@ interface DragAndDropContextType {
|
||||
dropTarget: DragAndDropElement | null;
|
||||
setDropTarget: (id: DragAndDropElement | null) => void;
|
||||
dragHandlerActive: DragAndDropElement | null;
|
||||
setDragHandlerActive: (active: DragAndDropElement | null) => void;
|
||||
onKeyboardDragEnd: (diff: number) => void;
|
||||
onKeyboardDragStart: (active: DragAndDropElement | null) => void;
|
||||
onKeyboardDragEnd: (diff: number, label: string) => void;
|
||||
startPendingDrag: (
|
||||
event: React.PointerEvent,
|
||||
item: DragAndDropElement,
|
||||
element?: HTMLElement | null,
|
||||
) => void;
|
||||
itemAmount: number;
|
||||
cancelDrag: (resetOrder?: boolean) => void;
|
||||
setAnnouncer: (message: string) => void;
|
||||
}
|
||||
|
||||
export const {
|
||||
|
||||
@@ -15,10 +15,18 @@ import DragAndDropItem, { DragAndDropItemProps } from "../item/DragAndDropItem";
|
||||
import { DragAndDropElement } from "../types";
|
||||
import { DragAndDropProvider } from "./DragAndDrop.context";
|
||||
|
||||
interface DragAndDropProps<T> extends React.HTMLAttributes<HTMLUListElement> {
|
||||
items: ColumnDefinitions<T>;
|
||||
setItems: React.Dispatch<React.SetStateAction<ColumnDefinitions<T>>>;
|
||||
renderItem: (item: ColumnDefinition<T>, index: number) => React.ReactNode;
|
||||
interface DragAndDropProps<
|
||||
T,
|
||||
DetailsT extends Record<string, any>,
|
||||
> extends React.HTMLAttributes<HTMLUListElement> {
|
||||
items: ColumnDefinitions<T, DetailsT>;
|
||||
setItems: React.Dispatch<
|
||||
React.SetStateAction<ColumnDefinitions<T, DetailsT>>
|
||||
>;
|
||||
renderItem: (
|
||||
item: ColumnDefinition<T, DetailsT>,
|
||||
index: number,
|
||||
) => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,7 +36,7 @@ interface DragAndDropProps<T> extends React.HTMLAttributes<HTMLUListElement> {
|
||||
* [x] pointer over listener / state, onPointerEnter, onPointerLeave
|
||||
* [x] Overlay - Use floating component
|
||||
* [x] Keyboard navigation
|
||||
* [ ] UU - announce on drag start, item moved, drag end
|
||||
* [x] UU - announce on drag start, item moved, drag end
|
||||
* [x] Make overlay same width as the OG item, currently jumps to content width
|
||||
* [x] Look into adding a cancel listener event
|
||||
* [x] Make onClick work on drag handler button, currently blocked by pointer down/up listeners
|
||||
@@ -36,23 +44,28 @@ interface DragAndDropProps<T> extends React.HTMLAttributes<HTMLUListElement> {
|
||||
* [x] Make arrow icons into buttons that react to keyboard events, currently just decorative
|
||||
* [x] Keep handler focus after clicking arrows for dragging
|
||||
* [x] Look into data-based API vs component-based API
|
||||
* [ ] Should we have hidden instructions for screen readers on how to use the drag and drop, and should we announce the position of the item while dragging?
|
||||
* [x] Should we have hidden instructions for screen readers on how to use the drag and drop, and should we announce the position of the item while dragging?
|
||||
* [x] Discuss if this component should be generic for drag and drop, or if it should be specifically for tables - just for table for now
|
||||
* [x] Discuss items type
|
||||
* [ ] Discuss how to implement label best
|
||||
* [x] Discuss how to implement label best
|
||||
* [ ] Quick nav (< > samtidig) - få piltastene til å fungere
|
||||
* [x] Implement new type for items - ColumnDefinitions<T>
|
||||
* [ ] Remove announcer div and use a live region component instead
|
||||
* [ ] Make ESC reset position, not just cancel dragging
|
||||
* [ ] Make instructions for keyboard users (visible?)
|
||||
* [x] Remove announcer div and use a live region component instead
|
||||
* [x] Make ESC reset position, not just cancel dragging
|
||||
* [x] Make instructions for keyboard users (visible?)
|
||||
* [ ] Ask design about visible keyboard instructions
|
||||
* [ ] Update design from Figma
|
||||
* [ ] Look at instructions text
|
||||
* [x] Remove default ul styling
|
||||
* [x] Fix setItems type
|
||||
*
|
||||
*/
|
||||
|
||||
const DRAG_THRESHOLD = 4; // Minimum movement in pixels to start dragging
|
||||
const SR_INSTRUCTIONS_ID = "drag-and-drop-instructions-id";
|
||||
|
||||
function DragAndDropInner<T>(
|
||||
{ items, setItems, renderItem }: DragAndDropProps<T>,
|
||||
function DragAndDropInner<T, DetailsT extends Record<string, any>>(
|
||||
{ items, setItems, renderItem }: DragAndDropProps<T, DetailsT>,
|
||||
forwardedRef: React.ForwardedRef<HTMLUListElement>,
|
||||
) {
|
||||
const [activeItem, setActiveItem] = useState<DragAndDropElement | null>(null);
|
||||
@@ -60,7 +73,8 @@ function DragAndDropInner<T>(
|
||||
const [dragHandlerActive, setDragHandlerActive] =
|
||||
useState<DragAndDropElement | null>(null);
|
||||
const [overlayWidth, setOverlayWidth] = useState<number | null>(null);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const [announcer, setAnnouncer] = useState("");
|
||||
const initialItemsRef = useRef<ColumnDefinitions<T, DetailsT> | null>(null);
|
||||
const activeData = items.find((item) => item.id === activeItem?.id);
|
||||
|
||||
const activeItemRef = useRef<DragAndDropElement | null>(null);
|
||||
@@ -79,6 +93,19 @@ function DragAndDropInner<T>(
|
||||
startY: number;
|
||||
} | null>(null);
|
||||
|
||||
const saveInitialItems = useCallback(() => {
|
||||
initialItemsRef.current = items;
|
||||
}, [items]);
|
||||
|
||||
const keyboardDragStart = (item: DragAndDropElement | null) => {
|
||||
if (item) {
|
||||
saveInitialItems();
|
||||
} else {
|
||||
initialItemsRef.current = null;
|
||||
}
|
||||
setDragHandlerActive(item);
|
||||
};
|
||||
|
||||
const startPendingDrag = (
|
||||
event: React.PointerEvent,
|
||||
item: DragAndDropElement,
|
||||
@@ -121,6 +148,21 @@ function DragAndDropInner<T>(
|
||||
[setItems],
|
||||
);
|
||||
|
||||
const cancelDrag = useCallback(
|
||||
(resetOrder = false) => {
|
||||
if (resetOrder && initialItemsRef.current) {
|
||||
setItems(initialItemsRef.current);
|
||||
}
|
||||
setOverlayWidth(null);
|
||||
setDragHandlerActive(null);
|
||||
setCombinedActiveItem(null);
|
||||
setCombinedDropTarget(null);
|
||||
pendingDragStartRef.current = null;
|
||||
initialItemsRef.current = null;
|
||||
},
|
||||
[setItems, setCombinedActiveItem, setCombinedDropTarget],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
/* This useEffect is used to toggle a class on the html element when dragging,
|
||||
to prevent cursor issues when dragging over interactive elements,
|
||||
@@ -156,6 +198,7 @@ function DragAndDropInner<T>(
|
||||
}
|
||||
|
||||
setOverlayWidth(element?.getBoundingClientRect().width ?? null);
|
||||
saveInitialItems();
|
||||
setCombinedActiveItem(pendingStart.item);
|
||||
setCombinedDropTarget(pendingStart.item);
|
||||
pendingDragStartRef.current = null;
|
||||
@@ -221,25 +264,12 @@ function DragAndDropInner<T>(
|
||||
|
||||
if (active && target && active.id !== target.id) {
|
||||
reorderItems(active.index, target.index);
|
||||
setAnnouncement(
|
||||
`Element flyttet til posisjon ${target?.index + 1} av ${items.length}`,
|
||||
); // TODO - Bedre formulering?
|
||||
}
|
||||
|
||||
setOverlayWidth(null);
|
||||
setDragHandlerActive(null);
|
||||
setCombinedActiveItem(null);
|
||||
setCombinedDropTarget(null);
|
||||
pendingDragStartRef.current = null;
|
||||
cancelDrag();
|
||||
};
|
||||
|
||||
const handlePointerCancel = () => {
|
||||
setOverlayWidth(null);
|
||||
setDragHandlerActive(null);
|
||||
setCombinedActiveItem(null);
|
||||
setCombinedDropTarget(null);
|
||||
pendingDragStartRef.current = null;
|
||||
};
|
||||
const handlePointerCancel = () => cancelDrag(true);
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
@@ -254,10 +284,11 @@ function DragAndDropInner<T>(
|
||||
setCombinedDropTarget,
|
||||
setCombinedActiveItem,
|
||||
reorderItems,
|
||||
items.length,
|
||||
saveInitialItems,
|
||||
cancelDrag,
|
||||
]);
|
||||
|
||||
const onKeyboardDragEnd = (diff: number) => {
|
||||
const onKeyboardDragEnd = (diff: number, label: string) => {
|
||||
if (!dragHandlerActive) return;
|
||||
|
||||
const targetIndex = dragHandlerActive.index + diff;
|
||||
@@ -265,11 +296,9 @@ function DragAndDropInner<T>(
|
||||
return;
|
||||
}
|
||||
|
||||
setAnnouncer(`${label}. Plass ${targetIndex + 1} av ${items.length}.`);
|
||||
reorderItems(dragHandlerActive.index, targetIndex);
|
||||
setDragHandlerActive({ ...dragHandlerActive, index: targetIndex });
|
||||
setAnnouncement(
|
||||
`Element flyttet til posisjon ${targetIndex + 1} av ${items.length}`,
|
||||
); // TODO - Bedre formulering?
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -279,12 +308,27 @@ function DragAndDropInner<T>(
|
||||
dropTarget={dropTarget}
|
||||
setDropTarget={setCombinedDropTarget}
|
||||
dragHandlerActive={dragHandlerActive}
|
||||
setDragHandlerActive={setDragHandlerActive}
|
||||
onKeyboardDragStart={keyboardDragStart}
|
||||
onKeyboardDragEnd={onKeyboardDragEnd}
|
||||
startPendingDrag={startPendingDrag}
|
||||
cancelDrag={cancelDrag}
|
||||
setAnnouncer={setAnnouncer}
|
||||
itemAmount={items.length}
|
||||
>
|
||||
<ul ref={forwardedRef} aria-label="Dra og slipp elementer">
|
||||
<span id={SR_INSTRUCTIONS_ID} className="sr-only">
|
||||
Bruk Tab for å fokusere på en kolonne. Trykk mellomrom eller enter for å
|
||||
starte flytting, bruk piltastene for å flytte kolonnen, trykk mellomrom
|
||||
eller enter for å slippe, eller Escape for å avbryte.
|
||||
</span>
|
||||
<div aria-live="assertive" className="sr-only" aria-atomic>
|
||||
{announcer}
|
||||
</div>
|
||||
<ul
|
||||
ref={forwardedRef}
|
||||
aria-label="Kolonneinnstillinger"
|
||||
aria-describedby={SR_INSTRUCTIONS_ID}
|
||||
className="aksel-data-table__drag-and-drop-root"
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
return (
|
||||
<DragAndDropItem
|
||||
@@ -306,6 +350,7 @@ function DragAndDropInner<T>(
|
||||
<Floating.Content
|
||||
align="start"
|
||||
updatePositionStrategy="always"
|
||||
aria-hidden
|
||||
style={{
|
||||
pointerEvents: "none",
|
||||
boxSizing: "border-box",
|
||||
@@ -323,15 +368,15 @@ function DragAndDropInner<T>(
|
||||
</Floating.Content>
|
||||
</Floating>
|
||||
)}
|
||||
<div aria-live="assertive" className="sr-only" aria-atomic="true">
|
||||
{announcement}
|
||||
</div>
|
||||
</DragAndDropProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const DragAndDrop = forwardRef(DragAndDropInner) as <T>(
|
||||
props: DragAndDropProps<T> & React.RefAttributes<HTMLUListElement>,
|
||||
const DragAndDrop = forwardRef(DragAndDropInner) as <
|
||||
T,
|
||||
DetailsT extends Record<string, any>,
|
||||
>(
|
||||
props: DragAndDropProps<T, DetailsT> & React.RefAttributes<HTMLUListElement>,
|
||||
) => React.ReactElement | null;
|
||||
|
||||
export { DragAndDrop, DragAndDropItem };
|
||||
|
||||
@@ -2,8 +2,3 @@ export interface DragAndDropElement {
|
||||
id: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface DragAndDropItemData {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
ColumnDefinitions,
|
||||
SortEntry,
|
||||
} from "../table/root/DataTable.types";
|
||||
import DataTableAuto from "../table/root/DataTableAuto";
|
||||
|
||||
const meta: Meta<typeof DataTable> = {
|
||||
title: "ds-react/Data/Data Prop",
|
||||
@@ -29,7 +28,9 @@ type Story = StoryObj<typeof DataTable>;
|
||||
|
||||
const selectionControls = {
|
||||
args: {
|
||||
selectionMode: "multiple",
|
||||
selection: {
|
||||
selectionMode: "multiple",
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
controls: {
|
||||
@@ -37,9 +38,11 @@ const selectionControls = {
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
selectionMode: {
|
||||
control: { type: "select" },
|
||||
options: ["none", "single", "multiple"],
|
||||
selection: {
|
||||
selectionMode: {
|
||||
control: { type: "select" },
|
||||
options: ["none", "single", "multiple"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -58,7 +61,7 @@ const userColumnDef: ColumnDefinitions<UserDataTest> = [
|
||||
header: "Id",
|
||||
label: "Id",
|
||||
cell: ({ id }) => id,
|
||||
type: "number",
|
||||
align: "right",
|
||||
defaultWidth: "100px",
|
||||
},
|
||||
{
|
||||
@@ -110,18 +113,20 @@ const userData = generateUserData(4);
|
||||
|
||||
export const ItemsAsData: Story = {
|
||||
render: () => {
|
||||
return <DataTableAuto columnDefinitions={userColumnDef} data={userData} />;
|
||||
return <DataTable columnDefinitions={userColumnDef} data={userData} />;
|
||||
},
|
||||
};
|
||||
|
||||
export const ItemsAsDataWithCustomRowId: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={userColumnDef}
|
||||
data={userData}
|
||||
selectionMode="multiple"
|
||||
onSelectionChange={console.info}
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
onSelectionChange: console.info,
|
||||
}}
|
||||
getRowId={(row) => row.foo + row.bar}
|
||||
/>
|
||||
);
|
||||
@@ -131,11 +136,13 @@ export const ItemsAsDataWithCustomRowId: Story = {
|
||||
export const SelectionModeMultiple: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={userColumnDef}
|
||||
data={userData}
|
||||
selectionMode="multiple"
|
||||
onSelectionChange={console.info}
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
onSelectionChange: console.info,
|
||||
}}
|
||||
getRowId={(row) => row.foo + row.bar}
|
||||
withKeyboardNav
|
||||
/>
|
||||
@@ -146,11 +153,13 @@ export const SelectionModeMultiple: Story = {
|
||||
export const SelectionModeSingle: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={userColumnDef}
|
||||
data={userData}
|
||||
selectionMode="single"
|
||||
onSelectionChange={console.info}
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
onSelectionChange: console.info,
|
||||
}}
|
||||
getRowId={(row) => row.foo + row.bar}
|
||||
withKeyboardNav
|
||||
/>
|
||||
@@ -161,11 +170,13 @@ export const SelectionModeSingle: Story = {
|
||||
export const SelectionModeSingleWithoutKeyboardNav: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={userColumnDef}
|
||||
data={userData}
|
||||
selectionMode="single"
|
||||
onSelectionChange={console.info}
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
onSelectionChange: console.info,
|
||||
}}
|
||||
getRowId={(row) => row.foo + row.bar}
|
||||
/>
|
||||
);
|
||||
@@ -175,14 +186,16 @@ export const SelectionModeSingleWithoutKeyboardNav: Story = {
|
||||
export const SelectionWithDisabledRows: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={userColumnDef}
|
||||
data={userData}
|
||||
selectionMode="multiple"
|
||||
onSelectionChange={console.info}
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
onSelectionChange: console.info,
|
||||
disabledSelectionKeys: [1, 2],
|
||||
}}
|
||||
getRowId={(row) => row.id}
|
||||
withKeyboardNav
|
||||
disabledSelectionKeys={[1, 2]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -202,12 +215,14 @@ export const ControlledSelection: Story = {
|
||||
Clear selection
|
||||
</Button>
|
||||
</div>
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={userColumnDef}
|
||||
data={userData}
|
||||
selectionMode="multiple"
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={setSelectedKeys}
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
onSelectionChange: setSelectedKeys,
|
||||
selectedKeys,
|
||||
}}
|
||||
getRowId={(row) => row.id}
|
||||
/>
|
||||
</div>
|
||||
@@ -218,12 +233,14 @@ export const ControlledSelection: Story = {
|
||||
export const DefaultSelectedKeys: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={userColumnDef}
|
||||
data={userData}
|
||||
selectionMode="multiple"
|
||||
defaultSelectedKeys={[1, 3]}
|
||||
onSelectionChange={console.info}
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
onSelectionChange: console.info,
|
||||
defaultSelectedKeys: [1, 3],
|
||||
}}
|
||||
getRowId={(row) => row.id}
|
||||
/>
|
||||
);
|
||||
@@ -233,13 +250,15 @@ export const DefaultSelectedKeys: Story = {
|
||||
export const SingleSelectionWithDisabledRows: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={userColumnDef}
|
||||
data={userData}
|
||||
selectionMode="single"
|
||||
onSelectionChange={console.info}
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
onSelectionChange: console.info,
|
||||
disabledSelectionKeys: [2],
|
||||
}}
|
||||
getRowId={(row) => row.id}
|
||||
disabledSelectionKeys={[2]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -248,10 +267,12 @@ export const SingleSelectionWithDisabledRows: Story = {
|
||||
export const EmptyData: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={userColumnDef}
|
||||
data={[]}
|
||||
selectionMode="multiple"
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -260,7 +281,7 @@ export const EmptyData: Story = {
|
||||
export const EmptyDataWithEmptyState: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={userColumnDef}
|
||||
data={[]}
|
||||
emptyState="Ingen data å vise"
|
||||
@@ -277,11 +298,10 @@ export const LoadingWithSkeletonRows: Story = {
|
||||
<Button onClick={() => setIsLoading((prev) => !prev)}>
|
||||
Toggle loading
|
||||
</Button>
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={userColumnDef}
|
||||
data={[]}
|
||||
isLoading={isLoading}
|
||||
loadingRows={4}
|
||||
loading={{ isLoading, loadingRows: 4 }}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
@@ -296,11 +316,10 @@ export const LoadingWithLoadingState: Story = {
|
||||
<Button onClick={() => setIsLoading((prev) => !prev)}>
|
||||
Toggle loading
|
||||
</Button>
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={userColumnDef}
|
||||
data={[]}
|
||||
isLoading={isLoading}
|
||||
loadingState="Laster data..."
|
||||
loading={{ isLoading, loadingState: "Laster data..." }}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
@@ -315,11 +334,10 @@ export const LoadingWhileKeepingData: Story = {
|
||||
<Button onClick={() => setIsLoading((prev) => !prev)}>
|
||||
Toggle loading
|
||||
</Button>
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={userColumnDef}
|
||||
data={userData}
|
||||
isLoading={isLoading}
|
||||
loadingRows={4}
|
||||
loading={{ isLoading, loadingRows: 4 }}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
@@ -334,14 +352,20 @@ export const LoadingWhileKeepingDataNoPlaceholders: Story = {
|
||||
<Button onClick={() => setIsLoading((prev) => !prev)}>
|
||||
Toggle loading
|
||||
</Button>
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={userColumnDef}
|
||||
data={userData}
|
||||
isLoading={isLoading}
|
||||
loading={{ isLoading, loadingLabel: "Laster innhold for tabell" }}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(canvas.getAllByText("Laster innhold for tabell")).toHaveLength(1);
|
||||
expect(canvas.getByText("foo2")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
const selectionPaginationSpy = fn();
|
||||
@@ -357,13 +381,15 @@ export const SelectionPagination: Story = {
|
||||
<div>
|
||||
<button onClick={() => setPage("0")}>Page 1</button>
|
||||
<button onClick={() => setPage("1")}>Page 2</button>
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={userColumnDef}
|
||||
data={dataToShow}
|
||||
selectionMode="multiple"
|
||||
onSelectionChange={(keys) => {
|
||||
console.info({ keys });
|
||||
selectionPaginationSpy(keys);
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
onSelectionChange: (keys) => {
|
||||
console.info({ keys });
|
||||
selectionPaginationSpy(keys);
|
||||
},
|
||||
}}
|
||||
getRowId={(row) => row.foo + row.bar}
|
||||
/>
|
||||
@@ -446,14 +472,16 @@ export const SelectionPagination: Story = {
|
||||
export const StickySelection: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={userColumnDef.map((col) => ({
|
||||
...col,
|
||||
defaultWidth: "350px",
|
||||
}))}
|
||||
data={userData}
|
||||
selectionMode="multiple"
|
||||
onSelectionChange={console.info}
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
onSelectionChange: console.info,
|
||||
}}
|
||||
getRowId={(row) => row.foo + row.bar}
|
||||
withKeyboardNav
|
||||
stickyColumns={{
|
||||
@@ -467,7 +495,7 @@ export const StickySelection: Story = {
|
||||
export const StickyLeftOne: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={userColumnDef.map((col) => ({
|
||||
...col,
|
||||
defaultWidth: "350px",
|
||||
@@ -486,7 +514,7 @@ export const StickyLeftOne: Story = {
|
||||
export const StickyRightOne: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={userColumnDef.map((col) => ({
|
||||
...col,
|
||||
defaultWidth: "350px",
|
||||
@@ -505,7 +533,7 @@ export const StickyRightOne: Story = {
|
||||
export const StickyBothOne: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={userColumnDef.map((col) => ({
|
||||
...col,
|
||||
defaultWidth: "250px",
|
||||
@@ -526,7 +554,7 @@ export const StickyHeader: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ height: "300px" }}>
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={userColumnDef}
|
||||
data={generateUserData(20)}
|
||||
getRowId={(row) => row.foo + row.bar}
|
||||
@@ -542,7 +570,7 @@ export const StickyHeaderAndColumns: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ height: "300px" }}>
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={userColumnDef.map((col) => ({
|
||||
...col,
|
||||
defaultWidth: "250px",
|
||||
@@ -603,7 +631,7 @@ const sortableColumnDef: ColumnDefinitions<SortableUserDataTest> = [
|
||||
id: "id",
|
||||
header: "Id",
|
||||
cell: ({ id }) => id,
|
||||
type: "number",
|
||||
align: "right",
|
||||
sortable: true,
|
||||
label: "Id",
|
||||
},
|
||||
@@ -646,7 +674,7 @@ export const SortableColumns: Story = {
|
||||
const sortedData = applySortEntries(sortableUserData, sort);
|
||||
|
||||
return (
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={sortableColumnDef}
|
||||
data={sortedData}
|
||||
getRowId={(row) => row.id}
|
||||
@@ -669,7 +697,7 @@ export const SortableColumnsUncontrolled: Story = {
|
||||
{loggedDetail && (
|
||||
<pre style={{ fontSize: "0.75rem" }}>{loggedDetail}</pre>
|
||||
)}
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={sortableColumnDef}
|
||||
data={sortableUserData}
|
||||
getRowId={(row) => row.id}
|
||||
@@ -686,7 +714,7 @@ export const SortableColumnsUncontrolled: Story = {
|
||||
const rowClickSpy = fn();
|
||||
|
||||
const rowClickColumnDef: ColumnDefinitions<UserDataTest> = [
|
||||
{ id: "id", header: "Id", cell: ({ id }) => id, type: "number", label: "Id" },
|
||||
{ id: "id", header: "Id", cell: ({ id }) => id, align: "right", label: "Id" },
|
||||
{ id: "foo", header: "Foo", cell: ({ foo }) => foo, label: "Foo" },
|
||||
{
|
||||
id: "link",
|
||||
@@ -714,12 +742,14 @@ const rowClickColumnDef: ColumnDefinitions<UserDataTest> = [
|
||||
|
||||
export const RowClick: Story = {
|
||||
render: () => (
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={rowClickColumnDef}
|
||||
data={userData}
|
||||
getRowId={(row) => row.id}
|
||||
onRowClick={() => console.info("Row clicked!")}
|
||||
selectionMode="multiple"
|
||||
onRowClick={(rowId) => console.info("Row clicked!: ", rowId)}
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
}}
|
||||
withKeyboardNav
|
||||
/>
|
||||
),
|
||||
@@ -727,12 +757,14 @@ export const RowClick: Story = {
|
||||
|
||||
export const RowClickTest: Story = {
|
||||
render: () => (
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={rowClickColumnDef}
|
||||
data={userData}
|
||||
getRowId={(row) => row.id}
|
||||
onRowClick={rowClickSpy}
|
||||
selectionMode="multiple"
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
@@ -764,15 +796,19 @@ export const RowClickTest: Story = {
|
||||
|
||||
export const RowExpansion: Story = {
|
||||
render: (args) => (
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={rowClickColumnDef}
|
||||
data={userData}
|
||||
getRowId={(row) => row.id}
|
||||
onRowClick={() => console.info("Row clicked!")}
|
||||
selectionMode={args.selectionMode}
|
||||
selection={{
|
||||
selectionMode: args.selection?.selectionMode,
|
||||
}}
|
||||
withKeyboardNav
|
||||
getDetailsPanelContent={(rowData) => {
|
||||
return <div>{`Details for ${rowData.foo} (id: ${rowData.id})`}</div>;
|
||||
detailsPanel={{
|
||||
getContent: (rowData) => {
|
||||
return <div>{`Details for ${rowData.foo} (id: ${rowData.id})`}</div>;
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
@@ -781,17 +817,21 @@ export const RowExpansion: Story = {
|
||||
|
||||
export const RowExpansionAll: Story = {
|
||||
render: (args) => (
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={rowClickColumnDef}
|
||||
data={userData}
|
||||
getRowId={(row) => row.id}
|
||||
onRowClick={() => console.info("Row clicked!")}
|
||||
selectionMode={args.selectionMode}
|
||||
selection={{
|
||||
selectionMode: args.selection?.selectionMode,
|
||||
}}
|
||||
withKeyboardNav
|
||||
getDetailsPanelContent={(rowData) => {
|
||||
return <div>{`Details for ${rowData.foo} (id: ${rowData.id})`}</div>;
|
||||
detailsPanel={{
|
||||
getContent: (rowData) => {
|
||||
return <div>{`Details for ${rowData.foo} (id: ${rowData.id})`}</div>;
|
||||
},
|
||||
showExpandAll: true,
|
||||
}}
|
||||
showExpandAll
|
||||
/>
|
||||
),
|
||||
...selectionControls,
|
||||
@@ -810,14 +850,18 @@ const nestedRowData = userData.map((user) => ({
|
||||
|
||||
export const NestedRows: Story = {
|
||||
render: (args) => (
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
columnDefinitions={rowClickColumnDef}
|
||||
data={nestedRowData}
|
||||
getRowId={(row) => row.id}
|
||||
selectionMode={args.selectionMode}
|
||||
selection={{
|
||||
selectionMode: args.selection?.selectionMode,
|
||||
onSelectionChange: console.info,
|
||||
}}
|
||||
withKeyboardNav
|
||||
getSubRows={(row) => row.children}
|
||||
onSelectionChange={console.info}
|
||||
subRows={{
|
||||
getRows: (row) => row.children,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
@@ -861,14 +905,18 @@ export const NestedRows: Story = {
|
||||
|
||||
export const NestedLeftAlignedContentRows: Story = {
|
||||
render: (args) => (
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
/* Removes right aligned id column */
|
||||
columnDefinitions={rowClickColumnDef.slice(1)}
|
||||
data={nestedRowData}
|
||||
getRowId={(row) => row.id}
|
||||
selectionMode={args.selectionMode}
|
||||
selection={{
|
||||
selectionMode: args.selection?.selectionMode,
|
||||
}}
|
||||
withKeyboardNav
|
||||
getSubRows={(row) => row.children}
|
||||
subRows={{
|
||||
getRows: (row) => row.children,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
...selectionControls,
|
||||
@@ -876,7 +924,7 @@ export const NestedLeftAlignedContentRows: Story = {
|
||||
|
||||
export const NestedOneLevelLeftAlignedContentRows: Story = {
|
||||
render: (args) => (
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
/* Removes right aligned id column */
|
||||
columnDefinitions={rowClickColumnDef.slice(1)}
|
||||
data={userData.map((user) => ({
|
||||
@@ -887,9 +935,13 @@ export const NestedOneLevelLeftAlignedContentRows: Story = {
|
||||
})),
|
||||
}))}
|
||||
getRowId={(row) => row.id}
|
||||
selectionMode={args.selectionMode}
|
||||
selection={{
|
||||
selectionMode: args.selection?.selectionMode,
|
||||
}}
|
||||
withKeyboardNav
|
||||
getSubRows={(row) => row.children}
|
||||
subRows={{
|
||||
getRows: (row) => row.children,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
...selectionControls,
|
||||
@@ -897,7 +949,7 @@ export const NestedOneLevelLeftAlignedContentRows: Story = {
|
||||
|
||||
export const NestedRowsWithMasterDetail: Story = {
|
||||
render: (args) => (
|
||||
<DataTableAuto
|
||||
<DataTable
|
||||
/* Removes right aligned id column */
|
||||
columnDefinitions={rowClickColumnDef.slice(1)}
|
||||
data={userData.map((user) => ({
|
||||
@@ -908,11 +960,23 @@ export const NestedRowsWithMasterDetail: Story = {
|
||||
})),
|
||||
}))}
|
||||
getRowId={(row) => row.id}
|
||||
selectionMode={args.selectionMode}
|
||||
selection={{
|
||||
selectionMode: args.selection?.selectionMode,
|
||||
}}
|
||||
withKeyboardNav
|
||||
getSubRows={(row) => row.children}
|
||||
getDetailsPanelContent={() => <div>Placeholder content</div>}
|
||||
getDetailsPanelHeight={() => 100}
|
||||
subRows={{
|
||||
getRows: (row) => row.children,
|
||||
}}
|
||||
detailsPanel={{
|
||||
getContent: () => (
|
||||
<div>
|
||||
This is the details panel content. It should be possible to interact
|
||||
with the content here without triggering row clicks or affecting row
|
||||
selection.
|
||||
</div>
|
||||
),
|
||||
getHeight: () => 100,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
/* play: async ({ canvasElement }) => {
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
import type { Meta } from "@storybook/react-vite";
|
||||
import React, { useState } from "react";
|
||||
import { ChevronDownIcon } from "@navikt/aksel-icons";
|
||||
import { Button } from "../../button";
|
||||
import { Checkbox } from "../../form/checkbox";
|
||||
import { HStack, Spacer } from "../../primitives/stack";
|
||||
import { Table } from "../../table";
|
||||
import { Tag } from "../../tag";
|
||||
import { DataTable } from "../table";
|
||||
import type { ColumnDefinitions } from "../table/root/DataTable.types";
|
||||
|
||||
const meta: Meta<typeof DataTable> = {
|
||||
title: "ds-react/Data/Compare",
|
||||
component: DataTable,
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
layout: "padded",
|
||||
a11y: { disable: true },
|
||||
controls: { disable: true },
|
||||
docs: { disable: true },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const generateUserData = (
|
||||
count: number,
|
||||
addNested: boolean = true,
|
||||
): UserDataTest[] => {
|
||||
const num = (index: number) => index + 1;
|
||||
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: num(i) + (addNested ? 0 : 100),
|
||||
foo: `foo${num(i)}`,
|
||||
bar: `bar${num(i)}`,
|
||||
baz: num(i) % 2 === 0,
|
||||
time: new Date(Date.now() - Math.floor(Math.random() * 10000000000)),
|
||||
nestedRows: addNested ? generateUserData(Math.floor(count / 2), false) : [],
|
||||
}));
|
||||
};
|
||||
|
||||
const data = generateUserData(10);
|
||||
|
||||
type UserDataTest = {
|
||||
id: number;
|
||||
foo: string;
|
||||
bar: string;
|
||||
baz: boolean;
|
||||
time: Date;
|
||||
nestedRows: UserDataTest[];
|
||||
};
|
||||
|
||||
const columns: ColumnDefinitions<UserDataTest> = [
|
||||
{
|
||||
id: "id",
|
||||
label: "Id",
|
||||
cell: ({ id }) => id,
|
||||
align: "right",
|
||||
},
|
||||
{
|
||||
id: "foo",
|
||||
label: "Foo",
|
||||
cell: ({ foo }) => foo,
|
||||
isRowHeader: true,
|
||||
},
|
||||
{
|
||||
id: "bar",
|
||||
label: "Bar",
|
||||
cell: ({ bar }) => (
|
||||
<Tag variant="strong" size="xsmall">
|
||||
{bar}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "baz",
|
||||
label: "Baz",
|
||||
cell: ({ baz }) => (baz ? "Yes" : "No"),
|
||||
},
|
||||
{
|
||||
id: "time",
|
||||
label: "Time",
|
||||
align: "right",
|
||||
cell: ({ time }) => time.toLocaleTimeString("no"),
|
||||
},
|
||||
];
|
||||
|
||||
export function DataTableOne() {
|
||||
return <DataTable columnDefinitions={columns} data={data} layout="auto" />;
|
||||
}
|
||||
|
||||
export function OriginalOne() {
|
||||
return (
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{columns.map((column) => (
|
||||
<Table.ColumnHeader
|
||||
scope="col"
|
||||
key={column.id}
|
||||
align={column.align}
|
||||
>
|
||||
{column.label}
|
||||
</Table.ColumnHeader>
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{data.map((row) => {
|
||||
return (
|
||||
<Table.Row key={row.id}>
|
||||
{columns.map((column) =>
|
||||
column.isRowHeader ? (
|
||||
<Table.HeaderCell
|
||||
key={column.id}
|
||||
scope="row"
|
||||
align={column.align}
|
||||
>
|
||||
{column.cell(row)}
|
||||
</Table.HeaderCell>
|
||||
) : (
|
||||
<Table.DataCell key={column.id} align={column.align}>
|
||||
{column.cell(row)}
|
||||
</Table.DataCell>
|
||||
),
|
||||
)}
|
||||
</Table.Row>
|
||||
);
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTableTwo() {
|
||||
return (
|
||||
<DataTable
|
||||
columnDefinitions={columns}
|
||||
data={data}
|
||||
layout="auto"
|
||||
getRowId={(row) => row.id}
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
onSelectionChange: console.info,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function OriginalTwo() {
|
||||
const [selectedRows, setSelectedRows] = useState<number[]>([]);
|
||||
|
||||
const toggleSelectedRow = (value) =>
|
||||
setSelectedRows((list) =>
|
||||
list.includes(value)
|
||||
? list.filter((id) => id !== value)
|
||||
: [...list, value],
|
||||
);
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.DataCell>
|
||||
<Checkbox
|
||||
checked={selectedRows.length === data.length}
|
||||
indeterminate={
|
||||
selectedRows.length > 0 && selectedRows.length !== data.length
|
||||
}
|
||||
onChange={() => {
|
||||
selectedRows.length
|
||||
? setSelectedRows([])
|
||||
: setSelectedRows(data.map(({ id }) => id));
|
||||
}}
|
||||
hideLabel
|
||||
>
|
||||
Velg alle rader
|
||||
</Checkbox>
|
||||
</Table.DataCell>
|
||||
{columns.map((column) => (
|
||||
<Table.ColumnHeader
|
||||
scope="col"
|
||||
key={column.id}
|
||||
align={column.align}
|
||||
>
|
||||
{column.label}
|
||||
</Table.ColumnHeader>
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{data.map((row) => {
|
||||
return (
|
||||
<Table.Row key={row.id} selected={selectedRows.includes(row.id)}>
|
||||
<Table.DataCell>
|
||||
<Checkbox
|
||||
hideLabel
|
||||
checked={selectedRows.includes(row.id)}
|
||||
onChange={() => toggleSelectedRow(row.id)}
|
||||
aria-labelledby={`id-${row.id}`}
|
||||
>
|
||||
{" "}
|
||||
</Checkbox>
|
||||
</Table.DataCell>
|
||||
{columns.map((column) =>
|
||||
column.isRowHeader ? (
|
||||
<Table.HeaderCell
|
||||
key={column.id}
|
||||
scope="row"
|
||||
align={column.align}
|
||||
>
|
||||
{column.cell(row)}
|
||||
</Table.HeaderCell>
|
||||
) : (
|
||||
<Table.DataCell key={column.id} align={column.align}>
|
||||
{column.cell(row)}
|
||||
</Table.DataCell>
|
||||
),
|
||||
)}
|
||||
</Table.Row>
|
||||
);
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailsPanel() {
|
||||
return <div>Placeholder</div>;
|
||||
}
|
||||
|
||||
export function DataTableThree() {
|
||||
return (
|
||||
<DataTable
|
||||
columnDefinitions={columns}
|
||||
data={data}
|
||||
layout="auto"
|
||||
getRowId={(row) => row.id}
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
onSelectionChange: console.info,
|
||||
}}
|
||||
detailsPanel={{
|
||||
getContent: DetailsPanel,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function OriginalThree() {
|
||||
const [selectedRows, setSelectedRows] = useState<number[]>([]);
|
||||
|
||||
const toggleSelectedRow = (value) =>
|
||||
setSelectedRows((list) =>
|
||||
list.includes(value)
|
||||
? list.filter((id) => id !== value)
|
||||
: [...list, value],
|
||||
);
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.DataCell aria-hidden />
|
||||
<Table.DataCell>
|
||||
<Checkbox
|
||||
checked={selectedRows.length === data.length}
|
||||
indeterminate={
|
||||
selectedRows.length > 0 && selectedRows.length !== data.length
|
||||
}
|
||||
onChange={() => {
|
||||
selectedRows.length
|
||||
? setSelectedRows([])
|
||||
: setSelectedRows(data.map(({ id }) => id));
|
||||
}}
|
||||
hideLabel
|
||||
>
|
||||
Velg alle rader
|
||||
</Checkbox>
|
||||
</Table.DataCell>
|
||||
{columns.map((column) => (
|
||||
<Table.ColumnHeader
|
||||
scope="col"
|
||||
key={column.id}
|
||||
align={column.align}
|
||||
>
|
||||
{column.label}
|
||||
</Table.ColumnHeader>
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{data.map((row) => {
|
||||
return (
|
||||
<Table.ExpandableRow
|
||||
key={row.id}
|
||||
selected={selectedRows.includes(row.id)}
|
||||
content={<DetailsPanel />}
|
||||
>
|
||||
<Table.DataCell>
|
||||
<Checkbox
|
||||
hideLabel
|
||||
checked={selectedRows.includes(row.id)}
|
||||
onChange={() => toggleSelectedRow(row.id)}
|
||||
aria-labelledby={`id-${row.id}`}
|
||||
>
|
||||
{" "}
|
||||
</Checkbox>
|
||||
</Table.DataCell>
|
||||
{columns.map((column) =>
|
||||
column.isRowHeader ? (
|
||||
<Table.HeaderCell
|
||||
key={column.id}
|
||||
scope="row"
|
||||
align={column.align}
|
||||
>
|
||||
{column.cell(row)}
|
||||
</Table.HeaderCell>
|
||||
) : (
|
||||
<Table.DataCell key={column.id} align={column.align}>
|
||||
{column.cell(row)}
|
||||
</Table.DataCell>
|
||||
),
|
||||
)}
|
||||
</Table.ExpandableRow>
|
||||
);
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTableFour() {
|
||||
return (
|
||||
<DataTable
|
||||
columnDefinitions={columns}
|
||||
data={data}
|
||||
layout="auto"
|
||||
getRowId={(row) => row.id}
|
||||
selection={{
|
||||
selectionMode: "multiple",
|
||||
onSelectionChange: console.info,
|
||||
}}
|
||||
detailsPanel={{
|
||||
getContent: DetailsPanel,
|
||||
}}
|
||||
subRows={{
|
||||
getRows: (row) => row.nestedRows,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function OriginalFour() {
|
||||
const [selectedRows, setSelectedRows] = useState<number[]>([]);
|
||||
const [expandedRows, setExpandedRows] = useState<number[]>([]);
|
||||
|
||||
const toggleSelectedRow = (value) =>
|
||||
setSelectedRows((list) =>
|
||||
list.includes(value)
|
||||
? list.filter((id) => id !== value)
|
||||
: [...list, value],
|
||||
);
|
||||
|
||||
const RowComponent = ({
|
||||
row,
|
||||
nested = false,
|
||||
}: {
|
||||
row: UserDataTest;
|
||||
nested: boolean;
|
||||
}) => {
|
||||
const WrapperComponent = ({
|
||||
children,
|
||||
selected,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
selected: boolean;
|
||||
}) => {
|
||||
if (nested) {
|
||||
return (
|
||||
<Table.Row selected={selected}>
|
||||
<Table.DataCell aria-hidden />
|
||||
{children}
|
||||
</Table.Row>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.ExpandableRow content={<DetailsPanel />} selected={selected}>
|
||||
{children}
|
||||
</Table.ExpandableRow>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<WrapperComponent key={row.id} selected={selectedRows.includes(row.id)}>
|
||||
<Table.DataCell>
|
||||
<Checkbox
|
||||
hideLabel
|
||||
checked={selectedRows.includes(row.id)}
|
||||
onChange={() => toggleSelectedRow(row.id)}
|
||||
aria-labelledby={`id-${row.id}`}
|
||||
>
|
||||
{" "}
|
||||
</Checkbox>
|
||||
</Table.DataCell>
|
||||
{columns.map((column, index) =>
|
||||
column.isRowHeader ? (
|
||||
<Table.HeaderCell key={column.id} scope="row" align={column.align}>
|
||||
{column.cell(row)}
|
||||
</Table.HeaderCell>
|
||||
) : (
|
||||
<Table.DataCell key={column.id} align={column.align}>
|
||||
<HStack align="center" gap="space-4" justify="space-between">
|
||||
{index === 0 && row.nestedRows.length > 0 && (
|
||||
<Button
|
||||
icon={<ChevronDownIcon />}
|
||||
size="small"
|
||||
variant="tertiary"
|
||||
data-color="neutral"
|
||||
onClick={() => {
|
||||
setExpandedRows((list) =>
|
||||
list.includes(row.id)
|
||||
? list.filter((id) => id !== row.id)
|
||||
: [...list, row.id],
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Spacer />
|
||||
<span>{column.cell(row)}</span>
|
||||
</HStack>
|
||||
</Table.DataCell>
|
||||
),
|
||||
)}
|
||||
</WrapperComponent>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.DataCell aria-hidden />
|
||||
<Table.DataCell>
|
||||
<Checkbox
|
||||
checked={selectedRows.length === data.length}
|
||||
indeterminate={
|
||||
selectedRows.length > 0 && selectedRows.length !== data.length
|
||||
}
|
||||
onChange={() => {
|
||||
selectedRows.length
|
||||
? setSelectedRows([])
|
||||
: setSelectedRows(data.map(({ id }) => id));
|
||||
}}
|
||||
hideLabel
|
||||
>
|
||||
Velg alle rader
|
||||
</Checkbox>
|
||||
</Table.DataCell>
|
||||
{columns.map((column) => (
|
||||
<Table.ColumnHeader
|
||||
scope="col"
|
||||
key={column.id}
|
||||
align={column.align}
|
||||
>
|
||||
{column.label}
|
||||
</Table.ColumnHeader>
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{data.map((row) => {
|
||||
if (row.nestedRows.length > 0) {
|
||||
return (
|
||||
<React.Fragment key={row.id}>
|
||||
<RowComponent row={row} nested={false} />
|
||||
{expandedRows.includes(row.id) &&
|
||||
row.nestedRows.map((nestedRow) => (
|
||||
<RowComponent key={nestedRow.id} row={nestedRow} nested />
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
return <RowComponent row={row} nested={false} key={row.id} />;
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,8 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
Table,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import React, { useState } from "react";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { DataTable } from "../table";
|
||||
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
|
||||
import { DataTableProfiler } from "./DataTableProfiler";
|
||||
import { PersonInfo, columns, sampleData } from "./dummy-data";
|
||||
import type { ColumnDefinitions } from "../table/root/DataTable.types";
|
||||
import { DataTable } from "../table/root/DataTableRoot";
|
||||
|
||||
const meta: Meta<typeof DataTable> = {
|
||||
title: "ds-react/Data/Keyboard Navigation",
|
||||
@@ -22,227 +11,82 @@ const meta: Meta<typeof DataTable> = {
|
||||
chromatic: { disable: true },
|
||||
layout: "padded",
|
||||
},
|
||||
decorators: [(Story) => <DataTableProfiler>{Story()}</DataTableProfiler>],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DataTable>;
|
||||
|
||||
export const TanstackDemo: Story = {
|
||||
render: () => {
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
data: sampleData,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
globalFilterFn: "includesString",
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageIndex: 1,
|
||||
pageSize: 5,
|
||||
},
|
||||
},
|
||||
state: {},
|
||||
columnResizeMode: "onChange",
|
||||
});
|
||||
type InputRow = { id: 1 | 2 | 3 };
|
||||
|
||||
return (
|
||||
<div style={{ padding: "4rem", display: "grid", gap: "2rem" }}>
|
||||
<button>Focuable before</button>
|
||||
<DataTable withKeyboardNav>
|
||||
<DataTable.Thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => {
|
||||
return (
|
||||
<DataTable.Tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<DataTableColumnHeader
|
||||
key={header.id}
|
||||
style={{ width: `var(--header-${header.id}-size)` }}
|
||||
defaultWidth={header.getSize()}
|
||||
sortable
|
||||
sortDirection={header.column.getIsSorted() || "none"}
|
||||
onSortClick={(event) => {
|
||||
const handler =
|
||||
header.column.getToggleSortingHandler();
|
||||
handler?.(event);
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</DataTableColumnHeader>
|
||||
);
|
||||
})}
|
||||
</DataTable.Tr>
|
||||
);
|
||||
})}
|
||||
</DataTable.Thead>
|
||||
const inputsData: InputRow[] = [{ id: 1 }, { id: 2 }, { id: 3 }];
|
||||
|
||||
<MemoizedTableBody table={table} />
|
||||
</DataTable>
|
||||
<button>Focuable after</button>
|
||||
</div>
|
||||
);
|
||||
const inputsColumnDef: ColumnDefinitions<InputRow> = [
|
||||
{
|
||||
id: "col1",
|
||||
header: "Col 1",
|
||||
label: "Col 1",
|
||||
cell: ({ id }) =>
|
||||
id === 1 ? (
|
||||
<input type="checkbox" data-testid="checkbox-1" />
|
||||
) : (
|
||||
<input type="checkbox" />
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const Spans: Story = {
|
||||
render: () => (
|
||||
<div style={{ padding: "4rem", display: "grid", gap: "2rem" }}>
|
||||
<DataTable style={{ width: "100%" }} withKeyboardNav layout="auto">
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader colSpan={2}>Group A</DataTableColumnHeader>
|
||||
<DataTableColumnHeader colSpan={2}>Group B</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Col 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 3</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 4</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td rowSpan={2}>Rowspan 2-1</DataTable.Td>
|
||||
<DataTable.Td>R1C2</DataTable.Td>
|
||||
<DataTable.Td colSpan={2}>Colspan 2-1</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>R2C2</DataTable.Td>
|
||||
<DataTable.Td>R2C3</DataTable.Td>
|
||||
<DataTable.Td>R2C4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>R3C1</DataTable.Td>
|
||||
<DataTable.Td colSpan={2}>Colspan 2-2</DataTable.Td>
|
||||
<DataTable.Td>R3C4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td rowSpan={2}>Rowspan 2-2</DataTable.Td>
|
||||
<DataTable.Td>R4C2</DataTable.Td>
|
||||
<DataTable.Td>R4C3</DataTable.Td>
|
||||
<DataTable.Td>R4C4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td colSpan={3}>Colspan 3</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
</div>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const { expectNodeFocus, right, left, down, up } = keyboardUtils();
|
||||
|
||||
const step = async (action: () => Promise<any>, text: string) => {
|
||||
await action();
|
||||
expectNodeFocus(text);
|
||||
};
|
||||
|
||||
const table = canvas.getByRole("table");
|
||||
expect(table).toBeInTheDocument();
|
||||
|
||||
table.focus();
|
||||
|
||||
expectNodeFocus("Group A");
|
||||
await step(right, "Group B");
|
||||
await step(down, "Col 3");
|
||||
await step(up, "Group B");
|
||||
await down();
|
||||
await step(right, "Col 4");
|
||||
await left();
|
||||
await step(left, "Col 2");
|
||||
await step(down, "R1C2");
|
||||
await step(down, "R2C2");
|
||||
await step(right, "R2C3");
|
||||
await step(down, "Colspan 2-2");
|
||||
await step(up, "R2C2");
|
||||
await step(down, "Colspan 2-2");
|
||||
await step(left, "R3C1");
|
||||
await step(up, "Rowspan 2-1");
|
||||
await step(right, "R1C2");
|
||||
await left();
|
||||
await down();
|
||||
await step(down, "Rowspan 2-2");
|
||||
await step(right, "R4C2");
|
||||
await step(right, "R4C3");
|
||||
await step(right, "R4C4");
|
||||
await step(down, "Colspan 3");
|
||||
await step(left, "Rowspan 2-2");
|
||||
await step(right, "R4C2");
|
||||
{
|
||||
id: "col2",
|
||||
header: "Col 2",
|
||||
label: "Col 2",
|
||||
cell: ({ id }) => {
|
||||
if (id === 1)
|
||||
return (
|
||||
<input type="text" placeholder="Col 2" data-testid="input-col-2" />
|
||||
);
|
||||
if (id === 2)
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Col 2"
|
||||
defaultValue="Test"
|
||||
data-testid="input-2"
|
||||
/>
|
||||
);
|
||||
return <textarea placeholder="Col 2" data-testid="textarea-col-2" />;
|
||||
},
|
||||
},
|
||||
};
|
||||
{
|
||||
id: "col3",
|
||||
header: "Col 3",
|
||||
label: "Col 3",
|
||||
cell: ({ id }) =>
|
||||
id === 2 ? (
|
||||
<select data-testid="select">
|
||||
<option value="">Select</option>
|
||||
<option value="option1">Option 1</option>
|
||||
<option value="option2">Option 2</option>
|
||||
</select>
|
||||
) : (
|
||||
"Col 3"
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "col4",
|
||||
header: "Col 4",
|
||||
label: "Col 4",
|
||||
cell: () => "Col 4",
|
||||
},
|
||||
];
|
||||
|
||||
export const Inputs: Story = {
|
||||
render: () => (
|
||||
<div style={{ padding: "4rem", display: "grid", gap: "2rem" }}>
|
||||
<DataTable style={{ width: "100%" }} withKeyboardNav layout="auto">
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Col 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 3</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 4</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>
|
||||
<input type="checkbox" data-testid="checkbox-1" />
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Col 2"
|
||||
data-testid="input-col-2"
|
||||
/>
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>Col 3</DataTable.Td>
|
||||
<DataTable.Td>Col 4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>
|
||||
<input type="checkbox" />
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Col 2"
|
||||
defaultValue="Test"
|
||||
data-testid="input-2"
|
||||
/>
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>
|
||||
<select data-testid="select">
|
||||
<option value="">Select</option>
|
||||
<option value="option1">Option 1</option>
|
||||
<option value="option2">Option 2</option>
|
||||
</select>
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>Col 4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>
|
||||
<input type="checkbox" />
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>
|
||||
<textarea placeholder="Col 2" data-testid="textarea-col-2" />
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>Col 3</DataTable.Td>
|
||||
<DataTable.Td>Col 4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
</div>
|
||||
<DataTable
|
||||
style={{ width: "100%" }}
|
||||
withKeyboardNav
|
||||
layout="auto"
|
||||
columnDefinitions={inputsColumnDef}
|
||||
data={inputsData}
|
||||
getRowId={(row) => row.id}
|
||||
/>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
@@ -308,62 +152,83 @@ export const Inputs: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
type DisabledRow = { id: 1 | 2 | 3 };
|
||||
|
||||
const disabledData: DisabledRow[] = [{ id: 1 }, { id: 2 }, { id: 3 }];
|
||||
|
||||
const disabledColumnDef: ColumnDefinitions<DisabledRow> = [
|
||||
{
|
||||
id: "col1",
|
||||
header: "Col 1",
|
||||
label: "Col 1",
|
||||
cell: ({ id }) => {
|
||||
if (id === 2) return <button disabled>Disabled button</button>;
|
||||
if (id === 3)
|
||||
return <button aria-disabled="true">Aria-Disabled button</button>;
|
||||
return "Row 1 Col 1";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "col2",
|
||||
header: "Col 2",
|
||||
label: "Col 2",
|
||||
cell: ({ id }) => {
|
||||
if (id === 2)
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
defaultValue="Disabled input"
|
||||
data-testid="disabled-input"
|
||||
/>
|
||||
);
|
||||
if (id === 3) return "Aria Disabled 2";
|
||||
return "Row 1 Col 2";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "col3",
|
||||
header: "Col 3",
|
||||
label: "Col 3",
|
||||
cell: ({ id }) => {
|
||||
if (id === 2)
|
||||
return (
|
||||
<select disabled data-testid="disabled-select">
|
||||
<option>Disabled select</option>
|
||||
</select>
|
||||
);
|
||||
if (id === 3) return "Aria Disabled 3";
|
||||
return "Row 1 Col 3";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "col4",
|
||||
header: "Col 4",
|
||||
label: "Col 4",
|
||||
cell: ({ id }) => {
|
||||
if (id === 2)
|
||||
return (
|
||||
<textarea
|
||||
disabled
|
||||
defaultValue="Disabled textarea"
|
||||
data-testid="disabled-textarea"
|
||||
/>
|
||||
);
|
||||
if (id === 3) return "Aria Disabled 4";
|
||||
return "Row 1 Col 4";
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const DisabledCells: Story = {
|
||||
render: () => (
|
||||
<div style={{ padding: "4rem", display: "grid", gap: "2rem" }}>
|
||||
<DataTable style={{ width: "100%" }} withKeyboardNav layout="auto">
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Col 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 3</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 4</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Hidden 1</DataTable.Td>
|
||||
<DataTable.Td>Hidden 2</DataTable.Td>
|
||||
<DataTable.Td style={{ display: "none" }}>Hidden 3</DataTable.Td>
|
||||
<DataTable.Td style={{ visibility: "hidden" }}>
|
||||
Hidden 4
|
||||
</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>
|
||||
<button disabled>Disabled button</button>
|
||||
</DataTable.Td>
|
||||
<DataTable.Td data-testid="disabled-input">
|
||||
<input type="text" disabled defaultValue="Disabled input" />
|
||||
</DataTable.Td>
|
||||
<DataTable.Td data-testid="disabled-select">
|
||||
<select disabled>
|
||||
<option>Disabled select</option>
|
||||
</select>
|
||||
</DataTable.Td>
|
||||
<DataTable.Td data-testid="disabled-textarea">
|
||||
<textarea disabled defaultValue="Disabled textarea" />
|
||||
</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>
|
||||
<button aria-disabled="true">Aria-Disabled button</button>
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>Aria Disabled 2</DataTable.Td>
|
||||
<DataTable.Td>Aria Disabled 3</DataTable.Td>
|
||||
<DataTable.Td>Aria Disabled 4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Normal 1</DataTable.Td>
|
||||
<DataTable.Td style={{ visibility: "hidden" }}>
|
||||
<button disabled>Disabled button</button>
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>Normal 3</DataTable.Td>
|
||||
<DataTable.Td>Normal 4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
</div>
|
||||
<DataTable
|
||||
withKeyboardNav
|
||||
layout="auto"
|
||||
columnDefinitions={disabledColumnDef}
|
||||
data={disabledData}
|
||||
getRowId={(row) => row.id}
|
||||
/>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
@@ -388,22 +253,21 @@ export const DisabledCells: Story = {
|
||||
expectNodeFocus("Col 1");
|
||||
await step(right, "Col 2");
|
||||
await step(left, "Col 1");
|
||||
await step(down, "Hidden 1");
|
||||
await step(right, "Hidden 2");
|
||||
await step(right, "Hidden 2");
|
||||
await step(down, "Row 1 Col 1");
|
||||
await step(right, "Row 1 Col 2");
|
||||
|
||||
await left();
|
||||
await stepCell(down, "Disabled button");
|
||||
await right();
|
||||
expect(canvas.getByTestId("disabled-input")).toHaveFocus();
|
||||
expect(canvas.getByTestId("disabled-input").closest("td")).toHaveFocus();
|
||||
await right();
|
||||
expect(canvas.getByTestId("disabled-select")).toHaveFocus();
|
||||
expect(canvas.getByTestId("disabled-select").closest("td")).toHaveFocus();
|
||||
await right();
|
||||
expect(canvas.getByTestId("disabled-textarea")).toHaveFocus();
|
||||
expect(canvas.getByTestId("disabled-textarea").closest("td")).toHaveFocus();
|
||||
await left();
|
||||
expect(canvas.getByTestId("disabled-select")).toHaveFocus();
|
||||
expect(canvas.getByTestId("disabled-select").closest("td")).toHaveFocus();
|
||||
await left();
|
||||
expect(canvas.getByTestId("disabled-input")).toHaveFocus();
|
||||
expect(canvas.getByTestId("disabled-input").closest("td")).toHaveFocus();
|
||||
await left();
|
||||
expectCellFocus("Disabled button");
|
||||
await stepCell(down, "Aria-Disabled button");
|
||||
@@ -413,89 +277,108 @@ export const DisabledCells: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
type CacheRow = { id: number; col1: string };
|
||||
|
||||
const cacheColumnDef: ColumnDefinitions<CacheRow> = [
|
||||
{ id: "col1", header: "Col 1", label: "Col 1", cell: ({ col1 }) => col1 },
|
||||
{ id: "col2", header: "Col 2", label: "Col 2", cell: () => "Col 2" },
|
||||
{
|
||||
id: "col3",
|
||||
header: "Col 3",
|
||||
label: "Col 3",
|
||||
cell: ({ id }) => (id === 1 ? <input type="checkbox" /> : "Col 3"),
|
||||
},
|
||||
{ id: "col4", header: "Col 4", label: "Col 4", cell: () => "Col 4" },
|
||||
];
|
||||
|
||||
export const Cache: Story = {
|
||||
render: () => {
|
||||
const [showThatSingleRow, setShowThatSingleRow] = useState(true);
|
||||
|
||||
const data: CacheRow[] = [
|
||||
{ id: 1, col1: "Row 1" },
|
||||
...(showThatSingleRow ? [{ id: 2, col1: "Custom row" }] : []),
|
||||
{ id: 3, col1: "Row 3" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: "4rem", display: "grid", gap: "2rem" }}>
|
||||
<button onClick={() => setShowThatSingleRow((s) => !s)}>
|
||||
Toggle single row: {showThatSingleRow ? "ON" : "OFF"}
|
||||
</button>
|
||||
<DataTable style={{ width: "100%" }} layout="auto">
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Col 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 3</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 4</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Col 1</DataTable.Td>
|
||||
<DataTable.Td>Col 2</DataTable.Td>
|
||||
<DataTable.Td>
|
||||
<input type="checkbox" />
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>Col 4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
{showThatSingleRow && (
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Custom row</DataTable.Td>
|
||||
<DataTable.Td>Col 2</DataTable.Td>
|
||||
<DataTable.Td>Col 3</DataTable.Td>
|
||||
<DataTable.Td>Col 4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
)}
|
||||
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Col 1</DataTable.Td>
|
||||
<DataTable.Td>Col 2</DataTable.Td>
|
||||
<DataTable.Td>Col 3</DataTable.Td>
|
||||
<DataTable.Td>Col 4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
<DataTable
|
||||
withKeyboardNav
|
||||
layout="auto"
|
||||
columnDefinitions={cacheColumnDef}
|
||||
data={data}
|
||||
getRowId={(row) => row.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const { down, up, expectNodeFocus } = keyboardUtils();
|
||||
|
||||
const table = canvas.getByRole("table");
|
||||
const toggleButton = canvas.getByRole("button", {
|
||||
name: /toggle single row/i,
|
||||
});
|
||||
|
||||
table.focus();
|
||||
await down();
|
||||
expectNodeFocus("Row 1");
|
||||
await down();
|
||||
expectNodeFocus("Custom row");
|
||||
|
||||
await userEvent.click(toggleButton);
|
||||
table.focus();
|
||||
await down();
|
||||
expectNodeFocus("Row 1");
|
||||
await down();
|
||||
expectNodeFocus("Row 3");
|
||||
|
||||
await userEvent.click(toggleButton);
|
||||
table.focus();
|
||||
await down();
|
||||
expectNodeFocus("Row 3");
|
||||
await up();
|
||||
expectNodeFocus("Custom row");
|
||||
},
|
||||
};
|
||||
|
||||
export const FocusElementInsideTable: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ padding: "4rem", display: "grid", gap: "2rem" }}>
|
||||
<DataTable style={{ width: "100%" }} withKeyboardNav layout="auto">
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Col 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 3</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 4</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Col 1</DataTable.Td>
|
||||
<DataTable.Td>
|
||||
<button>Focusable button</button>
|
||||
</DataTable.Td>
|
||||
<DataTable.Th>Col 3</DataTable.Th>
|
||||
<DataTable.Td>Col 4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
type FocusRow = { id: number };
|
||||
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Col 1</DataTable.Td>
|
||||
<DataTable.Td>Col 2</DataTable.Td>
|
||||
<DataTable.Td>Col 3</DataTable.Td>
|
||||
<DataTable.Td>Col 4</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
</div>
|
||||
);
|
||||
const focusData: FocusRow[] = [{ id: 1 }, { id: 2 }];
|
||||
|
||||
const focusColumnDef: ColumnDefinitions<FocusRow> = [
|
||||
{ id: "col1", header: "Col 1", label: "Col 1", cell: () => "Col 1" },
|
||||
{
|
||||
id: "col2",
|
||||
header: "Col 2",
|
||||
label: "Col 2",
|
||||
cell: ({ id }) => (id === 1 ? <button>Focusable button</button> : "Col 2"),
|
||||
},
|
||||
{
|
||||
id: "col3",
|
||||
header: "Col 3",
|
||||
label: "Col 3",
|
||||
cell: () => "Col 3",
|
||||
isRowHeader: true,
|
||||
},
|
||||
{ id: "col4", header: "Col 4", label: "Col 4", cell: () => "Col 4" },
|
||||
];
|
||||
|
||||
export const FocusElementInsideTable: Story = {
|
||||
render: () => (
|
||||
<DataTable
|
||||
withKeyboardNav
|
||||
layout="auto"
|
||||
columnDefinitions={focusColumnDef}
|
||||
data={focusData}
|
||||
getRowId={(row) => row.id}
|
||||
/>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
@@ -515,29 +398,6 @@ export const FocusElementInsideTable: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
const TableBody = ({ table }: { table: Table<PersonInfo> }) => (
|
||||
<DataTable.Tbody>
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
return (
|
||||
<DataTable.Tr key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
return (
|
||||
<DataTable.Td key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataTable.Td>
|
||||
);
|
||||
})}
|
||||
</DataTable.Tr>
|
||||
);
|
||||
})}
|
||||
</DataTable.Tbody>
|
||||
);
|
||||
|
||||
const MemoizedTableBody = React.memo(
|
||||
TableBody,
|
||||
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn,
|
||||
) as typeof TableBody;
|
||||
|
||||
function keyboardUtils() {
|
||||
return {
|
||||
expectNodeFocus(text: string) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import { DataTable } from "../table";
|
||||
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
|
||||
import { DataTable } from "../table/root/DataTableRoot.legacy";
|
||||
import { sampleData } from "./dummy-data";
|
||||
|
||||
const meta: Meta<typeof DataTable> = {
|
||||
@@ -71,9 +71,15 @@ export const AutoLayoutMinimal: Story = {
|
||||
<DataTable layout="auto">
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Column 1">
|
||||
Column 1
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Column 2">
|
||||
Column 2
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Column 3">
|
||||
Column 3
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
@@ -101,7 +107,10 @@ export const AutoLayoutOverflowX: Story = {
|
||||
<DataTable.Tr>
|
||||
{columns.map((column) => {
|
||||
return (
|
||||
<DataTableColumnHeader key={column.header}>
|
||||
<DataTableColumnHeader
|
||||
key={column.header}
|
||||
label={column.header}
|
||||
>
|
||||
{column.header}
|
||||
</DataTableColumnHeader>
|
||||
);
|
||||
@@ -134,7 +143,7 @@ export const AutoLayoutNoCellTruncation: Story = {
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
{columns.map((column) => (
|
||||
<DataTableColumnHeader key={column.header}>
|
||||
<DataTableColumnHeader key={column.header} label={column.header}>
|
||||
{column.header}
|
||||
</DataTableColumnHeader>
|
||||
))}
|
||||
@@ -164,9 +173,15 @@ export const AutoLayoutSortable: Story = {
|
||||
<DataTable layout="auto">
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader sortable>Left</DataTableColumnHeader>
|
||||
<DataTableColumnHeader sortable>Center</DataTableColumnHeader>
|
||||
<DataTableColumnHeader sortable>Right</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Left" sortable>
|
||||
Left
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Center" sortable>
|
||||
Center
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Right" sortable>
|
||||
Right
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
@@ -196,9 +211,15 @@ export const FixedLayoutMinimal: Story = {
|
||||
<DataTable layout="fixed">
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Column 1">
|
||||
Column 1
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Column 2">
|
||||
Column 2
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Column 3">
|
||||
Column 3
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
@@ -224,9 +245,15 @@ export const FixedLayoutDynamicWidth: Story = {
|
||||
<DataTable layout="fixed">
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader width="100%">Column 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader width="100%">Column 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader width="100%">Column 3</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Column 1" defaultWidth="100%">
|
||||
Column 1
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Column 2" defaultWidth="100%">
|
||||
Column 2
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Column 3" defaultWidth="100%">
|
||||
Column 3
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import React, { useDeferredValue, useMemo, useState } from "react";
|
||||
import { Search } from "../../form/search";
|
||||
import { VStack } from "../../primitives/stack";
|
||||
import { DataTable } from "../table";
|
||||
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
|
||||
import { DataTable } from "../table/root/DataTableRoot.legacy";
|
||||
import { DataToolbar } from "../toolbar";
|
||||
import { PersonInfo, sampleData } from "./dummy-data";
|
||||
|
||||
@@ -128,6 +128,7 @@ export const WithoutTanstack: Story = {
|
||||
});
|
||||
}}
|
||||
isPinned={columnPinning.left.includes(column.accessorKey)} */
|
||||
label={column.header}
|
||||
>
|
||||
{column.header}
|
||||
</DataTableColumnHeader>
|
||||
|
||||
@@ -12,8 +12,8 @@ import React from "react";
|
||||
import { Search } from "../../form/search";
|
||||
import { Box } from "../../primitives/box";
|
||||
import { VStack } from "../../primitives/stack";
|
||||
import { DataTable } from "../table";
|
||||
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
|
||||
import { DataTable } from "../table/root/DataTableRoot.legacy";
|
||||
import { DataToolbar } from "../toolbar";
|
||||
import { PersonInfo, columns, sampleData } from "./dummy-data";
|
||||
|
||||
@@ -85,6 +85,9 @@ export const NoPageScroll: Story = {
|
||||
return (
|
||||
<DataTableColumnHeader
|
||||
key={header.id}
|
||||
label={(
|
||||
header.column.columnDef.header || ""
|
||||
).toString()}
|
||||
defaultWidth={header.getSize()}
|
||||
sortable
|
||||
sortDirection={header.column.getIsSorted() || "none"}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import { DataTable } from "../table";
|
||||
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
|
||||
import { DataTable } from "../table/root/DataTableRoot.legacy";
|
||||
|
||||
const meta: Meta<typeof DataTable> = {
|
||||
title: "ds-react/Data/Resize",
|
||||
@@ -21,11 +21,15 @@ export const Resize: Story = {
|
||||
<DataTable withKeyboardNav>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader textAlign="left">Left</DataTableColumnHeader>
|
||||
<DataTableColumnHeader textAlign="center">
|
||||
<DataTableColumnHeader label="Left" textAlign="left">
|
||||
Left
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Center" textAlign="center">
|
||||
Center
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader textAlign="right">Right</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Right" textAlign="right">
|
||||
Right
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
@@ -55,22 +59,25 @@ export const ResizeMinMax: Story = {
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader
|
||||
maxWidth="400px"
|
||||
minWidth="100px"
|
||||
label="Left"
|
||||
maxWidth={400}
|
||||
minWidth={100}
|
||||
textAlign="left"
|
||||
>
|
||||
Left
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader
|
||||
maxWidth="400px"
|
||||
minWidth="50px"
|
||||
label="Center"
|
||||
maxWidth={400}
|
||||
minWidth={50}
|
||||
textAlign="center"
|
||||
>
|
||||
Center
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader
|
||||
maxWidth="400px"
|
||||
minWidth="200px"
|
||||
label="Right"
|
||||
maxWidth={400}
|
||||
minWidth={200}
|
||||
textAlign="right"
|
||||
>
|
||||
Right
|
||||
@@ -103,13 +110,25 @@ export const ResizeDefaultStaticWidth: Story = {
|
||||
<DataTable withKeyboardNav>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader defaultWidth="300px" textAlign="left">
|
||||
<DataTableColumnHeader
|
||||
label="Left"
|
||||
defaultWidth="300px"
|
||||
textAlign="left"
|
||||
>
|
||||
Left
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader defaultWidth="300px" textAlign="center">
|
||||
<DataTableColumnHeader
|
||||
label="Center"
|
||||
defaultWidth="300px"
|
||||
textAlign="center"
|
||||
>
|
||||
Center
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader defaultWidth="300px" textAlign="right">
|
||||
<DataTableColumnHeader
|
||||
label="Right"
|
||||
defaultWidth="300px"
|
||||
textAlign="right"
|
||||
>
|
||||
Right
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
@@ -140,13 +159,25 @@ export const ResizeDefaultDynamicWidth: Story = {
|
||||
<DataTable withKeyboardNav>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader defaultWidth="100%" textAlign="left">
|
||||
<DataTableColumnHeader
|
||||
label="Left"
|
||||
defaultWidth="100%"
|
||||
textAlign="left"
|
||||
>
|
||||
Left
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader defaultWidth="100%" textAlign="center">
|
||||
<DataTableColumnHeader
|
||||
label="Center"
|
||||
defaultWidth="100%"
|
||||
textAlign="center"
|
||||
>
|
||||
Center
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader defaultWidth="100%" textAlign="right">
|
||||
<DataTableColumnHeader
|
||||
label="Right"
|
||||
defaultWidth="100%"
|
||||
textAlign="right"
|
||||
>
|
||||
Right
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import { DataTable } from "../table";
|
||||
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
|
||||
import { DataTable } from "../table/root/DataTableRoot.legacy";
|
||||
|
||||
const meta: Meta<typeof DataTable> = {
|
||||
title: "ds-react/Data/Settings",
|
||||
@@ -21,11 +21,13 @@ export const TextAlign: Story = {
|
||||
<DataTable layout="auto" withKeyboardNav>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Left</DataTableColumnHeader>
|
||||
<DataTableColumnHeader textAlign="center">
|
||||
<DataTableColumnHeader label="Left">Left</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Center" textAlign="center">
|
||||
Center
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader textAlign="right">Right</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Right" textAlign="right">
|
||||
Right
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
@@ -54,11 +56,13 @@ export const TextAlignSortable: Story = {
|
||||
<DataTable layout="auto" withKeyboardNav>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader sortable>Left</DataTableColumnHeader>
|
||||
<DataTableColumnHeader sortable textAlign="center">
|
||||
<DataTableColumnHeader label="Left" sortable>
|
||||
Left
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Center" sortable textAlign="center">
|
||||
Center
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader sortable textAlign="right">
|
||||
<DataTableColumnHeader label="Right" sortable textAlign="right">
|
||||
Right
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import { VStack } from "../../primitives/stack";
|
||||
import { DataTable } from "../table";
|
||||
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
|
||||
import { DataTable } from "../table/root/DataTableRoot.legacy";
|
||||
|
||||
const meta: Meta<typeof DataTable> = {
|
||||
title: "ds-react/Data/Spans",
|
||||
@@ -24,18 +24,26 @@ export const Spans: Story = {
|
||||
<DataTable>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader width="100%" colSpan={2}>
|
||||
<DataTableColumnHeader
|
||||
label="Group A"
|
||||
defaultWidth="100%"
|
||||
colSpan={2}
|
||||
>
|
||||
Group A
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader width="100%" colSpan={2}>
|
||||
<DataTableColumnHeader
|
||||
label="Group B"
|
||||
defaultWidth="100%"
|
||||
colSpan={2}
|
||||
>
|
||||
Group B
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Col 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 3</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Col 4</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Col 1">Col 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Col 2">Col 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Col 3">Col 3</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Col 4">Col 4</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
@@ -105,18 +113,36 @@ export const SpansSortable: Story = {
|
||||
<DataTable>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader width="100%" colSpan={2} sortable>
|
||||
<DataTableColumnHeader
|
||||
label="Group A"
|
||||
defaultWidth="100%"
|
||||
colSpan={2}
|
||||
sortable
|
||||
>
|
||||
Group A
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader width="100%" colSpan={2} sortable>
|
||||
<DataTableColumnHeader
|
||||
label="Group B"
|
||||
defaultWidth="100%"
|
||||
colSpan={2}
|
||||
sortable
|
||||
>
|
||||
Group B
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader sortable>Col 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader sortable>Col 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader sortable>Col 3</DataTableColumnHeader>
|
||||
<DataTableColumnHeader sortable>Col 4</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Col 1" sortable>
|
||||
Col 1
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Col 2" sortable>
|
||||
Col 2
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Col 3" sortable>
|
||||
Col 3
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Col 4" sortable>
|
||||
Col 4
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import { Button } from "../../button";
|
||||
import { DataTable } from "../table";
|
||||
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
|
||||
import { DataTable } from "../table/root/DataTableRoot.legacy";
|
||||
|
||||
const meta: Meta<typeof DataTable> = {
|
||||
title: "ds-react/Data/States",
|
||||
@@ -22,9 +22,15 @@ export const EmptyStateAuto: Story = {
|
||||
<DataTable layout="auto" withKeyboardNav>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Column 1">
|
||||
Column 1
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Column 2">
|
||||
Column 2
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Column 3">
|
||||
Column 3
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
@@ -42,9 +48,15 @@ export const EmptyStateFixed: Story = {
|
||||
<DataTable layout="fixed" withKeyboardNav>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Column 1">
|
||||
Column 1
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Column 2">
|
||||
Column 2
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Column 3">
|
||||
Column 3
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
@@ -62,9 +74,15 @@ export const LoadingStateAuto: Story = {
|
||||
<DataTable layout="auto" withKeyboardNav>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Column 1">
|
||||
Column 1
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Column 2">
|
||||
Column 2
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Column 3">
|
||||
Column 3
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
@@ -79,9 +97,15 @@ export const LoadingStateFixed: Story = {
|
||||
<DataTable layout="fixed" withKeyboardNav>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Column 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 2</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Column 3</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Column 1">
|
||||
Column 1
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Column 2">
|
||||
Column 2
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Column 3">
|
||||
Column 3
|
||||
</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
type RowSelectionState,
|
||||
Table,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
@@ -17,19 +16,30 @@ import {
|
||||
} from "@navikt/aksel-icons";
|
||||
import { Button } from "../../button";
|
||||
import { Dialog } from "../../dialog";
|
||||
import { Checkbox } from "../../form/checkbox";
|
||||
import { CheckboxInput } from "../../form/checkbox/checkbox-input/CheckboxInput";
|
||||
import { Radio, RadioGroup } from "../../form/radio";
|
||||
import { RadioInput } from "../../form/radio/radio-input/RadioInput";
|
||||
import { Search } from "../../form/search";
|
||||
import { Select } from "../../form/select";
|
||||
import { Switch } from "../../form/switch";
|
||||
import { HStack, VStack } from "../../primitives/stack";
|
||||
import { BodyShort } from "../../typography";
|
||||
import DataDragAndDrop from "../drag-and-drop-old/root/DataDragAndDropRoot";
|
||||
import { DataTable } from "../table";
|
||||
import { BodyShort, Heading } from "../../typography";
|
||||
import DragAndDropLegacy from "../drag-and-drop-legacy/root/DragAndDropLegacyRoot";
|
||||
import DragAndDrop from "../drag-and-drop/root/DragAndDropRoot";
|
||||
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
|
||||
import type { SelectionProps } from "../table/hooks/useTableSelection";
|
||||
import { DataTable } from "../table/root/DataTableRoot";
|
||||
import { DataTable as DataTableLegacy } from "../table/root/DataTableRoot.legacy";
|
||||
import { TokenFilter } from "../token-filter/TokenFilter";
|
||||
import type { ExternalQuery } from "../token-filter/TokenFilter.types";
|
||||
import { DataToolbar } from "../toolbar";
|
||||
import {
|
||||
TEST_DATA,
|
||||
TEST_DATA_NESTED,
|
||||
columnDef_TEST_DATA,
|
||||
columnDef_TEST_DATA_NESTED,
|
||||
} from "./Data.test-data";
|
||||
import {
|
||||
PersonInfo,
|
||||
columns,
|
||||
@@ -183,10 +193,10 @@ export const KitchenSink: Story = {
|
||||
>
|
||||
Velg alle
|
||||
</Switch>
|
||||
<DataDragAndDrop setItems={setColumnOrder}>
|
||||
<DragAndDropLegacy setItems={setColumnOrder}>
|
||||
{table.getAllLeafColumns().map((column, index) => {
|
||||
return (
|
||||
<DataDragAndDrop.Item
|
||||
<DragAndDropLegacy.Item
|
||||
id={column.id}
|
||||
index={index}
|
||||
key={column.id}
|
||||
@@ -203,10 +213,10 @@ export const KitchenSink: Story = {
|
||||
>
|
||||
{column.id}
|
||||
</Switch>
|
||||
</DataDragAndDrop.Item>
|
||||
</DragAndDropLegacy.Item>
|
||||
);
|
||||
})}
|
||||
</DataDragAndDrop>
|
||||
</DragAndDropLegacy>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
@@ -216,16 +226,16 @@ export const KitchenSink: Story = {
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
<DataTableLegacy
|
||||
rowDensity={rowDensity}
|
||||
zebraStripes={zebraStripes}
|
||||
truncateContent={truncateContent}
|
||||
withKeyboardNav
|
||||
>
|
||||
<DataTable.Thead>
|
||||
<DataTableLegacy.Thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => {
|
||||
return (
|
||||
<DataTable.Tr key={headerGroup.id}>
|
||||
<DataTableLegacy.Tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<DataTableColumnHeader
|
||||
@@ -245,6 +255,9 @@ export const KitchenSink: Story = {
|
||||
header.column.getToggleSortingHandler();
|
||||
handler?.(event);
|
||||
}}
|
||||
label={(
|
||||
header.column.columnDef.header || ""
|
||||
).toString()}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
@@ -255,32 +268,32 @@ export const KitchenSink: Story = {
|
||||
</DataTableColumnHeader>
|
||||
);
|
||||
})}
|
||||
</DataTable.Tr>
|
||||
</DataTableLegacy.Tr>
|
||||
);
|
||||
})}
|
||||
</DataTable.Thead>
|
||||
</DataTableLegacy.Thead>
|
||||
|
||||
<MemoizedTableBody table={table} />
|
||||
|
||||
{table.getRowModel().rows.length > 0 && (
|
||||
<DataTable.Tfoot>
|
||||
<DataTableLegacy.Tfoot>
|
||||
{table.getFooterGroups().map((footerGroup) => (
|
||||
<DataTable.Tr key={footerGroup.id}>
|
||||
<DataTableLegacy.Tr key={footerGroup.id}>
|
||||
{footerGroup.headers.map((header) => (
|
||||
<DataTable.Td key={header.id}>
|
||||
<DataTableLegacy.Td key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.footer,
|
||||
header.getContext(),
|
||||
)}
|
||||
</DataTable.Td>
|
||||
</DataTableLegacy.Td>
|
||||
))}
|
||||
</DataTable.Tr>
|
||||
</DataTableLegacy.Tr>
|
||||
))}
|
||||
</DataTable.Tfoot>
|
||||
</DataTableLegacy.Tfoot>
|
||||
)}
|
||||
</DataTable>
|
||||
</DataTableLegacy>
|
||||
</VStack>
|
||||
);
|
||||
},
|
||||
@@ -344,19 +357,19 @@ const filterOptions = [
|
||||
propertyKey: "homeSystem",
|
||||
value: v,
|
||||
})),
|
||||
...Array.from(new Set(sampleData.map((row) => row.supervisor))).map(
|
||||
...Array.from(new Set(TEST_DATA.map((row) => row.supervisor))).map(
|
||||
(value) => ({
|
||||
propertyKey: "supervisor",
|
||||
value,
|
||||
}),
|
||||
),
|
||||
...Array.from(new Set(sampleData.map((row) => row.nationalId))).map(
|
||||
...Array.from(new Set(TEST_DATA.map((row) => row.nationalId))).map(
|
||||
(value) => ({
|
||||
propertyKey: "nationalId",
|
||||
value,
|
||||
}),
|
||||
),
|
||||
...Array.from(new Set(sampleData.map((row) => row.name))).map((value) => ({
|
||||
...Array.from(new Set(TEST_DATA.map((row) => row.name))).map((value) => ({
|
||||
propertyKey: "name",
|
||||
value,
|
||||
})),
|
||||
@@ -369,28 +382,31 @@ export const KitchenSinkAdvancedFilter: Story = {
|
||||
>("normal");
|
||||
const [zebraStripes, setZebraStripes] = React.useState(false);
|
||||
const [truncateContent, setTruncateContent] = React.useState(true);
|
||||
const [columnOrder, setColumnOrder] = React.useState<string[]>(
|
||||
columns.map((col) => col.accessorKey!),
|
||||
);
|
||||
const [columnView, setColumnView] = React.useState(columnDef_TEST_DATA);
|
||||
const [stickyColumns, setStickyColumns] = React.useState<{
|
||||
first: "none" | "first";
|
||||
last: "none" | "last";
|
||||
}>({
|
||||
first: "none",
|
||||
last: "none",
|
||||
});
|
||||
const [showDetailsPanel, setShowDetailsPanel] = useState(false);
|
||||
const [showNestedRows, setNestedRows] = useState(false);
|
||||
|
||||
const [query, setQuery] = useState<ExternalQuery>({
|
||||
tokens: [],
|
||||
operation: "and",
|
||||
});
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [selectionMode, setSelectionMode] = useState<"single" | "multiple">(
|
||||
"multiple",
|
||||
);
|
||||
|
||||
console.log(rowSelection);
|
||||
const [selectionMode, setSelectionMode] =
|
||||
useState<SelectionProps["selectionMode"]>("none");
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (query.tokens.length === 0) {
|
||||
return sampleData;
|
||||
return showNestedRows ? TEST_DATA_NESTED : TEST_DATA;
|
||||
}
|
||||
|
||||
return sampleData.filter((row) => {
|
||||
return (showNestedRows ? TEST_DATA_NESTED : TEST_DATA).filter((row) => {
|
||||
const results = query.tokens.map((token) => {
|
||||
const value = row[token.propertyKey as keyof PersonInfo];
|
||||
if (token.operator === "=") {
|
||||
@@ -426,10 +442,10 @@ export const KitchenSinkAdvancedFilter: Story = {
|
||||
? results.every(Boolean)
|
||||
: results.some(Boolean);
|
||||
});
|
||||
}, [query]);
|
||||
}, [query.operation, query.tokens, showNestedRows]);
|
||||
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
/* const table = useReactTable({
|
||||
columns: [{}],
|
||||
data: filteredData,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
@@ -454,10 +470,12 @@ export const KitchenSinkAdvancedFilter: Story = {
|
||||
debugTable: false,
|
||||
debugHeaders: false,
|
||||
debugColumns: false,
|
||||
});
|
||||
}); */
|
||||
|
||||
const pagedData = usePaginatedData(filteredData);
|
||||
|
||||
return (
|
||||
<VStack gap="space-16">
|
||||
<VStack gap="space-16" maxHeight="100vh" padding="space-24">
|
||||
<DataToolbar
|
||||
renderInput={
|
||||
<TokenFilter
|
||||
@@ -474,19 +492,19 @@ export const KitchenSinkAdvancedFilter: Story = {
|
||||
variant="tertiary"
|
||||
icon={<ChevronLeftIcon aria-hidden />}
|
||||
data-color="neutral"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
onClick={() => pagedData.setPageIndex(pagedData.pageIndex - 1)}
|
||||
disabled={!pagedData.canPreviousPage}
|
||||
/>
|
||||
<BodyShort as="div" style={{ whiteSpace: "pre" }} size="small">
|
||||
{`Side ${table.getState().pagination.pageIndex + 1} av ${table.getPageCount()}`}
|
||||
{`Side ${pagedData.pageIndex + 1} av ${pagedData.pageCount}`}
|
||||
</BodyShort>
|
||||
<Button
|
||||
size="small"
|
||||
variant="tertiary"
|
||||
icon={<ChevronRightIcon aria-hidden />}
|
||||
data-color="neutral"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
onClick={() => pagedData.setPageIndex(pagedData.pageIndex + 1)}
|
||||
disabled={!pagedData.canNextPage}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
@@ -511,7 +529,7 @@ export const KitchenSinkAdvancedFilter: Story = {
|
||||
</Dialog.Header>
|
||||
<Dialog.Body>
|
||||
<HStack gap="space-32" paddingBlock="space-0 space-32">
|
||||
<VStack gap="space-36" maxWidth="50%">
|
||||
<VStack gap="space-24" maxWidth="50%">
|
||||
<RadioGroup
|
||||
legend="Velg radtetthet"
|
||||
onChange={setRowDensity}
|
||||
@@ -538,57 +556,127 @@ export const KitchenSinkAdvancedFilter: Story = {
|
||||
>
|
||||
Stripede rader
|
||||
</Switch>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={selectionMode === "multiple"}
|
||||
onChangeCapture={(e) =>
|
||||
setSelectionMode(
|
||||
e.target.checked ? "multiple" : "single",
|
||||
)
|
||||
<RadioGroup
|
||||
legend="Sticky første kolonne"
|
||||
onChange={(value) =>
|
||||
setStickyColumns((prev) => ({
|
||||
...prev,
|
||||
first: value as "none" | "first",
|
||||
}))
|
||||
}
|
||||
description="Slår på flervalg"
|
||||
size="small"
|
||||
value={stickyColumns.first}
|
||||
>
|
||||
Flervalg
|
||||
</Switch>
|
||||
<Radio value="none">Ingen</Radio>
|
||||
<Radio value="first">Første kolonne</Radio>
|
||||
</RadioGroup>
|
||||
<RadioGroup
|
||||
legend="Sticky siste kolonne"
|
||||
onChange={(value) =>
|
||||
setStickyColumns((prev) => ({
|
||||
...prev,
|
||||
last: value as "none" | "last",
|
||||
}))
|
||||
}
|
||||
size="small"
|
||||
value={stickyColumns.last}
|
||||
>
|
||||
<Radio value="none">Ingen</Radio>
|
||||
<Radio value="last">Siste kolonne</Radio>
|
||||
</RadioGroup>
|
||||
<VStack gap="space-8">
|
||||
<Heading size="xsmall" spacing>
|
||||
Demo innstillinger
|
||||
</Heading>
|
||||
<Select
|
||||
label="Radvalg"
|
||||
size="small"
|
||||
value={selectionMode}
|
||||
onChange={(e) =>
|
||||
setSelectionMode(
|
||||
e.target.value as SelectionProps["selectionMode"],
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="none">Ingen</option>
|
||||
<option value="single">Enkeltvalg</option>
|
||||
<option value="multiple">Flervalg</option>
|
||||
</Select>
|
||||
<Checkbox
|
||||
onChange={(e) =>
|
||||
setShowDetailsPanel(e.target.checked)
|
||||
}
|
||||
size="small"
|
||||
checked={showDetailsPanel}
|
||||
>
|
||||
Vis detaljer
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
onChange={(e) => {
|
||||
setNestedRows(e.target.checked);
|
||||
setColumnView(
|
||||
e.target.checked
|
||||
? columnDef_TEST_DATA_NESTED
|
||||
: columnDef_TEST_DATA,
|
||||
);
|
||||
}}
|
||||
size="small"
|
||||
checked={showNestedRows}
|
||||
>
|
||||
Vis nestede rader
|
||||
</Checkbox>
|
||||
</VStack>
|
||||
</VStack>
|
||||
<VStack maxWidth="50%" gap="space-16">
|
||||
<BodyShort weight="semibold">Vis kolonner</BodyShort>
|
||||
<VStack gap="space-8">
|
||||
<Switch
|
||||
checked={table.getIsAllColumnsVisible()}
|
||||
checked={columnView.every(
|
||||
(col) => col.details?.visible,
|
||||
)}
|
||||
size="small"
|
||||
onChange={(event) => {
|
||||
const handler =
|
||||
table.getToggleAllColumnsVisibilityHandler();
|
||||
handler?.(event);
|
||||
onChange={() => {
|
||||
const allVisible = columnView.every(
|
||||
(col) => col.details?.visible,
|
||||
);
|
||||
const newColumnView = columnView.map((col) => ({
|
||||
...col,
|
||||
details: {
|
||||
...col.details,
|
||||
visible: !allVisible,
|
||||
},
|
||||
}));
|
||||
setColumnView(newColumnView);
|
||||
}}
|
||||
>
|
||||
Velg alle
|
||||
</Switch>
|
||||
<DataDragAndDrop setItems={setColumnOrder}>
|
||||
{table.getAllLeafColumns().map((column, index) => {
|
||||
<DragAndDrop
|
||||
items={columnView}
|
||||
setItems={setColumnView}
|
||||
renderItem={(item, index) => {
|
||||
return (
|
||||
<DataDragAndDrop.Item
|
||||
id={column.id}
|
||||
index={index}
|
||||
key={column.id}
|
||||
<Switch
|
||||
key={item.id}
|
||||
size="small"
|
||||
checked={item.details?.visible}
|
||||
onChange={(event) => {
|
||||
const newColumnView = [...columnView];
|
||||
newColumnView[index] = {
|
||||
...item,
|
||||
details: {
|
||||
...item.details,
|
||||
visible: event.target.checked,
|
||||
},
|
||||
};
|
||||
setColumnView(newColumnView);
|
||||
}}
|
||||
>
|
||||
<Switch
|
||||
key={column.id}
|
||||
size="small"
|
||||
checked={column.getIsVisible()}
|
||||
onChange={(event) => {
|
||||
const handler =
|
||||
column.getToggleVisibilityHandler();
|
||||
handler(event);
|
||||
}}
|
||||
>
|
||||
{column.id}
|
||||
</Switch>
|
||||
</DataDragAndDrop.Item>
|
||||
{item.label}
|
||||
</Switch>
|
||||
);
|
||||
})}
|
||||
</DataDragAndDrop>
|
||||
}}
|
||||
/>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
@@ -599,84 +687,33 @@ export const KitchenSinkAdvancedFilter: Story = {
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
getRowId={(row) => row.name}
|
||||
columnDefinitions={columnView.filter((col) => col.details?.visible)}
|
||||
data={pagedData.paginatedData}
|
||||
rowDensity={rowDensity}
|
||||
zebraStripes={zebraStripes}
|
||||
truncateContent={truncateContent}
|
||||
withKeyboardNav
|
||||
>
|
||||
<DataTable.Thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => {
|
||||
return (
|
||||
<DataTable.Tr key={headerGroup.id}>
|
||||
{table.options.enableMultiRowSelection ? (
|
||||
<DataTableColumnHeader textAlign="center" width="64px">
|
||||
<CheckboxInput
|
||||
compact
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
indeterminate={table.getIsSomePageRowsSelected()}
|
||||
onChange={table.getToggleAllPageRowsSelectedHandler()}
|
||||
aria-label="Velg alle rader"
|
||||
/>
|
||||
</DataTableColumnHeader>
|
||||
) : (
|
||||
<DataTableColumnHeader textAlign="center" width="64px" />
|
||||
)}
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<DataTableColumnHeader
|
||||
key={header.id}
|
||||
style={{ width: `var(--header-${header.id}-size)` }}
|
||||
defaultWidth={header.getSize()}
|
||||
/* pinningHandler={
|
||||
header.column.getIsPinned() === "left"
|
||||
? () => header.column.pin(false)
|
||||
: () => header.column.pin("left")
|
||||
}
|
||||
isPinned={header.column.getIsPinned() === "left"} */
|
||||
sortable
|
||||
sortDirection={header.column.getIsSorted() || "none"}
|
||||
onSortClick={(event) => {
|
||||
const handler =
|
||||
header.column.getToggleSortingHandler();
|
||||
handler?.(event);
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</DataTableColumnHeader>
|
||||
);
|
||||
})}
|
||||
</DataTable.Tr>
|
||||
);
|
||||
})}
|
||||
</DataTable.Thead>
|
||||
|
||||
<MemoizedTableBody table={table} />
|
||||
|
||||
{table.getRowModel().rows.length > 0 && (
|
||||
<DataTable.Tfoot>
|
||||
{table.getFooterGroups().map((footerGroup) => (
|
||||
<DataTable.Tr key={footerGroup.id}>
|
||||
<DataTable.Td />
|
||||
{footerGroup.headers.map((header) => (
|
||||
<DataTable.Td key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.footer,
|
||||
header.getContext(),
|
||||
)}
|
||||
</DataTable.Td>
|
||||
))}
|
||||
</DataTable.Tr>
|
||||
))}
|
||||
</DataTable.Tfoot>
|
||||
)}
|
||||
</DataTable>
|
||||
selection={{
|
||||
selectionMode,
|
||||
}}
|
||||
stickyHeader
|
||||
stickyColumns={{
|
||||
first: stickyColumns.first === "first" ? "1" : undefined,
|
||||
last: stickyColumns.last === "last" ? "1" : undefined,
|
||||
}}
|
||||
detailsPanel={{
|
||||
getContent: showDetailsPanel
|
||||
? (rowData) => <DetailsPanel row={rowData} />
|
||||
: undefined,
|
||||
}}
|
||||
subRows={{
|
||||
/* @ts-expect-error Test-data just hacked together now */
|
||||
getRows: showNestedRows
|
||||
? (rowData) => rowData.nestedRows
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
},
|
||||
@@ -684,31 +721,69 @@ export const KitchenSinkAdvancedFilter: Story = {
|
||||
a11y: { disable: true },
|
||||
controls: { disable: true },
|
||||
docs: { disable: true },
|
||||
layout: "fullscreen",
|
||||
},
|
||||
};
|
||||
|
||||
function DetailsPanel({ row }: { row: PersonInfo }) {
|
||||
return (
|
||||
<VStack gap="space-16" padding="space-16">
|
||||
<Heading size="small">{row.name}</Heading>
|
||||
<BodyShort>National ID: {row.nationalId}</BodyShort>
|
||||
<BodyShort>Day job: {row.dayJob}</BodyShort>
|
||||
<BodyShort>Supervisor: {row.supervisor}</BodyShort>
|
||||
<BodyShort>Home system: {row.homeSystem}</BodyShort>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
function usePaginatedData<T extends any[]>(data: T) {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = pageIndex * pageSize;
|
||||
return data.slice(start, start + pageSize);
|
||||
}, [data, pageIndex]);
|
||||
|
||||
const pageCount = Math.ceil(data.length / pageSize);
|
||||
|
||||
const canNextPage = pageIndex < pageCount - 1;
|
||||
const canPreviousPage = pageIndex > 0;
|
||||
|
||||
return {
|
||||
paginatedData,
|
||||
pageIndex,
|
||||
setPageIndex,
|
||||
canNextPage,
|
||||
canPreviousPage,
|
||||
pageCount,
|
||||
};
|
||||
}
|
||||
|
||||
const TableBody = ({ table }: { table: Table<PersonInfo> }) => {
|
||||
const hasRowSelection = table.options.enableRowSelection;
|
||||
const multiRowSelection = table.options.enableMultiRowSelection;
|
||||
|
||||
if (table.getRowModel().rows.length === 0) {
|
||||
return (
|
||||
<DataTable.Tbody>
|
||||
<DataTable.EmptyState>
|
||||
<DataTableLegacy.Tbody>
|
||||
<DataTableLegacy.EmptyState>
|
||||
<div>No data available</div>
|
||||
<Button size="small">Create data</Button>
|
||||
</DataTable.EmptyState>
|
||||
</DataTable.Tbody>
|
||||
</DataTableLegacy.EmptyState>
|
||||
</DataTableLegacy.Tbody>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable.Tbody>
|
||||
<DataTableLegacy.Tbody>
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
return (
|
||||
<DataTable.Tr key={row.id} selected={row.getIsSelected()}>
|
||||
<DataTableLegacy.Tr key={row.id} selected={row.getIsSelected()}>
|
||||
{hasRowSelection && (
|
||||
<DataTable.Td textAlign="center" UNSAFE_isSelection>
|
||||
<DataTableLegacy.Td textAlign="center" UNSAFE_isSelection>
|
||||
{multiRowSelection ? (
|
||||
<CheckboxInput
|
||||
compact
|
||||
@@ -726,19 +801,19 @@ const TableBody = ({ table }: { table: Table<PersonInfo> }) => {
|
||||
aria-label={`Velg rad ${row.id}`}
|
||||
/>
|
||||
)}
|
||||
</DataTable.Td>
|
||||
</DataTableLegacy.Td>
|
||||
)}
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
return (
|
||||
<DataTable.Td key={cell.id}>
|
||||
<DataTableLegacy.Td key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataTable.Td>
|
||||
</DataTableLegacy.Td>
|
||||
);
|
||||
})}
|
||||
</DataTable.Tr>
|
||||
</DataTableLegacy.Tr>
|
||||
);
|
||||
})}
|
||||
</DataTable.Tbody>
|
||||
</DataTableLegacy.Tbody>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -4,7 +4,7 @@ import { DragVerticalIcon } from "@navikt/aksel-icons";
|
||||
import { Switch } from "../../form/switch";
|
||||
import { HStack } from "../../primitives/stack";
|
||||
import { Floating } from "../../utils/components/floating/Floating";
|
||||
import DragAndDrop from "../drag-and-drop-old/root/DataDragAndDropRoot";
|
||||
import DragAndDrop from "../drag-and-drop-legacy/root/DragAndDropLegacyRoot";
|
||||
|
||||
const meta: Meta<typeof DragAndDrop> = {
|
||||
title: "ds-react/Data/OldDragAndDrop",
|
||||
@@ -1,248 +0,0 @@
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import React, { useState } from "react";
|
||||
import { DataTable } from ".";
|
||||
import { Checkbox } from "../../form/checkbox";
|
||||
import { DataTableColumnHeader } from "./column-header/DataTableColumnHeader";
|
||||
|
||||
type SortDirection = "asc" | "desc" | "none";
|
||||
|
||||
const meta: Meta<typeof DataTable> = {
|
||||
title: "ds-react/Data/DataTable",
|
||||
component: DataTable,
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
};
|
||||
|
||||
type Story = StoryObj<typeof DataTable>;
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (props) => (
|
||||
<DataTable
|
||||
rowDensity={props.rowDensity}
|
||||
style={{ width: "500px" }}
|
||||
zebraStripes={props.zebraStripes}
|
||||
truncateContent={props.truncateContent}
|
||||
>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Header 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Header 2</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Data 1</DataTable.Td>
|
||||
<DataTable.Td>Data 2</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Data 1</DataTable.Td>
|
||||
<DataTable.Td>Data 2</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Data 1</DataTable.Td>
|
||||
<DataTable.Td>
|
||||
Very long content that should be truncated
|
||||
</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
),
|
||||
args: {
|
||||
rowDensity: "normal",
|
||||
zebraStripes: false,
|
||||
truncateContent: true,
|
||||
},
|
||||
argTypes: {
|
||||
rowDensity: {
|
||||
control: { type: "radio" },
|
||||
options: ["condensed", "normal", "spacious"],
|
||||
defaultValue: "normal",
|
||||
},
|
||||
zebraStripes: {
|
||||
control: { type: "boolean" },
|
||||
},
|
||||
truncateContent: {
|
||||
control: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Selected: Story = {
|
||||
render: () => {
|
||||
const useToggleList = (initialState) => {
|
||||
const [list, setList] = useState(initialState);
|
||||
|
||||
return [
|
||||
list,
|
||||
(value) =>
|
||||
setList((oldList) =>
|
||||
oldList.includes(value)
|
||||
? oldList.filter((id) => id !== value)
|
||||
: [...oldList, value],
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
const [selectedRows, toggleSelectedRow] = useToggleList(["2"]);
|
||||
|
||||
return (
|
||||
<DataTable style={{ width: "500px" }}>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>
|
||||
<Checkbox
|
||||
size="small"
|
||||
indeterminate={
|
||||
selectedRows.length > 0 && selectedRows.length < 3
|
||||
}
|
||||
onChange={() => {
|
||||
if (selectedRows.length === 3) {
|
||||
selectedRows.forEach((id) => {
|
||||
toggleSelectedRow(id);
|
||||
});
|
||||
} else {
|
||||
["1", "2", "3"].forEach((id) => {
|
||||
if (!selectedRows.includes(id)) {
|
||||
toggleSelectedRow(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Velg alle
|
||||
</Checkbox>
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Header 1</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Header 2</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr selected={selectedRows.includes("1")}>
|
||||
<DataTable.Td>
|
||||
<Checkbox
|
||||
hideLabel
|
||||
size="small"
|
||||
checked={selectedRows.includes("1")}
|
||||
onChange={() => toggleSelectedRow("1")}
|
||||
>
|
||||
{" "}
|
||||
</Checkbox>
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>Data 1</DataTable.Td>
|
||||
<DataTable.Td>Data 2</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr selected={selectedRows.includes("2")}>
|
||||
<DataTable.Td>
|
||||
<Checkbox
|
||||
hideLabel
|
||||
size="small"
|
||||
checked={selectedRows.includes("2")}
|
||||
onChange={() => toggleSelectedRow("2")}
|
||||
aria-labelledby="test"
|
||||
>
|
||||
{" "}
|
||||
</Checkbox>
|
||||
</DataTable.Td>
|
||||
<DataTable.Td id="test">Data 1</DataTable.Td>
|
||||
<DataTable.Td>Data 2</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
<DataTable.Tr selected={selectedRows.includes("3")}>
|
||||
<DataTable.Td>
|
||||
<Checkbox
|
||||
hideLabel
|
||||
size="small"
|
||||
checked={selectedRows.includes("3")}
|
||||
onChange={() => toggleSelectedRow("3")}
|
||||
>
|
||||
{" "}
|
||||
</Checkbox>
|
||||
</DataTable.Td>
|
||||
<DataTable.Td>Data 1</DataTable.Td>
|
||||
<DataTable.Td>Data 2</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const SORT_CYCLE: Record<SortDirection, SortDirection> = {
|
||||
none: "asc",
|
||||
asc: "desc",
|
||||
desc: "none",
|
||||
};
|
||||
|
||||
const DATA = [
|
||||
{ name: "Arne", age: 42, city: "Oslo" },
|
||||
{ name: "Bjørg", age: 31, city: "Bergen" },
|
||||
{ name: "Carl", age: 55, city: "Trondheim" },
|
||||
{ name: "Diana", age: 28, city: "Stavanger" },
|
||||
];
|
||||
|
||||
export const Sortable: Story = {
|
||||
render: () => {
|
||||
const [sortKey, setSortKey] = useState<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<SortDirection>("none");
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
if (sortKey === key) {
|
||||
const next = SORT_CYCLE[sortDir];
|
||||
setSortDir(next);
|
||||
if (next === "none") setSortKey(null);
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const sortedData = [...DATA].sort((a, b) => {
|
||||
if (!sortKey || sortDir === "none") return 0;
|
||||
const aVal = a[sortKey];
|
||||
const bVal = b[sortKey];
|
||||
const cmp =
|
||||
typeof aVal === "number"
|
||||
? aVal - bVal
|
||||
: String(aVal).localeCompare(String(bVal));
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
|
||||
const dirFor = (key: string): SortDirection =>
|
||||
sortKey === key ? sortDir : "none";
|
||||
|
||||
return (
|
||||
<DataTable style={{ width: "500px" }}>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader
|
||||
sortable
|
||||
sortDirection={dirFor("name")}
|
||||
onSortClick={() => handleSort("name")}
|
||||
>
|
||||
Navn
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader
|
||||
sortable
|
||||
sortDirection={dirFor("age")}
|
||||
onSortClick={() => handleSort("age")}
|
||||
>
|
||||
Alder
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>By</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
{sortedData.map((row) => (
|
||||
<DataTable.Tr key={row.name}>
|
||||
<DataTable.Td>{row.name}</DataTable.Td>
|
||||
<DataTable.Td>{row.age}</DataTable.Td>
|
||||
<DataTable.Td>{row.city}</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
))}
|
||||
</DataTable.Tbody>
|
||||
</DataTable>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -17,11 +17,10 @@ import { type ResizeProps, useTableColumnResize } from "./useTableColumnResize";
|
||||
|
||||
interface DataTableColumnHeaderProps
|
||||
extends ResizeProps, DataTableBaseCellProps {
|
||||
resizeHandler?: (
|
||||
event:
|
||||
| React.MouseEvent<HTMLButtonElement>
|
||||
| React.TouchEvent<HTMLButtonElement>,
|
||||
) => void;
|
||||
/**
|
||||
* Accessible name of the column.
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* Makes the column header sortable. The entire header cell content becomes
|
||||
* a clickable button when true.
|
||||
@@ -59,9 +58,11 @@ const DataTableColumnHeader = forwardRef<
|
||||
{
|
||||
className,
|
||||
children,
|
||||
label,
|
||||
sortable = false,
|
||||
sortDirection = "none",
|
||||
onSortClick,
|
||||
resizable = true,
|
||||
style,
|
||||
width,
|
||||
defaultWidth,
|
||||
@@ -81,6 +82,7 @@ const DataTableColumnHeader = forwardRef<
|
||||
const mergedRef = useMergeRefs(forwardedRef, thRef);
|
||||
|
||||
const resizeResult = useTableColumnResize({
|
||||
resizable,
|
||||
thRef,
|
||||
width,
|
||||
defaultWidth,
|
||||
@@ -109,6 +111,7 @@ const DataTableColumnHeader = forwardRef<
|
||||
>
|
||||
{sortable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="aksel-data-table__th-sort-button"
|
||||
onClick={onSortClick}
|
||||
>
|
||||
@@ -138,11 +141,12 @@ const DataTableColumnHeader = forwardRef<
|
||||
{resizeResult.enabled && !UNSAFE_isSelection && (
|
||||
<button
|
||||
{...resizeResult.resizeHandlerProps}
|
||||
type="button"
|
||||
className="aksel-data-table__th-resize-handle"
|
||||
aria-label={
|
||||
resizeResult.isResizingWithKeyboard
|
||||
? "Bruk pil venstre/høyre"
|
||||
: "Endre bredde"
|
||||
: `Endre bredde ${label}`
|
||||
} // TODO Translate
|
||||
data-active={resizeResult.isResizingWithKeyboard}
|
||||
data-disable-keyboard-nav={resizeResult.isResizingWithKeyboard}
|
||||
|
||||
@@ -8,19 +8,15 @@ import {
|
||||
import { useControllableState } from "../../../utils/hooks";
|
||||
import { useDataTableContext } from "../root/DataTableRoot.context";
|
||||
|
||||
type ColumnWidth = number | string;
|
||||
|
||||
type ResizeProps = {
|
||||
// If/when we add support for composition, consider mentioning that resizing only works on first row in thead.
|
||||
/**
|
||||
* Controlled width of the column.
|
||||
* Whether the column should be resizable by the user.
|
||||
*
|
||||
* Should only be used to fully control column width state. Otherwise, use `defaultWidth` and let the component handle resizing.
|
||||
* **NB:** This is always disabled when `layout="auto"` on the root component.
|
||||
* @default true
|
||||
*/
|
||||
width?: ColumnWidth;
|
||||
/**
|
||||
* Initial width of the column. Only used when `width` is not set.
|
||||
*/
|
||||
defaultWidth?: ColumnWidth;
|
||||
resizable?: boolean;
|
||||
/**
|
||||
* Whether the column should automatically resize to fit its content. **Runs only once.**
|
||||
*
|
||||
@@ -33,21 +29,36 @@ type ResizeProps = {
|
||||
*/
|
||||
autoWidth?: boolean;
|
||||
/**
|
||||
* Minimum width of the column.
|
||||
*
|
||||
* Should be used in conjunction with `width` or `defaultWidth` to set limits when resizing.
|
||||
* Minimum width of the column when resizing. Only used when `resizable` or `autoWidth` is enabled.
|
||||
* @default 40
|
||||
*/
|
||||
minWidth?: ColumnWidth;
|
||||
minWidth?: number;
|
||||
/**
|
||||
* Maximum width of the column.
|
||||
*
|
||||
* Should be used in conjunction with `width` or `defaultWidth` to set limits when resizing.
|
||||
* Maximum width of the column when resizing. Only used when `resizable` or `autoWidth` is enabled.
|
||||
*/
|
||||
maxWidth?: ColumnWidth;
|
||||
maxWidth?: number;
|
||||
// TODO: Consider "allowing" %-width on last column, if we find a solution to the overflow issue (width becomes 0px).
|
||||
/**
|
||||
* Controlled width of the column. Does not respect `minWidth` and `maxWidth`.
|
||||
*
|
||||
* Should only be used to fully control column width state. Otherwise, use `defaultWidth` and let the component handle resizing.
|
||||
*
|
||||
* **NB:** Percentage as initial width does not work well with resizing.
|
||||
*/
|
||||
width?: number | string;
|
||||
/**
|
||||
* Initial width of the column. Only used when `width` is not set and `resizable` is true.
|
||||
* Does not respect `minWidth` and `maxWidth`.
|
||||
*
|
||||
* **NB:** Percentage as initial width does not work well with resizing.
|
||||
* @default 140px
|
||||
*/
|
||||
defaultWidth?: number | string;
|
||||
/**
|
||||
* Called when the column width changes.
|
||||
* @param width New width in pixels.
|
||||
*/
|
||||
onWidthChange?: (width: ColumnWidth) => void;
|
||||
onWidthChange?: (width: number) => void;
|
||||
/**
|
||||
* Forwarded styles
|
||||
*/
|
||||
@@ -96,6 +107,7 @@ function useTableColumnResize(
|
||||
args: TableColumnResizeArgs,
|
||||
): TableColumnResizeResult {
|
||||
const {
|
||||
resizable,
|
||||
thRef,
|
||||
width: userWidth,
|
||||
defaultWidth,
|
||||
@@ -112,7 +124,7 @@ function useTableColumnResize(
|
||||
const [isResizingWithKeyboard, setIsResizingWithKeyboard] = useState(false);
|
||||
const ignoreNextOnClick = useRef(false);
|
||||
|
||||
const [width, _setWidth] = useControllableState({
|
||||
const [width, setWidth] = useControllableState({
|
||||
value: userWidth,
|
||||
defaultValue: defaultWidth ?? (colSpan ?? 1) * 140,
|
||||
/**
|
||||
@@ -123,14 +135,11 @@ function useTableColumnResize(
|
||||
onChange: onWidthChange,
|
||||
});
|
||||
|
||||
const setWidth = useCallback(
|
||||
const setClampedWidth = useCallback(
|
||||
(newWidth: number) => {
|
||||
const min = parseWidth(minWidth) ?? 0;
|
||||
const max = parseWidth(maxWidth) ?? Infinity;
|
||||
const clamped = Math.min(Math.max(newWidth, min), max);
|
||||
_setWidth(clamped);
|
||||
setWidth(Math.min(Math.max(newWidth, minWidth), maxWidth));
|
||||
},
|
||||
[minWidth, maxWidth, _setWidth],
|
||||
[minWidth, maxWidth, setWidth],
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: We only want to run this on mount and when autoWidth changes
|
||||
@@ -142,7 +151,7 @@ function useTableColumnResize(
|
||||
|
||||
const newColumnWidth = getAutoColumnWidth(thRef);
|
||||
if (newColumnWidth) {
|
||||
setWidth(newColumnWidth);
|
||||
setClampedWidth(newColumnWidth);
|
||||
}
|
||||
},
|
||||
[autoWidth], // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -173,19 +182,19 @@ function useTableColumnResize(
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
const delta = event.key === "ArrowRight" ? 20 : -20;
|
||||
setWidth(currentWidth + delta);
|
||||
setClampedWidth(currentWidth + delta);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
setWidth(0); // will fall back to minWidth
|
||||
setClampedWidth(0); // will fall back to minWidth
|
||||
return;
|
||||
}
|
||||
if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
const newWidth = getAutoColumnWidth(thRef);
|
||||
if (newWidth && newWidth > currentWidth) {
|
||||
setWidth(newWidth);
|
||||
setClampedWidth(newWidth);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -193,7 +202,7 @@ function useTableColumnResize(
|
||||
setIsResizingWithKeyboard(false);
|
||||
}
|
||||
},
|
||||
[isResizingWithKeyboard, setWidth, thRef],
|
||||
[isResizingWithKeyboard, setClampedWidth, thRef],
|
||||
);
|
||||
|
||||
const startResize = useCallback(
|
||||
@@ -204,19 +213,16 @@ function useTableColumnResize(
|
||||
const currentWidth = thRef.current?.offsetWidth ?? 0;
|
||||
const newWidth = startWidth + (clientX - startX);
|
||||
|
||||
const min = parseWidth(minWidth) ?? 0;
|
||||
const max = parseWidth(maxWidth) ?? Infinity;
|
||||
|
||||
if (newWidth > max) {
|
||||
if (newWidth > maxWidth) {
|
||||
setWidth(newWidth < currentWidth ? newWidth : currentWidth);
|
||||
return;
|
||||
}
|
||||
if (newWidth < min) {
|
||||
if (newWidth < minWidth) {
|
||||
setWidth(newWidth > currentWidth ? newWidth : currentWidth);
|
||||
return;
|
||||
}
|
||||
|
||||
setWidth(newWidth);
|
||||
setClampedWidth(newWidth);
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
@@ -244,7 +250,7 @@ function useTableColumnResize(
|
||||
document.addEventListener("touchend", cleanup, { once: true });
|
||||
document.addEventListener("touchcancel", cleanup, { once: true });
|
||||
},
|
||||
[maxWidth, minWidth, setWidth, thRef],
|
||||
[maxWidth, minWidth, setWidth, setClampedWidth, thRef],
|
||||
);
|
||||
|
||||
const handleMouseDown: DOMAttributes<HTMLButtonElement>["onMouseDown"] =
|
||||
@@ -268,9 +274,9 @@ function useTableColumnResize(
|
||||
useCallback(() => {
|
||||
const newColumnWidth = getAutoColumnWidth(thRef);
|
||||
if (newColumnWidth) {
|
||||
setWidth(newColumnWidth);
|
||||
setClampedWidth(newColumnWidth);
|
||||
}
|
||||
}, [setWidth, thRef]);
|
||||
}, [setClampedWidth, thRef]);
|
||||
|
||||
if (tableContext.layout !== "fixed") {
|
||||
return {
|
||||
@@ -279,6 +285,16 @@ function useTableColumnResize(
|
||||
};
|
||||
}
|
||||
|
||||
if (!resizable) {
|
||||
return {
|
||||
style: {
|
||||
...style,
|
||||
width,
|
||||
},
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
style: {
|
||||
...style,
|
||||
@@ -297,20 +313,6 @@ function useTableColumnResize(
|
||||
};
|
||||
}
|
||||
|
||||
function parseWidth(width: ColumnWidth | undefined): number | undefined {
|
||||
if (width == null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof width === "number") {
|
||||
return width;
|
||||
}
|
||||
if (typeof width === "string") {
|
||||
const parsed = parseInt(width, 10);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getAutoColumnWidth(
|
||||
thRef: React.RefObject<HTMLTableCellElement | null>,
|
||||
) {
|
||||
@@ -323,7 +325,9 @@ function getAutoColumnWidth(
|
||||
}
|
||||
|
||||
// Find needed width for header cell
|
||||
const contentWidth = thContent.scrollWidth;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(thContent);
|
||||
const contentWidth = range.getBoundingClientRect().width;
|
||||
const paddingElStyle = window.getComputedStyle(thPaddingEl);
|
||||
const thInlinePadding =
|
||||
parseInt(paddingElStyle.paddingLeft, 10) +
|
||||
@@ -339,7 +343,6 @@ function getAutoColumnWidth(
|
||||
}
|
||||
|
||||
// Find needed width for each cell in column in tbody and tfoot
|
||||
const range = document.createRange();
|
||||
let skipRows = 0;
|
||||
for (const row of rows) {
|
||||
// Skip rows where the cell in this column is covered by a rowspan from a previous row
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from "react";
|
||||
import {
|
||||
getDataTableDetailsPanelId,
|
||||
useDataTableDetailsPanel,
|
||||
} from "../hooks/useTableDetailsPanel";
|
||||
import { useDataTableContext } from "../root/DataTableRoot.context";
|
||||
|
||||
function DataTableDetailsPanelRow<T>({
|
||||
rowId,
|
||||
rowData,
|
||||
}: {
|
||||
rowId: string | number;
|
||||
rowData: T;
|
||||
}) {
|
||||
const { tableId, fullWidthColSpan } = useDataTableContext();
|
||||
const {
|
||||
enableDetailsPanel,
|
||||
isExpanded,
|
||||
getDetailsPanelContent,
|
||||
getDetailsPanelHeight,
|
||||
} = useDataTableDetailsPanel();
|
||||
|
||||
if (!enableDetailsPanel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isExpanded(rowId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = getDetailsPanelContent?.(rowData);
|
||||
const expansionId = getDataTableDetailsPanelId(tableId, rowId);
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const panelHeight = getDetailsPanelHeight?.(rowData);
|
||||
|
||||
const style: React.CSSProperties = panelHeight
|
||||
? { height: panelHeight, overflow: "auto" }
|
||||
: { height: "auto" };
|
||||
|
||||
return (
|
||||
<tr className="aksel-data-table__details-panel-row">
|
||||
<td
|
||||
id={expansionId}
|
||||
colSpan={fullWidthColSpan}
|
||||
className="aksel-data-table__details-panel-row-cell"
|
||||
>
|
||||
<div style={style}>{content}</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export { DataTableDetailsPanelRow };
|
||||
@@ -3,19 +3,21 @@ type TableRowEntryId = string | number;
|
||||
type CollectTableRowEntriesArgs<T> = {
|
||||
items: T[];
|
||||
getRowId?: (rowData: T, index: number) => TableRowEntryId;
|
||||
getSubRows?: (rowData: T) => T[];
|
||||
isSubRowExpandable?: (rowData: T) => boolean;
|
||||
getRows?: (rowData: T) => T[];
|
||||
isRowExpandable?: (rowData: T) => boolean;
|
||||
};
|
||||
|
||||
interface ItemDetail<T> {
|
||||
id: string | number;
|
||||
id: TableRowEntryId;
|
||||
rowData: T;
|
||||
level: number;
|
||||
parent: null | T;
|
||||
children: readonly T[];
|
||||
parentId: TableRowEntryId | null;
|
||||
children: readonly TableRowEntryId[];
|
||||
}
|
||||
|
||||
type CollectTableRowEntriesReturn<T> = {
|
||||
itemDetails: Map<T, ItemDetail<T>>;
|
||||
itemDetails: Map<TableRowEntryId, ItemDetail<T>>;
|
||||
rootRowIds: TableRowEntryId[];
|
||||
/**
|
||||
* Direct child ids for each row, used to traverse nested selection groups
|
||||
* without storing every descendant list on each ancestor.
|
||||
@@ -26,61 +28,65 @@ type CollectTableRowEntriesReturn<T> = {
|
||||
function collectTableRowEntries<T>({
|
||||
items,
|
||||
getRowId,
|
||||
getSubRows,
|
||||
isSubRowExpandable,
|
||||
getRows,
|
||||
isRowExpandable,
|
||||
}: CollectTableRowEntriesArgs<T>): CollectTableRowEntriesReturn<T> {
|
||||
const itemDetailsMap = new Map<T, ItemDetail<T>>();
|
||||
const itemDetailsMap = new Map<TableRowEntryId, ItemDetail<T>>();
|
||||
const childRowIdsById = new Map<TableRowEntryId, TableRowEntryId[]>();
|
||||
const rootRowIds: TableRowEntryId[] = [];
|
||||
|
||||
const traverseRow = (
|
||||
rowData: T,
|
||||
rowIndex: number,
|
||||
level: number,
|
||||
parent: T | null,
|
||||
parentId?: TableRowEntryId,
|
||||
parentRowId: TableRowEntryId | null,
|
||||
): TableRowEntryId => {
|
||||
const rowId =
|
||||
getRowId?.(rowData, rowIndex) ??
|
||||
(parentId == null ? rowIndex : `${parentId}-${rowIndex}`);
|
||||
const isRowExpandable = isSubRowExpandable?.(rowData) ?? true;
|
||||
const children = (isRowExpandable ? getSubRows?.(rowData) : []) ?? [];
|
||||
const rowId = getRowId
|
||||
? getRowId(rowData, rowIndex)
|
||||
: getFallbackTableRowId(rowIndex, parentRowId);
|
||||
|
||||
itemDetailsMap.set(rowData, {
|
||||
id: rowId,
|
||||
level,
|
||||
parent,
|
||||
children,
|
||||
});
|
||||
const children =
|
||||
((isRowExpandable?.(rowData) ?? true) ? getRows?.(rowData) : []) ?? [];
|
||||
|
||||
const childRowIds: TableRowEntryId[] = [];
|
||||
|
||||
for (let childIndex = 0; childIndex < children.length; childIndex++) {
|
||||
const childRow = children[childIndex];
|
||||
const childRowId = traverseRow(
|
||||
childRow,
|
||||
childIndex,
|
||||
level + 1,
|
||||
rowData,
|
||||
rowId,
|
||||
);
|
||||
const childRowId = traverseRow(childRow, childIndex, level + 1, rowId);
|
||||
childRowIds.push(childRowId);
|
||||
}
|
||||
|
||||
itemDetailsMap.set(rowId, {
|
||||
id: rowId,
|
||||
rowData,
|
||||
level,
|
||||
parentId: parentRowId,
|
||||
children: childRowIds,
|
||||
});
|
||||
|
||||
childRowIdsById.set(rowId, childRowIds);
|
||||
|
||||
return rowId;
|
||||
};
|
||||
|
||||
for (let rowIndex = 0; rowIndex < items.length; rowIndex++) {
|
||||
traverseRow(items[rowIndex], rowIndex, 0, null);
|
||||
rootRowIds.push(traverseRow(items[rowIndex], rowIndex, 0, null));
|
||||
}
|
||||
|
||||
return {
|
||||
itemDetails: itemDetailsMap,
|
||||
rootRowIds,
|
||||
childRowIdsById,
|
||||
};
|
||||
}
|
||||
|
||||
function getFallbackTableRowId(
|
||||
rowIndex: number,
|
||||
parentRowId: TableRowEntryId | null,
|
||||
): string {
|
||||
return parentRowId == null ? String(rowIndex) : `${parentRowId}.${rowIndex}`;
|
||||
}
|
||||
|
||||
export { collectTableRowEntries };
|
||||
export type {
|
||||
CollectTableRowEntriesArgs,
|
||||
|
||||
@@ -121,16 +121,70 @@ function prepareCellFocus(cell: Element): HTMLElement | null {
|
||||
/**
|
||||
* Applies focus and scroll to an element.
|
||||
*/
|
||||
function applyFocusAndScroll(element: HTMLElement): void {
|
||||
element.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
function getStickyOffsets(element: HTMLElement): {
|
||||
stickyOffsetStart: number;
|
||||
stickyOffsetEnd: number;
|
||||
stickyHeaderHeight: number;
|
||||
} {
|
||||
const table = element.closest(".aksel-data-table");
|
||||
|
||||
element.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
});
|
||||
if (!table) {
|
||||
return {
|
||||
stickyOffsetStart: 0,
|
||||
stickyOffsetEnd: 0,
|
||||
stickyHeaderHeight: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const stickyHeader = table.querySelector<HTMLElement>(
|
||||
`.aksel-data-table__tr[data-sticky="true"]`,
|
||||
);
|
||||
|
||||
const stickyNodesStart = table.querySelectorAll<HTMLElement>(
|
||||
`.aksel-data-table__column-header[data-sticky="start"]`,
|
||||
);
|
||||
|
||||
const stickyNodesEnd = table.querySelectorAll<HTMLElement>(
|
||||
`.aksel-data-table__column-header[data-sticky="end"]`,
|
||||
);
|
||||
|
||||
return {
|
||||
stickyOffsetStart: Array.from(stickyNodesStart).reduce(
|
||||
(offset, node) => offset + node.getBoundingClientRect().width,
|
||||
0,
|
||||
),
|
||||
stickyOffsetEnd: Array.from(stickyNodesEnd).reduce(
|
||||
(offset, node) => offset + node.getBoundingClientRect().width,
|
||||
0,
|
||||
),
|
||||
stickyHeaderHeight: stickyHeader?.getBoundingClientRect().height ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function applyFocusAndScroll(element: HTMLElement): void {
|
||||
const { stickyOffsetStart, stickyOffsetEnd, stickyHeaderHeight } =
|
||||
getStickyOffsets(element);
|
||||
|
||||
const originalScrollMarginInline = element.style.scrollMarginInline;
|
||||
const originalScrollMarginBlockStart = element.style.scrollMarginBlockStart;
|
||||
|
||||
element.style.scrollMarginInline = `${stickyOffsetStart}px ${stickyOffsetEnd}px`;
|
||||
element.style.scrollMarginBlockStart = `${stickyHeaderHeight}px`;
|
||||
|
||||
try {
|
||||
element.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
|
||||
element.scrollIntoView({
|
||||
behavior: "auto",
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
});
|
||||
} finally {
|
||||
element.style.scrollMarginBlockStart = originalScrollMarginBlockStart;
|
||||
element.style.scrollMarginInline = originalScrollMarginInline;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,8 +16,7 @@ type NavigationAction =
|
||||
|
||||
/**
|
||||
* Maps keyboard events to navigation actions.
|
||||
* Supports arrow keys, Home/End (row navigation), Ctrl/Cmd+Home/End (table navigation),
|
||||
* and PageUp/PageDown (multi-row navigation).
|
||||
* Supports arrow keys, Home/End (row navigation), Ctrl/Cmd+Home/End (table navigation).
|
||||
*/
|
||||
function getNavigationAction(event: KeyboardEvent): NavigationAction | null {
|
||||
const key = event.key;
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
DataTableExpansionProvider,
|
||||
useDataTableExpansion,
|
||||
} from "../useTableExpansion";
|
||||
import { useTableItems } from "../useTableItems";
|
||||
|
||||
type TestRow = {
|
||||
id: number;
|
||||
subRows?: TestRow[];
|
||||
};
|
||||
|
||||
function createWrapper(
|
||||
options: {
|
||||
onDetailsPanelChange?: (ids: (string | number)[]) => void;
|
||||
isDetailsPanelExpandable?: (row: TestRow) => boolean;
|
||||
} = {},
|
||||
) {
|
||||
const rows: TestRow[] = [{ id: 1, subRows: [{ id: 10 }] }, { id: 2 }];
|
||||
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const tableItems = useTableItems({
|
||||
items: rows,
|
||||
getRowId: (row) => row.id,
|
||||
getSubRows: (row) => row.subRows ?? [],
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTableExpansionProvider<TestRow>
|
||||
itemDetails={tableItems.itemDetails}
|
||||
getDetailsPanelContent={(row) => row.id}
|
||||
isDetailsPanelExpandable={options.isDetailsPanelExpandable}
|
||||
onDetailsPanelChange={options.onDetailsPanelChange}
|
||||
>
|
||||
{children}
|
||||
</DataTableExpansionProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe("useTableExpansion", () => {
|
||||
test("does not allow toggling rows that are not expandable", () => {
|
||||
const onDetailsPanelChange = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useDataTableExpansion(), {
|
||||
wrapper: createWrapper({
|
||||
onDetailsPanelChange,
|
||||
isDetailsPanelExpandable: (row) => row.id === 1,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.current.isDetailsPanelExpandable(1)).toBe(true);
|
||||
expect(result.current.isDetailsPanelExpandable(2)).toBe(false);
|
||||
expect(result.current.isDetailsPanelExpandable(10)).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleExpansion(2);
|
||||
});
|
||||
|
||||
expect(result.current.isExpanded(2)).toBe(false);
|
||||
expect(onDetailsPanelChange).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
result.current.toggleExpansion(1);
|
||||
});
|
||||
|
||||
expect(result.current.isExpanded(1)).toBe(true);
|
||||
expect(onDetailsPanelChange).toHaveBeenCalledWith([1]);
|
||||
});
|
||||
|
||||
test("expand all only expands expandable rows", () => {
|
||||
const onDetailsPanelChange = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useDataTableExpansion(), {
|
||||
wrapper: createWrapper({
|
||||
onDetailsPanelChange,
|
||||
isDetailsPanelExpandable: (row) => row.id === 1,
|
||||
}),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.toggleAll();
|
||||
});
|
||||
|
||||
expect(result.current.isExpanded(1)).toBe(true);
|
||||
expect(result.current.isExpanded(2)).toBe(false);
|
||||
expect(result.current.isAllExpanded).toBe(true);
|
||||
expect(onDetailsPanelChange).toHaveBeenCalledWith([1]);
|
||||
});
|
||||
|
||||
test("expand all only targets top-level table items", () => {
|
||||
const onDetailsPanelChange = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useDataTableExpansion(), {
|
||||
wrapper: createWrapper({
|
||||
onDetailsPanelChange,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.current.isDetailsPanelExpandable(1)).toBe(true);
|
||||
expect(result.current.isDetailsPanelExpandable(2)).toBe(true);
|
||||
expect(result.current.isDetailsPanelExpandable(10)).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleAll();
|
||||
});
|
||||
|
||||
expect(result.current.isExpanded(1)).toBe(true);
|
||||
expect(result.current.isExpanded(2)).toBe(true);
|
||||
expect(result.current.isExpanded(10)).toBe(false);
|
||||
expect(onDetailsPanelChange).toHaveBeenCalledWith([1, 2]);
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,12 @@ const fallbackRows: FallbackTestRow[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const duplicatedRowObject: TestRow = {
|
||||
id: "shared",
|
||||
name: "Shared",
|
||||
subRows: [{ id: "shared-child", name: "Child" }],
|
||||
};
|
||||
|
||||
const getSubRows = (row: TestRow) => row.subRows ?? [];
|
||||
|
||||
const getVisibleIds = (rows: TestRow[]) => rows.map((row) => row.id);
|
||||
@@ -59,16 +65,18 @@ describe("useTableItems", () => {
|
||||
);
|
||||
|
||||
expect(getVisibleIds(result.current.items)).toEqual(["a", "b"]);
|
||||
expect(result.current.itemDetails.get(plainRows[0])).toMatchObject({
|
||||
expect(result.current.itemDetails.get("a")).toMatchObject({
|
||||
id: "a",
|
||||
rowData: plainRows[0],
|
||||
level: 0,
|
||||
parent: null,
|
||||
parentId: null,
|
||||
children: [],
|
||||
});
|
||||
expect(result.current.itemDetails.get(plainRows[1])).toMatchObject({
|
||||
expect(result.current.itemDetails.get("b")).toMatchObject({
|
||||
id: "b",
|
||||
rowData: plainRows[1],
|
||||
level: 0,
|
||||
parent: null,
|
||||
parentId: null,
|
||||
children: [],
|
||||
});
|
||||
});
|
||||
@@ -78,8 +86,10 @@ describe("useTableItems", () => {
|
||||
useTableItems({
|
||||
items: nestedRows,
|
||||
getRowId: (row) => row.id,
|
||||
getSubRows,
|
||||
defaultExpandedSubRowIds: ["a"],
|
||||
subRows: {
|
||||
getRows: getSubRows,
|
||||
defaultExpandedRowIds: ["a"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -91,7 +101,7 @@ describe("useTableItems", () => {
|
||||
useTableItems({
|
||||
items: nestedRows,
|
||||
getRowId: (row) => row.id,
|
||||
getSubRows,
|
||||
subRows: { getRows: getSubRows },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -100,12 +110,14 @@ describe("useTableItems", () => {
|
||||
expect(result.current.childRowIdsById.get("b")).toEqual(["b1"]);
|
||||
});
|
||||
|
||||
test("uses the same fallback root id to reveal child rows when getRowId is omitted", () => {
|
||||
test("uses unique fallback ids to reveal child rows when getRowId is omitted", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableItems({
|
||||
items: fallbackRows,
|
||||
getSubRows: (row) => row.subRows ?? [],
|
||||
defaultExpandedSubRowIds: [0],
|
||||
subRows: {
|
||||
getRows: (row: any) => row.subRows ?? [],
|
||||
defaultExpandedRowIds: ["0"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -113,6 +125,7 @@ describe("useTableItems", () => {
|
||||
"Parent",
|
||||
"Child",
|
||||
]);
|
||||
expect(result.current.childRowIdsById.get("0")).toEqual(["0.0"]);
|
||||
});
|
||||
|
||||
test("updates visible rows in depth-first order for controlled expanded ids", () => {
|
||||
@@ -121,8 +134,10 @@ describe("useTableItems", () => {
|
||||
useTableItems({
|
||||
items: nestedRows,
|
||||
getRowId: (row) => row.id,
|
||||
getSubRows,
|
||||
expandedSubRowIds: expandedIds,
|
||||
subRows: {
|
||||
getRows: getSubRows,
|
||||
expandedRowIds: expandedIds,
|
||||
},
|
||||
}),
|
||||
{
|
||||
initialProps: { expandedIds: [] as (string | number)[] },
|
||||
@@ -142,4 +157,35 @@ describe("useTableItems", () => {
|
||||
"b1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("tracks duplicated row objects by row id instead of object identity", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableItems({
|
||||
items: [duplicatedRowObject, duplicatedRowObject],
|
||||
subRows: {
|
||||
getRows: getSubRows,
|
||||
defaultExpandedRowIds: ["0"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleRowIds).toEqual(["0", "0.0", "1"]);
|
||||
expect(getVisibleIds(result.current.items)).toEqual([
|
||||
"shared",
|
||||
"shared-child",
|
||||
"shared",
|
||||
]);
|
||||
expect(result.current.itemDetails.get("0")).toMatchObject({
|
||||
id: "0",
|
||||
rowData: duplicatedRowObject,
|
||||
parentId: null,
|
||||
children: ["0.0"],
|
||||
});
|
||||
expect(result.current.itemDetails.get("1")).toMatchObject({
|
||||
id: "1",
|
||||
rowData: duplicatedRowObject,
|
||||
parentId: null,
|
||||
children: ["1.0"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,7 @@ describe("useTableSelection", () => {
|
||||
test("returns empty selectedKeys and no prop getters", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "none",
|
||||
selection: { selectionMode: "none" },
|
||||
visibleRowIds,
|
||||
}),
|
||||
);
|
||||
@@ -56,7 +56,7 @@ describe("useTableSelection", () => {
|
||||
test("returns getRowRadioProps", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "single",
|
||||
selection: { selectionMode: "single" },
|
||||
visibleRowIds,
|
||||
}),
|
||||
);
|
||||
@@ -69,9 +69,8 @@ describe("useTableSelection", () => {
|
||||
const onChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "single",
|
||||
selection: { selectionMode: "single", onSelectionChange: onChange },
|
||||
visibleRowIds,
|
||||
onSelectionChange: onChange,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -88,9 +87,8 @@ describe("useTableSelection", () => {
|
||||
test("toggling the same row keeps it selected", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "single",
|
||||
selection: { selectionMode: "single", defaultSelectedKeys: ["a"] },
|
||||
visibleRowIds,
|
||||
defaultSelectedKeys: ["a"],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -106,9 +104,8 @@ describe("useTableSelection", () => {
|
||||
test("selecting a new row replaces the previous", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "single",
|
||||
selection: { selectionMode: "single", defaultSelectedKeys: ["a"] },
|
||||
visibleRowIds,
|
||||
defaultSelectedKeys: ["a"],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -124,9 +121,8 @@ describe("useTableSelection", () => {
|
||||
test("disabled rows have disabled prop", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "single",
|
||||
selection: { selectionMode: "single", disabledSelectionKeys: ["b"] },
|
||||
visibleRowIds,
|
||||
disabledSelectionKeys: ["b"],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -138,9 +134,8 @@ describe("useTableSelection", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ selectedKeys }) =>
|
||||
useTableSelection({
|
||||
selectionMode: "single",
|
||||
selection: { selectionMode: "single", selectedKeys },
|
||||
visibleRowIds,
|
||||
selectedKeys,
|
||||
}),
|
||||
{ initialProps: { selectedKeys: ["a"] as (string | number)[] } },
|
||||
);
|
||||
@@ -156,7 +151,7 @@ describe("useTableSelection", () => {
|
||||
test("returns getTheadCheckboxProps and getRowCheckboxProps", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
selection: { selectionMode: "multiple" },
|
||||
visibleRowIds,
|
||||
}),
|
||||
);
|
||||
@@ -169,7 +164,7 @@ describe("useTableSelection", () => {
|
||||
test("selecting individual rows", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
selection: { selectionMode: "multiple" },
|
||||
visibleRowIds,
|
||||
}),
|
||||
);
|
||||
@@ -194,9 +189,11 @@ describe("useTableSelection", () => {
|
||||
test("deselecting a row", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
selection: {
|
||||
selectionMode: "multiple",
|
||||
defaultSelectedKeys: ["a", "b"],
|
||||
},
|
||||
visibleRowIds,
|
||||
defaultSelectedKeys: ["a", "b"],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -212,7 +209,7 @@ describe("useTableSelection", () => {
|
||||
test("select all via thead checkbox", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
selection: { selectionMode: "multiple" },
|
||||
visibleRowIds,
|
||||
}),
|
||||
);
|
||||
@@ -229,7 +226,7 @@ describe("useTableSelection", () => {
|
||||
test("select all via thead includes hidden descendants for visible parents", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
selection: { selectionMode: "multiple" },
|
||||
visibleRowIds: ["a"],
|
||||
childRowIdsById,
|
||||
}),
|
||||
@@ -247,9 +244,11 @@ describe("useTableSelection", () => {
|
||||
test("deselect all when all are selected", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
selection: {
|
||||
selectionMode: "multiple",
|
||||
defaultSelectedKeys: ["a", "b", "c"],
|
||||
},
|
||||
visibleRowIds,
|
||||
defaultSelectedKeys: ["a", "b", "c"],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -265,10 +264,12 @@ describe("useTableSelection", () => {
|
||||
test("deselect all clears hidden descendants for visible parents but preserves unrelated keys", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
selection: {
|
||||
selectionMode: "multiple",
|
||||
defaultSelectedKeys: ["a", "a1", "a2", "a2a", "external"],
|
||||
},
|
||||
visibleRowIds: ["a"],
|
||||
childRowIdsById,
|
||||
defaultSelectedKeys: ["a", "a1", "a2", "a2a", "external"],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -284,9 +285,11 @@ describe("useTableSelection", () => {
|
||||
test("select all skips disabled keys", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
selection: {
|
||||
selectionMode: "multiple",
|
||||
disabledSelectionKeys: ["b"],
|
||||
},
|
||||
visibleRowIds,
|
||||
disabledSelectionKeys: ["b"],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -302,10 +305,12 @@ describe("useTableSelection", () => {
|
||||
test("deselect all preserves disabled-but-selected rows", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
selection: {
|
||||
selectionMode: "multiple",
|
||||
defaultSelectedKeys: ["a", "b", "c"],
|
||||
disabledSelectionKeys: ["b"],
|
||||
},
|
||||
visibleRowIds,
|
||||
defaultSelectedKeys: ["a", "b", "c"],
|
||||
disabledSelectionKeys: ["b"],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -321,9 +326,8 @@ describe("useTableSelection", () => {
|
||||
test("thead checkbox shows indeterminate when partially selected", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
selection: { selectionMode: "multiple", defaultSelectedKeys: ["a"] },
|
||||
visibleRowIds,
|
||||
defaultSelectedKeys: ["a"],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -335,9 +339,11 @@ describe("useTableSelection", () => {
|
||||
test("thead checkbox shows checked when all selected", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
selection: {
|
||||
selectionMode: "multiple",
|
||||
defaultSelectedKeys: ["a", "b", "c"],
|
||||
},
|
||||
visibleRowIds,
|
||||
defaultSelectedKeys: ["a", "b", "c"],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -349,10 +355,12 @@ describe("useTableSelection", () => {
|
||||
test("thead checkbox shows checked when all selectable rows are selected", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
selection: {
|
||||
selectionMode: "multiple",
|
||||
defaultSelectedKeys: ["a", "c"],
|
||||
disabledSelectionKeys: ["b"],
|
||||
},
|
||||
visibleRowIds,
|
||||
defaultSelectedKeys: ["a", "c"],
|
||||
disabledSelectionKeys: ["b"],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -364,9 +372,11 @@ describe("useTableSelection", () => {
|
||||
test("deselecting one row when all rows are selected", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
selection: {
|
||||
selectionMode: "multiple",
|
||||
defaultSelectedKeys: ["a", "b", "c"],
|
||||
},
|
||||
visibleRowIds,
|
||||
defaultSelectedKeys: ["a", "b", "c"],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -382,9 +392,11 @@ describe("useTableSelection", () => {
|
||||
test("disabled rows have disabled prop", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
selection: {
|
||||
selectionMode: "multiple",
|
||||
disabledSelectionKeys: ["b"],
|
||||
},
|
||||
visibleRowIds,
|
||||
disabledSelectionKeys: ["b"],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -395,9 +407,11 @@ describe("useTableSelection", () => {
|
||||
test("thead checkbox disabled when all rows disabled", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
selection: {
|
||||
selectionMode: "multiple",
|
||||
disabledSelectionKeys: ["a", "b", "c"],
|
||||
},
|
||||
visibleRowIds,
|
||||
disabledSelectionKeys: ["a", "b", "c"],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -407,10 +421,9 @@ describe("useTableSelection", () => {
|
||||
test("parent rows show indeterminate when visible descendants are partially selected", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
selection: { selectionMode: "multiple", defaultSelectedKeys: ["a1"] },
|
||||
visibleRowIds: ["a", "a1", "a2"],
|
||||
childRowIdsById,
|
||||
defaultSelectedKeys: ["a1"],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -423,7 +436,7 @@ describe("useTableSelection", () => {
|
||||
test("toggling a parent row selects and deselects its descendants", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
selection: { selectionMode: "multiple" },
|
||||
visibleRowIds: ["a", "a1", "a2"],
|
||||
childRowIdsById,
|
||||
}),
|
||||
@@ -449,7 +462,7 @@ describe("useTableSelection", () => {
|
||||
test("toggling a collapsed parent selects and deselects hidden descendants", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableSelection({
|
||||
selectionMode: "multiple",
|
||||
selection: { selectionMode: "multiple" },
|
||||
visibleRowIds: ["a"],
|
||||
childRowIdsById,
|
||||
}),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
ColumnDefinition,
|
||||
ColumnDefinitions,
|
||||
@@ -28,19 +29,32 @@ function useColumnOptions<T>(
|
||||
|
||||
const hasSelection = selectionMode !== "none";
|
||||
|
||||
return {
|
||||
stickySelection: selectionMode !== "none" && stickyColumns?.first === "1",
|
||||
columns: columnDefinitions.map((colDef, index) => {
|
||||
const columns = useMemo(() => {
|
||||
return columnDefinitions.map((colDef, index) => {
|
||||
const isFirstSticky =
|
||||
stickyColumns?.first === "1" && index === 0 && !hasSelection;
|
||||
const isLastSticky =
|
||||
stickyColumns?.last === "1" && index === columnDefinitions.length - 1;
|
||||
|
||||
return {
|
||||
isSticky: isFirstSticky ? "start" : isLastSticky ? "end" : false,
|
||||
isSticky: isFirstSticky
|
||||
? ("start" as const)
|
||||
: isLastSticky
|
||||
? ("end" as const)
|
||||
: (false as const),
|
||||
colDef,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}, [
|
||||
columnDefinitions,
|
||||
hasSelection,
|
||||
stickyColumns?.first,
|
||||
stickyColumns?.last,
|
||||
]);
|
||||
|
||||
return {
|
||||
stickySelection: selectionMode !== "none" && stickyColumns?.first === "1",
|
||||
columns,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
|
||||
});
|
||||
|
||||
const [activeCell, setActiveCell] = useState<Element | null>(null);
|
||||
const activeCellRef = useValueAsRef(activeCell).current;
|
||||
const activeCellRef = useValueAsRef(activeCell);
|
||||
const observerRef = useRef<MutationObserver | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -24,7 +24,8 @@ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
|
||||
|
||||
observerRef.current = new MutationObserver(() => {
|
||||
gridCacheRef.current.dirty = true;
|
||||
if (activeCellRef && !activeCellRef.isConnected) {
|
||||
|
||||
if (activeCellRef.current && !activeCellRef.current.isConnected) {
|
||||
setActiveCell(null);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { createStrictContext } from "../../../utils/helpers";
|
||||
import { useControllableState } from "../../../utils/hooks";
|
||||
import { useTableItemsContext } from "./useTableItems";
|
||||
|
||||
type DetailsPanelProps<T> = {
|
||||
/**
|
||||
* Renders a details panel below the row when expanded.
|
||||
* When provided, an expand toggle column is added automatically.
|
||||
*/
|
||||
getContent?: (rowData: T) => React.ReactNode;
|
||||
/**
|
||||
* Determines whether a row can be expanded to show details panel content.
|
||||
* @default () => true
|
||||
*/
|
||||
isRowExpandable?: (rowData: T) => boolean;
|
||||
/**
|
||||
* Controlled list of expanded row IDs.
|
||||
* Use with `onDetailsPanelChange` for controlled usage, or `defaultDetailsPanelRowIds` for uncontrolled.
|
||||
*/
|
||||
expandedRowIds?: (string | number)[];
|
||||
/**
|
||||
* Initial list of expanded row IDs for uncontrolled usage.
|
||||
* @default []
|
||||
*/
|
||||
defaultExpandedRowIds?: (string | number)[];
|
||||
/**
|
||||
* Called when the list of expanded row IDs changes.
|
||||
*
|
||||
*
|
||||
* TODO:
|
||||
* - Docs: This pattern is called "Master / Detail" in general terms
|
||||
*/
|
||||
onExpandedRowIdsChange?: (ids: (string | number)[]) => void;
|
||||
/**
|
||||
* Returns the height (in px) or `"auto"` for a row's details panel.
|
||||
* When a number is returned, the panel scrolls within that fixed height.
|
||||
* @default "auto"
|
||||
*/
|
||||
getHeight?: (rowData: T) => number | "auto";
|
||||
/**
|
||||
* Shows an expand-all toggle button in the expand column header.
|
||||
* @default false
|
||||
*/
|
||||
showExpandAll?: boolean;
|
||||
};
|
||||
|
||||
type DataTableDetailsPanelContextT = {
|
||||
isExpanded: (id: string | number) => boolean;
|
||||
isDetailsPanelExpandable: (id: string | number) => boolean;
|
||||
toggleExpansion: (id: string | number) => void;
|
||||
toggleAll: () => void;
|
||||
isAllExpanded: boolean;
|
||||
getDetailsPanelContent?: (row: unknown) => React.ReactNode;
|
||||
getDetailsPanelHeight?: (row: unknown) => number | "auto";
|
||||
showExpandAll: boolean;
|
||||
enableDetailsPanel: boolean;
|
||||
};
|
||||
|
||||
const {
|
||||
Provider: DataTableDetailsPanelContextProvider,
|
||||
useContext: useDataTableDetailsPanel,
|
||||
} = createStrictContext<DataTableDetailsPanelContextT>({
|
||||
name: "DataTableDetailsPanelContext",
|
||||
errorMessage:
|
||||
"useDataTableDetailsPanel must be used within a DataTableDetailsPanelProvider.",
|
||||
});
|
||||
|
||||
function DataTableDetailsPanelProvider<T>({
|
||||
children,
|
||||
detailsPanel = {},
|
||||
}: { detailsPanel?: DetailsPanelProps<T> } & { children: React.ReactNode }) {
|
||||
const {
|
||||
expandedRowIds,
|
||||
defaultExpandedRowIds = [],
|
||||
onExpandedRowIdsChange,
|
||||
getContent,
|
||||
isRowExpandable,
|
||||
getHeight,
|
||||
showExpandAll = false,
|
||||
} = detailsPanel;
|
||||
|
||||
const [expandedIds, setExpandedIds] = useControllableState({
|
||||
value: expandedRowIds,
|
||||
defaultValue: defaultExpandedRowIds,
|
||||
onChange: onExpandedRowIdsChange,
|
||||
});
|
||||
|
||||
/* TODO: False is just fallback until auto and root is merged */
|
||||
const tableItemsContext = useTableItemsContext(false);
|
||||
|
||||
const { itemDetails } = tableItemsContext ?? {
|
||||
itemDetails: new Map<
|
||||
string | number,
|
||||
{ rowData: T; id: string | number; level: number }
|
||||
>(),
|
||||
};
|
||||
|
||||
const expandableIds = React.useMemo(() => {
|
||||
if (!getContent) {
|
||||
return new Set<string | number>();
|
||||
}
|
||||
|
||||
const ids = new Set<string | number>();
|
||||
|
||||
for (const { rowData, id, level } of itemDetails.values()) {
|
||||
/* We only allow Master - Details pattern on top level rows */
|
||||
if (level > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isRowExpandable || isRowExpandable(rowData)) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}, [getContent, isRowExpandable, itemDetails]);
|
||||
|
||||
const isDetailsPanelExpandableById = useCallback(
|
||||
(id: string | number) => expandableIds.has(id),
|
||||
[expandableIds],
|
||||
);
|
||||
|
||||
const isExpanded = useCallback(
|
||||
(id: string | number) =>
|
||||
isDetailsPanelExpandableById(id) && expandedIds.includes(id),
|
||||
[expandedIds, isDetailsPanelExpandableById],
|
||||
);
|
||||
|
||||
const toggleExpansion = useCallback(
|
||||
(id: string | number) => {
|
||||
if (!isDetailsPanelExpandableById(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedIds((currentExpandedIds) =>
|
||||
currentExpandedIds.includes(id)
|
||||
? currentExpandedIds.filter((expandedId) => expandedId !== id)
|
||||
: [...currentExpandedIds, id],
|
||||
);
|
||||
},
|
||||
[isDetailsPanelExpandableById, setExpandedIds],
|
||||
);
|
||||
|
||||
const isAllExpanded =
|
||||
expandableIds.size > 0 &&
|
||||
Array.from(expandableIds).every((key) => expandedIds.includes(key));
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
setExpandedIds(isAllExpanded ? [] : Array.from(expandableIds));
|
||||
}, [expandableIds, isAllExpanded, setExpandedIds]);
|
||||
|
||||
return (
|
||||
<DataTableDetailsPanelContextProvider
|
||||
isExpanded={isExpanded}
|
||||
isDetailsPanelExpandable={isDetailsPanelExpandableById}
|
||||
toggleExpansion={toggleExpansion}
|
||||
toggleAll={toggleAll}
|
||||
isAllExpanded={isAllExpanded}
|
||||
getDetailsPanelContent={
|
||||
getContent as ((row: unknown) => React.ReactNode) | undefined
|
||||
}
|
||||
getDetailsPanelHeight={
|
||||
getHeight as ((row: unknown) => number | "auto") | undefined
|
||||
}
|
||||
showExpandAll={showExpandAll}
|
||||
enableDetailsPanel={!!getContent}
|
||||
>
|
||||
{children}
|
||||
</DataTableDetailsPanelContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function getDataTableDetailsPanelId(tableId: string, rowId: string | number) {
|
||||
return `${tableId}-expansion-${rowId}`;
|
||||
}
|
||||
|
||||
export {
|
||||
DataTableDetailsPanelProvider,
|
||||
getDataTableDetailsPanelId,
|
||||
useDataTableDetailsPanel,
|
||||
};
|
||||
|
||||
export type { DetailsPanelProps };
|
||||
@@ -1,146 +0,0 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { createStrictContext } from "../../../utils/helpers";
|
||||
import { useControllableState } from "../../../utils/hooks";
|
||||
import { useTableItemsContext } from "./useTableItems";
|
||||
|
||||
type DataTableExpansionContextT = {
|
||||
isExpanded: (id: string | number) => boolean;
|
||||
isDetailsPanelExpandable: (id: string | number) => boolean;
|
||||
toggleExpansion: (id: string | number) => void;
|
||||
toggleAll: () => void;
|
||||
isAllExpanded: boolean;
|
||||
getDetailsPanelContent?: (row: unknown) => React.ReactNode;
|
||||
getDetailsPanelHeight?: (row: unknown) => number | "auto";
|
||||
showExpandAll: boolean;
|
||||
enableDetailsPanel: boolean;
|
||||
};
|
||||
|
||||
const {
|
||||
Provider: DataTableExpansionContextProvider,
|
||||
useContext: useDataTableExpansion,
|
||||
} = createStrictContext<DataTableExpansionContextT>({
|
||||
name: "DataTableExpansionContext",
|
||||
errorMessage:
|
||||
"useDataTableExpansion must be used within a DataTableExpansionProvider.",
|
||||
});
|
||||
|
||||
type TableExpansionOptions<T> = {
|
||||
detailsPanelRowIds?: (string | number)[];
|
||||
defaultDetailsPanelRowIds?: (string | number)[];
|
||||
onDetailsPanelChange?: (ids: (string | number)[]) => void;
|
||||
getDetailsPanelContent?: (row: T) => React.ReactNode;
|
||||
isDetailsPanelExpandable?: (rowData: T) => boolean;
|
||||
getDetailsPanelHeight?: (row: T) => number | "auto";
|
||||
showExpandAll?: boolean;
|
||||
};
|
||||
|
||||
function getDataTableExpansionId(tableId: string, rowId: string | number) {
|
||||
return `${tableId}-expansion-${rowId}`;
|
||||
}
|
||||
|
||||
function DataTableExpansionProvider<T>({
|
||||
children,
|
||||
detailsPanelRowIds,
|
||||
defaultDetailsPanelRowIds = [],
|
||||
onDetailsPanelChange,
|
||||
getDetailsPanelContent,
|
||||
isDetailsPanelExpandable,
|
||||
getDetailsPanelHeight,
|
||||
showExpandAll = false,
|
||||
}: TableExpansionOptions<T> & { children: React.ReactNode }) {
|
||||
const [expandedIds, setExpandedIds] = useControllableState({
|
||||
value: detailsPanelRowIds,
|
||||
defaultValue: defaultDetailsPanelRowIds,
|
||||
onChange: onDetailsPanelChange,
|
||||
});
|
||||
|
||||
/* TODO: False is just fallback until auto and root is merged */
|
||||
const tableItemsContext = useTableItemsContext(false);
|
||||
|
||||
const { itemDetails } = tableItemsContext ?? {
|
||||
itemDetails: new Map(),
|
||||
};
|
||||
|
||||
const expandableIds = React.useMemo(() => {
|
||||
if (!getDetailsPanelContent) {
|
||||
return new Set<string | number>();
|
||||
}
|
||||
|
||||
const ids = new Set<string | number>();
|
||||
|
||||
for (const [rowData, { id, level }] of itemDetails.entries()) {
|
||||
/* We only allow Master - Details pattern on top level rows */
|
||||
if (level > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isDetailsPanelExpandable || isDetailsPanelExpandable(rowData)) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}, [getDetailsPanelContent, isDetailsPanelExpandable, itemDetails]);
|
||||
|
||||
const isDetailsPanelExpandableById = useCallback(
|
||||
(id: string | number) => expandableIds.has(id),
|
||||
[expandableIds],
|
||||
);
|
||||
|
||||
const isExpanded = useCallback(
|
||||
(id: string | number) =>
|
||||
isDetailsPanelExpandableById(id) && expandedIds.includes(id),
|
||||
[expandedIds, isDetailsPanelExpandableById],
|
||||
);
|
||||
|
||||
const toggleExpansion = useCallback(
|
||||
(id: string | number) => {
|
||||
if (!isDetailsPanelExpandableById(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedIds((currentExpandedIds) =>
|
||||
currentExpandedIds.includes(id)
|
||||
? currentExpandedIds.filter((expandedId) => expandedId !== id)
|
||||
: [...currentExpandedIds, id],
|
||||
);
|
||||
},
|
||||
[isDetailsPanelExpandableById, setExpandedIds],
|
||||
);
|
||||
|
||||
const isAllExpanded =
|
||||
expandableIds.size > 0 &&
|
||||
Array.from(expandableIds).every((key) => expandedIds.includes(key));
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
setExpandedIds(isAllExpanded ? [] : Array.from(expandableIds));
|
||||
}, [expandableIds, isAllExpanded, setExpandedIds]);
|
||||
|
||||
return (
|
||||
<DataTableExpansionContextProvider
|
||||
isExpanded={isExpanded}
|
||||
isDetailsPanelExpandable={isDetailsPanelExpandableById}
|
||||
toggleExpansion={toggleExpansion}
|
||||
toggleAll={toggleAll}
|
||||
isAllExpanded={isAllExpanded}
|
||||
getDetailsPanelContent={
|
||||
getDetailsPanelContent as
|
||||
| ((row: unknown) => React.ReactNode)
|
||||
| undefined
|
||||
}
|
||||
getDetailsPanelHeight={
|
||||
getDetailsPanelHeight as ((row: unknown) => number | "auto") | undefined
|
||||
}
|
||||
showExpandAll={showExpandAll}
|
||||
enableDetailsPanel={!!getDetailsPanelContent}
|
||||
>
|
||||
{children}
|
||||
</DataTableExpansionContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DataTableExpansionProvider,
|
||||
getDataTableExpansionId,
|
||||
useDataTableExpansion,
|
||||
};
|
||||
@@ -7,56 +7,47 @@ import {
|
||||
collectTableRowEntries,
|
||||
} from "../helpers/collectTableRowEntries";
|
||||
|
||||
type SubRowsProps<T> = {
|
||||
getRows?: (rowData: T) => T[];
|
||||
expandedRowIds?: (string | number)[];
|
||||
defaultExpandedRowIds?: (string | number)[];
|
||||
isRowExpandable?: (rowData: T) => boolean;
|
||||
onExpandedRowIdsChange?: (ids: (string | number)[]) => void;
|
||||
};
|
||||
|
||||
type UseTableItemsArgs<T> = {
|
||||
items: T[];
|
||||
getRowId?: (rowData: T, index: number) => string | number;
|
||||
/**
|
||||
* Master - Detail pattern props
|
||||
*/
|
||||
/* expandedDetailsPanelIds?: (string | number)[];
|
||||
defaultExpandedDetailsPanelIds?: (string | number)[];
|
||||
isDetailsPanelExpandable?: (rowData: T) => boolean;
|
||||
onDetailsPanelChange?: (ids: (string | number)[]) => void;
|
||||
|
||||
getDetailsPanelHeight?: (row: T) => number | "auto";
|
||||
getDetailsPanelContent?: (row: T) => React.ReactNode; */
|
||||
/**
|
||||
* Expanded/Nested rows pattern props
|
||||
*/
|
||||
getSubRows?: (rowData: T) => T[];
|
||||
expandedSubRowIds?: (string | number)[];
|
||||
defaultExpandedSubRowIds?: (string | number)[];
|
||||
isSubRowExpandable?: (rowData: T) => boolean;
|
||||
onExpandedSubRowIdsChange?: (ids: (string | number)[]) => void;
|
||||
getRowId?: (rowData: T) => TableRowEntryId;
|
||||
subRows?: SubRowsProps<T>;
|
||||
};
|
||||
|
||||
type useTableItemsReturn<T> = {
|
||||
items: T[];
|
||||
itemDetails: Map<T, ItemDetail<T>>;
|
||||
itemDetails: Map<TableRowEntryId, ItemDetail<T>>;
|
||||
/** Row ids for the rows currently rendered in the table body. */
|
||||
visibleRowIds: TableRowEntryId[];
|
||||
/** Direct child ids for each row, used to traverse selection groups lazily. */
|
||||
childRowIdsById: Map<TableRowEntryId, TableRowEntryId[]>;
|
||||
onExpandedSubRowIdsChange: (id: string | number) => void;
|
||||
onExpandedRowIdsChange: (id: string | number) => void;
|
||||
isSubRowExpanded: (id: string | number) => boolean;
|
||||
};
|
||||
|
||||
function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
|
||||
const { items, subRows = {}, getRowId } = args;
|
||||
|
||||
const {
|
||||
items,
|
||||
expandedSubRowIds,
|
||||
defaultExpandedSubRowIds,
|
||||
getSubRows,
|
||||
getRowId,
|
||||
onExpandedSubRowIdsChange,
|
||||
isSubRowExpandable,
|
||||
} = args;
|
||||
expandedRowIds,
|
||||
defaultExpandedRowIds,
|
||||
getRows,
|
||||
onExpandedRowIdsChange,
|
||||
isRowExpandable,
|
||||
} = subRows;
|
||||
|
||||
const [nestedSubRowsExpandedIds, setNestedSubRowsExpandedIds] =
|
||||
useControllableState({
|
||||
value: expandedSubRowIds,
|
||||
defaultValue: defaultExpandedSubRowIds ?? [],
|
||||
onChange: onExpandedSubRowIdsChange,
|
||||
value: expandedRowIds,
|
||||
defaultValue: defaultExpandedRowIds ?? [],
|
||||
onChange: onExpandedRowIdsChange,
|
||||
});
|
||||
|
||||
const expandedIdsSet = useMemo(
|
||||
@@ -66,32 +57,35 @@ function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
|
||||
|
||||
const { itemDetails, visibleItems, visibleRowIds, childRowIdsById } =
|
||||
useMemo(() => {
|
||||
const { itemDetails: rowEntriesMap, childRowIdsById: _childRowIdsById } =
|
||||
collectTableRowEntries({
|
||||
items,
|
||||
getRowId,
|
||||
getSubRows,
|
||||
isSubRowExpandable,
|
||||
});
|
||||
const {
|
||||
itemDetails: rowEntriesMap,
|
||||
rootRowIds,
|
||||
childRowIdsById: _childRowIdsById,
|
||||
} = collectTableRowEntries({
|
||||
items,
|
||||
getRowId,
|
||||
getRows,
|
||||
isRowExpandable,
|
||||
});
|
||||
|
||||
const localVisibleItems: T[] = [];
|
||||
const localVisibleRowIds: TableRowEntryId[] = [];
|
||||
|
||||
const addVisibleRows = (rowData: T): TableRowEntryId[] => {
|
||||
const details = rowEntriesMap.get(rowData);
|
||||
const addVisibleRows = (rowId: TableRowEntryId): TableRowEntryId[] => {
|
||||
const details = rowEntriesMap.get(rowId);
|
||||
|
||||
if (!details) {
|
||||
return [];
|
||||
}
|
||||
|
||||
localVisibleItems.push(rowData);
|
||||
localVisibleItems.push(details.rowData);
|
||||
localVisibleRowIds.push(details.id);
|
||||
|
||||
const visibleDescendantRowIds: TableRowEntryId[] = [];
|
||||
|
||||
if (expandedIdsSet.has(details.id)) {
|
||||
for (const childRow of details.children) {
|
||||
const childVisibleRowIds = addVisibleRows(childRow);
|
||||
for (const childRowId of details.children) {
|
||||
const childVisibleRowIds = addVisibleRows(childRowId);
|
||||
visibleDescendantRowIds.push(...childVisibleRowIds);
|
||||
}
|
||||
}
|
||||
@@ -99,8 +93,8 @@ function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
|
||||
return [details.id, ...visibleDescendantRowIds];
|
||||
};
|
||||
|
||||
for (const rowData of items) {
|
||||
addVisibleRows(rowData);
|
||||
for (const rowId of rootRowIds) {
|
||||
addVisibleRows(rowId);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -109,7 +103,7 @@ function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
|
||||
childRowIdsById: _childRowIdsById,
|
||||
itemDetails: rowEntriesMap,
|
||||
};
|
||||
}, [getSubRows, items, getRowId, isSubRowExpandable, expandedIdsSet]);
|
||||
}, [getRows, items, getRowId, isRowExpandable, expandedIdsSet]);
|
||||
|
||||
const handleExpandedSubRowIdChange = useCallback(
|
||||
(id: string | number) => {
|
||||
@@ -127,20 +121,18 @@ function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
|
||||
itemDetails,
|
||||
visibleRowIds,
|
||||
childRowIdsById,
|
||||
onExpandedSubRowIdsChange: handleExpandedSubRowIdChange,
|
||||
onExpandedRowIdsChange: handleExpandedSubRowIdChange,
|
||||
isSubRowExpanded: (id: string | number) => expandedIdsSet.has(id),
|
||||
};
|
||||
}
|
||||
|
||||
const { Provider: TableItemsProvider, useContext: useTableItemsContext } =
|
||||
/* TODO: Can we type this better? */
|
||||
createStrictContext<
|
||||
Omit<useTableItemsReturn<any>, "visibleRowIds" | "childRowIdsById">
|
||||
>({
|
||||
createStrictContext<Omit<useTableItemsReturn<any>, "childRowIdsById">>({
|
||||
name: "TableItemsContext",
|
||||
errorMessage:
|
||||
"useTableItemsContext must be used within a TableItemsProvider",
|
||||
});
|
||||
|
||||
export { useTableItems, TableItemsProvider, useTableItemsContext };
|
||||
export type { ItemDetail };
|
||||
export type { ItemDetail, SubRowsProps };
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEventCallback } from "../../../utils/hooks";
|
||||
import { focusInitialTableTarget } from "../helpers/table-cell";
|
||||
import { focusCellAndUpdateTabIndex } from "../helpers/table-focus";
|
||||
import { focusCell, focusCellAndUpdateTabIndex } from "../helpers/table-focus";
|
||||
import {
|
||||
findFirstCell,
|
||||
findFirstCellInRow,
|
||||
@@ -137,6 +137,10 @@ function useTableKeyboardNav({
|
||||
const target = event.target as Element | null;
|
||||
|
||||
if (tableRef && target === tableRef) {
|
||||
if (activeCell) {
|
||||
focusCell(activeCell);
|
||||
return;
|
||||
}
|
||||
focusInitialTableTarget(tableRef);
|
||||
return;
|
||||
}
|
||||
@@ -173,7 +177,7 @@ function useTableKeyboardNav({
|
||||
|
||||
return {
|
||||
/* Table should only have tabIndex until the focus is moved inside and is enabled */
|
||||
tabIndex: enabled ? (activeCell ? undefined : 0) : undefined,
|
||||
tabIndex: enabled ? 0 : undefined,
|
||||
setTableRef,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import type {
|
||||
TableSelection,
|
||||
} from "../helpers/selection/selection.types";
|
||||
|
||||
type UseTableSelectionArgs = SelectionProps & {
|
||||
type UseTableSelectionArgs = {
|
||||
selection?: SelectionProps;
|
||||
/* Visible rows manage the header checkbox state and render selection cells. */
|
||||
visibleRowIds: (string | number)[];
|
||||
/* Direct child ids let selection walk nested rows lazily. */
|
||||
@@ -19,17 +20,23 @@ type UseTableSelectionArgs = SelectionProps & {
|
||||
type UseTableSelectionReturn = {
|
||||
selection: TableSelection;
|
||||
renderSelection: boolean;
|
||||
disableRowSelectionOnClick: boolean;
|
||||
};
|
||||
|
||||
function useTableSelection({
|
||||
selectionMode = "none",
|
||||
defaultSelectedKeys,
|
||||
selectedKeys: selectedKeysProp,
|
||||
onSelectionChange,
|
||||
disabledSelectionKeys = [],
|
||||
selection = {},
|
||||
visibleRowIds = [],
|
||||
childRowIdsById,
|
||||
}: UseTableSelectionArgs): UseTableSelectionReturn {
|
||||
const {
|
||||
selectionMode = "none",
|
||||
defaultSelectedKeys,
|
||||
selectedKeys: selectedKeysProp,
|
||||
onSelectionChange,
|
||||
disabledSelectionKeys = [],
|
||||
disableRowSelectionOnClick = false,
|
||||
} = selection;
|
||||
|
||||
const radioGroupName = useId();
|
||||
|
||||
const [selectedKeys, setSelectedKeys] = useControllableState<SelectedKeysT>({
|
||||
@@ -59,6 +66,7 @@ function useTableSelection({
|
||||
...baseSelection,
|
||||
selectedKeys: [],
|
||||
},
|
||||
disableRowSelectionOnClick,
|
||||
renderSelection: false,
|
||||
};
|
||||
}
|
||||
@@ -75,6 +83,7 @@ function useTableSelection({
|
||||
name: radioGroupName,
|
||||
}),
|
||||
},
|
||||
disableRowSelectionOnClick,
|
||||
renderSelection: visibleRowIds.length !== 0,
|
||||
};
|
||||
}
|
||||
@@ -92,6 +101,7 @@ function useTableSelection({
|
||||
childRowIdsById,
|
||||
}),
|
||||
},
|
||||
disableRowSelectionOnClick,
|
||||
renderSelection: visibleRowIds.length !== 0,
|
||||
};
|
||||
}
|
||||
@@ -106,6 +116,7 @@ const noSelectionState: UseTableSelectionReturn = {
|
||||
disabledSelectionKeys: [],
|
||||
isRowSelected: () => false,
|
||||
},
|
||||
disableRowSelectionOnClick: false,
|
||||
renderSelection: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
export {
|
||||
/* export {
|
||||
default as DataTable,
|
||||
DataTableCaption,
|
||||
DataTableThead,
|
||||
@@ -8,7 +8,7 @@ export {
|
||||
DataTableTh,
|
||||
DataTableTd,
|
||||
DataTableTfoot,
|
||||
} from "./root/DataTableRoot";
|
||||
} from "./root/DataTableRoot.legacy";
|
||||
export type {
|
||||
DataTableProps,
|
||||
DataTableCaptionProps,
|
||||
@@ -18,4 +18,6 @@ export type {
|
||||
DataTableThProps,
|
||||
DataTableTdProps,
|
||||
DataTableTfootProps,
|
||||
} from "./root/DataTableRoot";
|
||||
} from "./root/DataTableRoot.legacy"; */
|
||||
|
||||
export { DataTable, type DataTableProps } from "./root/DataTableRoot";
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ResizeProps } from "../column-header/useTableColumnResize";
|
||||
|
||||
type SortDirection = "asc" | "desc" | "none";
|
||||
|
||||
/**
|
||||
@@ -5,25 +7,28 @@ type SortDirection = "asc" | "desc" | "none";
|
||||
* - Consider "accessorKey" or similar to allow simple column definitions without a cell function.
|
||||
* - Add "align" property for better control over text alignment in cells.
|
||||
*/
|
||||
type ColumnDefinition<T> = {
|
||||
type ColumnDefinition<T, DetailsT = Record<string, any>> = Pick<
|
||||
ResizeProps,
|
||||
| "resizable"
|
||||
| "width"
|
||||
| "defaultWidth"
|
||||
| "autoWidth"
|
||||
| "minWidth"
|
||||
| "maxWidth"
|
||||
| "onWidthChange"
|
||||
> & {
|
||||
id: string;
|
||||
width?: number | string;
|
||||
defaultWidth?: number | string;
|
||||
minWidth?: number | string;
|
||||
maxWidth?: number | string;
|
||||
/**
|
||||
* Currently only handles cell alignment.
|
||||
* TODO: Should this include centering?
|
||||
* type "icon" or something to avoid ellipsis on actions, tags etc
|
||||
* Text alignment for cells in this column.
|
||||
*
|
||||
*
|
||||
* @default "left"
|
||||
*/
|
||||
type?: "string" | "number";
|
||||
align?: "left" | "right" | "center";
|
||||
/**
|
||||
* Assigned to the cell's `th` element instead of `td` if true.
|
||||
*
|
||||
* Should be used for cells that act as row headers. Each row should have one rowheader, and only have one cell with `isRowHeader: true`,
|
||||
*
|
||||
* TODO: Not implemented
|
||||
* - Add a generic tablecell component that can render either a td or th based on context or this prop.
|
||||
*/
|
||||
isRowHeader?: boolean;
|
||||
/**
|
||||
@@ -43,9 +48,16 @@ type ColumnDefinition<T> = {
|
||||
* Use `sort` and `onSortChange` on the root component to control sort state.
|
||||
*/
|
||||
sortable?: boolean;
|
||||
/**
|
||||
* Additional metadata that can be used for filtering or other purposes. Not used by the table itself.
|
||||
*/
|
||||
details?: DetailsT;
|
||||
};
|
||||
|
||||
type ColumnDefinitions<T> = ColumnDefinition<T>[];
|
||||
type ColumnDefinitions<T, DetailsT = Record<string, any>> = ColumnDefinition<
|
||||
T,
|
||||
DetailsT
|
||||
>[];
|
||||
|
||||
/**
|
||||
* A single sort entry representing a column's current sort state.
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DataTableColumnHeader } from "../column-header/DataTableColumnHeader";
|
||||
import type { ColumnDefinitions } from "./DataTable.types";
|
||||
import { DataTableAuto } from "./DataTableAuto";
|
||||
import { DataTable } from "./DataTableRoot";
|
||||
|
||||
type TestRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
subRows?: TestRow[];
|
||||
};
|
||||
|
||||
const data: TestRow[] = [
|
||||
{
|
||||
id: "root",
|
||||
name: "Root",
|
||||
subRows: [{ id: "child", name: "Child" }],
|
||||
},
|
||||
];
|
||||
|
||||
const deepNestedData: TestRow[] = [
|
||||
{
|
||||
id: "root",
|
||||
name: "Root",
|
||||
subRows: [
|
||||
{
|
||||
id: "child",
|
||||
name: "Child",
|
||||
subRows: [{ id: "grandchild", name: "Grandchild" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const fallbackIdData: TestRow[] = [
|
||||
{
|
||||
id: "unused-root-1",
|
||||
name: "Root",
|
||||
subRows: [{ id: "unused-child", name: "Child" }],
|
||||
},
|
||||
{
|
||||
id: "unused-root-2",
|
||||
name: "Sibling",
|
||||
},
|
||||
];
|
||||
|
||||
const columns: ColumnDefinitions<TestRow> = [
|
||||
{
|
||||
id: "name",
|
||||
label: "Name",
|
||||
header: "Name",
|
||||
cell: (row) => row.name,
|
||||
},
|
||||
];
|
||||
|
||||
describe("DataTableAuto", () => {
|
||||
test("renders expanded child rows and includes them in select-all", () => {
|
||||
render(
|
||||
<DataTableAuto
|
||||
columnDefinitions={columns}
|
||||
data={data}
|
||||
getRowId={(row) => row.id}
|
||||
getSubRows={(row) => row.subRows ?? []}
|
||||
selectionMode="multiple"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Child")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Vis under-rader" }));
|
||||
|
||||
expect(screen.getByText("Child")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("checkbox")).toHaveLength(3);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("checkbox", { name: "Velg alle synlige rader" }),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole("checkbox")
|
||||
.every((checkbox) => (checkbox as HTMLInputElement).checked),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("select-all checks all visible rows when fallback ids are used", () => {
|
||||
render(
|
||||
<DataTableAuto
|
||||
columnDefinitions={columns}
|
||||
data={fallbackIdData}
|
||||
getSubRows={(row) => row.subRows ?? []}
|
||||
defaultExpandedSubRowIds={[0]}
|
||||
selectionMode="multiple"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("checkbox", { name: "Velg alle synlige rader" }),
|
||||
);
|
||||
|
||||
const rowCheckboxes = screen
|
||||
.getAllByRole("checkbox")
|
||||
.slice(1) as HTMLInputElement[];
|
||||
|
||||
expect(rowCheckboxes).toHaveLength(3);
|
||||
expect(rowCheckboxes.every((checkbox) => checkbox.checked)).toBe(true);
|
||||
});
|
||||
|
||||
test("parent row selection follows visible nested rows", () => {
|
||||
render(
|
||||
<DataTableAuto
|
||||
columnDefinitions={columns}
|
||||
data={data}
|
||||
getRowId={(row) => row.id}
|
||||
getSubRows={(row) => row.subRows ?? []}
|
||||
selectionMode="multiple"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Vis under-rader" }));
|
||||
|
||||
const getCheckboxes = () =>
|
||||
screen.getAllByRole("checkbox") as HTMLInputElement[];
|
||||
|
||||
fireEvent.click(getCheckboxes()[2]);
|
||||
|
||||
expect(getCheckboxes()[1].checked).toBe(false);
|
||||
expect(getCheckboxes()[1].indeterminate).toBe(true);
|
||||
|
||||
fireEvent.click(getCheckboxes()[1]);
|
||||
|
||||
expect(getCheckboxes()[1].checked).toBe(true);
|
||||
expect(getCheckboxes()[2].checked).toBe(true);
|
||||
|
||||
fireEvent.click(getCheckboxes()[1]);
|
||||
|
||||
expect(getCheckboxes()[1].checked).toBe(false);
|
||||
expect(getCheckboxes()[2].checked).toBe(false);
|
||||
});
|
||||
|
||||
test("collapsed parent selection includes hidden descendants and can clear them again", () => {
|
||||
render(
|
||||
<DataTableAuto
|
||||
columnDefinitions={columns}
|
||||
data={deepNestedData}
|
||||
getRowId={(row) => row.id}
|
||||
getSubRows={(row) => row.subRows ?? []}
|
||||
selectionMode="multiple"
|
||||
/>,
|
||||
);
|
||||
|
||||
const getCheckboxes = () =>
|
||||
screen.getAllByRole("checkbox") as HTMLInputElement[];
|
||||
|
||||
fireEvent.click(getCheckboxes()[1]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Vis under-rader" }));
|
||||
|
||||
expect(getCheckboxes()[1].checked).toBe(true);
|
||||
expect(getCheckboxes()[2].checked).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Vis under-rader" }));
|
||||
|
||||
expect(getCheckboxes()[3].checked).toBe(true);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("button", { name: "Skjul under-rader" })[0],
|
||||
);
|
||||
fireEvent.click(getCheckboxes()[1]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Vis under-rader" }));
|
||||
|
||||
expect(getCheckboxes()[1].checked).toBe(false);
|
||||
expect(getCheckboxes()[2].checked).toBe(false);
|
||||
expect(getCheckboxes()[3].checked).toBe(false);
|
||||
});
|
||||
|
||||
test("select-all includes hidden descendants for collapsed parents and clears them again", () => {
|
||||
render(
|
||||
<DataTableAuto
|
||||
columnDefinitions={columns}
|
||||
data={deepNestedData}
|
||||
getRowId={(row) => row.id}
|
||||
getSubRows={(row) => row.subRows ?? []}
|
||||
selectionMode="multiple"
|
||||
/>,
|
||||
);
|
||||
|
||||
const getCheckboxes = () =>
|
||||
screen.getAllByRole("checkbox") as HTMLInputElement[];
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("checkbox", { name: "Velg alle synlige rader" }),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Vis under-rader" }));
|
||||
|
||||
expect(getCheckboxes()[1].checked).toBe(true);
|
||||
expect(getCheckboxes()[2].checked).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Vis under-rader" }));
|
||||
|
||||
expect(getCheckboxes()[3].checked).toBe(true);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("checkbox", { name: "Fjern alle synlige valgte rader" }),
|
||||
);
|
||||
|
||||
expect(getCheckboxes()[0].checked).toBe(false);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("button", { name: "Skjul under-rader" })[0],
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Vis under-rader" }));
|
||||
|
||||
expect(getCheckboxes()[1].checked).toBe(false);
|
||||
expect(getCheckboxes()[2].checked).toBe(false);
|
||||
expect(getCheckboxes()[3].checked).toBe(false);
|
||||
});
|
||||
|
||||
test("does not render expansion controls in the manual table variant", () => {
|
||||
render(
|
||||
<DataTable>
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Name</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
<DataTable.Tr>
|
||||
<DataTable.Td>Root</DataTable.Td>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Tbody>
|
||||
</DataTable>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Root")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /vis detaljer|skjul detaljer/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import type { ColumnDefinitions } from "./DataTable.types";
|
||||
import { DataTable } from "./DataTableRoot";
|
||||
|
||||
type TestRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
subRows?: TestRow[];
|
||||
};
|
||||
|
||||
const meta: Meta<typeof DataTable> = {
|
||||
title: "ds-react/Data/DataTable/Tests",
|
||||
component: DataTable,
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DataTable>;
|
||||
|
||||
const data: TestRow[] = [
|
||||
{
|
||||
id: "root",
|
||||
name: "Root",
|
||||
subRows: [{ id: "child", name: "Child" }],
|
||||
},
|
||||
];
|
||||
|
||||
const deepNestedData: TestRow[] = [
|
||||
{
|
||||
id: "root",
|
||||
name: "Root",
|
||||
subRows: [
|
||||
{
|
||||
id: "child",
|
||||
name: "Child",
|
||||
subRows: [{ id: "grandchild", name: "Grandchild" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const fallbackIdData: TestRow[] = [
|
||||
{
|
||||
id: "unused-root-1",
|
||||
name: "Root",
|
||||
subRows: [{ id: "unused-child", name: "Child" }],
|
||||
},
|
||||
{
|
||||
id: "unused-root-2",
|
||||
name: "Sibling",
|
||||
},
|
||||
];
|
||||
|
||||
const columns: ColumnDefinitions<TestRow> = [
|
||||
{
|
||||
id: "name",
|
||||
label: "Name",
|
||||
header: "Name",
|
||||
cell: (row) => row.name,
|
||||
},
|
||||
];
|
||||
|
||||
const getSubRows = (row: TestRow) => row.subRows ?? [];
|
||||
|
||||
const getCheckboxes = (canvasElement: HTMLElement) =>
|
||||
within(canvasElement).getAllByRole("checkbox") as HTMLInputElement[];
|
||||
|
||||
export const ExpandedChildRowsIncludedInSelectAll: Story = {
|
||||
render: () => (
|
||||
<DataTable
|
||||
columnDefinitions={columns}
|
||||
data={data}
|
||||
getRowId={(row) => row.id}
|
||||
subRows={{ getRows: getSubRows }}
|
||||
selection={{ selectionMode: "multiple" }}
|
||||
/>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(canvas.queryByText("Child")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Vis under-rader" }),
|
||||
);
|
||||
|
||||
expect(canvas.getByText("Child")).toBeInTheDocument();
|
||||
expect(getCheckboxes(canvasElement)).toHaveLength(3);
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("checkbox", { name: "Velg alle synlige rader" }),
|
||||
);
|
||||
|
||||
expect(
|
||||
getCheckboxes(canvasElement).every((checkbox) => checkbox.checked),
|
||||
).toBe(true);
|
||||
},
|
||||
};
|
||||
|
||||
export const FallbackIdsSelectAllVisibleRows: Story = {
|
||||
render: () => (
|
||||
<DataTable
|
||||
columnDefinitions={columns}
|
||||
data={fallbackIdData}
|
||||
subRows={{
|
||||
getRows: getSubRows,
|
||||
defaultExpandedRowIds: [0],
|
||||
}}
|
||||
getRowId={(row) => row.id}
|
||||
selection={{ selectionMode: "multiple" }}
|
||||
/>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("checkbox", { name: "Velg alle synlige rader" }),
|
||||
);
|
||||
|
||||
const rowCheckboxes = getCheckboxes(canvasElement) as HTMLInputElement[];
|
||||
|
||||
expect(rowCheckboxes).toHaveLength(3);
|
||||
expect(rowCheckboxes.every((checkbox) => checkbox.checked)).toBe(true);
|
||||
},
|
||||
};
|
||||
|
||||
export const ParentSelectionFollowsVisibleNestedRows: Story = {
|
||||
render: () => (
|
||||
<DataTable
|
||||
columnDefinitions={columns}
|
||||
data={data}
|
||||
getRowId={(row) => row.id}
|
||||
subRows={{ getRows: getSubRows }}
|
||||
selection={{ selectionMode: "multiple" }}
|
||||
/>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Vis under-rader" }),
|
||||
);
|
||||
|
||||
await userEvent.click(getCheckboxes(canvasElement)[2]);
|
||||
|
||||
expect(getCheckboxes(canvasElement)[1].checked).toBe(false);
|
||||
expect(getCheckboxes(canvasElement)[1].indeterminate).toBe(true);
|
||||
|
||||
await userEvent.click(getCheckboxes(canvasElement)[1]);
|
||||
|
||||
expect(getCheckboxes(canvasElement)[1].checked).toBe(true);
|
||||
expect(getCheckboxes(canvasElement)[2].checked).toBe(true);
|
||||
|
||||
await userEvent.click(getCheckboxes(canvasElement)[1]);
|
||||
|
||||
expect(getCheckboxes(canvasElement)[1].checked).toBe(false);
|
||||
expect(getCheckboxes(canvasElement)[2].checked).toBe(false);
|
||||
},
|
||||
};
|
||||
|
||||
export const CollapsedParentSelectionIncludesHiddenDescendants: Story = {
|
||||
render: () => (
|
||||
<DataTable
|
||||
columnDefinitions={columns}
|
||||
data={deepNestedData}
|
||||
getRowId={(row) => row.id}
|
||||
subRows={{ getRows: getSubRows }}
|
||||
selection={{ selectionMode: "multiple" }}
|
||||
/>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(getCheckboxes(canvasElement)[1]);
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Vis under-rader" }),
|
||||
);
|
||||
|
||||
expect(getCheckboxes(canvasElement)[1].checked).toBe(true);
|
||||
expect(getCheckboxes(canvasElement)[2].checked).toBe(true);
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Vis under-rader" }),
|
||||
);
|
||||
|
||||
expect(getCheckboxes(canvasElement)[3].checked).toBe(true);
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getAllByRole("button", { name: "Skjul under-rader" })[0],
|
||||
);
|
||||
await userEvent.click(getCheckboxes(canvasElement)[1]);
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Vis under-rader" }),
|
||||
);
|
||||
|
||||
expect(getCheckboxes(canvasElement)[1].checked).toBe(false);
|
||||
expect(getCheckboxes(canvasElement)[2].checked).toBe(false);
|
||||
expect(getCheckboxes(canvasElement)[3].checked).toBe(false);
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectAllIncludesHiddenDescendantsForCollapsedParents: Story = {
|
||||
render: () => (
|
||||
<DataTable
|
||||
columnDefinitions={columns}
|
||||
data={deepNestedData}
|
||||
getRowId={(row) => row.id}
|
||||
subRows={{ getRows: getSubRows }}
|
||||
selection={{ selectionMode: "multiple" }}
|
||||
/>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("checkbox", { name: "Velg alle synlige rader" }),
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Vis under-rader" }),
|
||||
);
|
||||
|
||||
expect(getCheckboxes(canvasElement)[1].checked).toBe(true);
|
||||
expect(getCheckboxes(canvasElement)[2].checked).toBe(true);
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Vis under-rader" }),
|
||||
);
|
||||
|
||||
expect(getCheckboxes(canvasElement)[3].checked).toBe(true);
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("checkbox", {
|
||||
name: "Fjern alle synlige valgte rader",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getCheckboxes(canvasElement)[0].checked).toBe(false);
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getAllByRole("button", { name: "Skjul under-rader" })[0],
|
||||
);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Vis under-rader" }),
|
||||
);
|
||||
|
||||
expect(getCheckboxes(canvasElement)[1].checked).toBe(false);
|
||||
expect(getCheckboxes(canvasElement)[2].checked).toBe(false);
|
||||
expect(getCheckboxes(canvasElement)[3].checked).toBe(false);
|
||||
},
|
||||
};
|
||||
@@ -1,596 +0,0 @@
|
||||
/** biome-ignore-all lint/correctness/useHookAtTopLevel: False positive because of the way forwardRef() is added */
|
||||
import React, { forwardRef } from "react";
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "@navikt/aksel-icons";
|
||||
import { Button } from "../../../button";
|
||||
import { Skeleton } from "../../../skeleton";
|
||||
import { useId } from "../../../utils-external";
|
||||
import { cl } from "../../../utils/helpers";
|
||||
import { useMergeRefs } from "../../../utils/hooks";
|
||||
import { DataTableBaseCell } from "../base-cell/DataTableBaseCell";
|
||||
import { DataTableColumnHeader } from "../column-header/DataTableColumnHeader";
|
||||
import { DataTableEmptyState } from "../empty-state/DataTableEmptyState";
|
||||
import { useColumnOptions } from "../hooks/useColumnOptions";
|
||||
import {
|
||||
DataTableExpansionProvider,
|
||||
getDataTableExpansionId,
|
||||
useDataTableExpansion,
|
||||
} from "../hooks/useTableExpansion";
|
||||
import {
|
||||
type ItemDetail,
|
||||
TableItemsProvider,
|
||||
useTableItems,
|
||||
useTableItemsContext,
|
||||
} from "../hooks/useTableItems";
|
||||
import { useTableKeyboardNav } from "../hooks/useTableKeyboardNav";
|
||||
import {
|
||||
type SelectionProps,
|
||||
useTableSelection,
|
||||
} from "../hooks/useTableSelection";
|
||||
import { type TableSortOptions, useTableSort } from "../hooks/useTableSort";
|
||||
import { DataTableLoadingState } from "../loading-state/DataTableLoadingState";
|
||||
import { DataTableTbody } from "../tbody/DataTableTbody";
|
||||
import { DataTableThead } from "../thead/DataTableThead";
|
||||
import { DataTableTr } from "../tr/DataTableTr";
|
||||
import type { ColumnDefinitions } from "./DataTable.types";
|
||||
import {
|
||||
DataTableContextProvider,
|
||||
useDataTableContext,
|
||||
} from "./DataTableRoot.context";
|
||||
|
||||
interface DataTableProps<T>
|
||||
extends
|
||||
React.HTMLAttributes<HTMLTableElement>,
|
||||
SelectionProps,
|
||||
TableSortOptions {
|
||||
children?: never;
|
||||
/**
|
||||
* Controls vertical cell padding.
|
||||
* @default "normal"
|
||||
*/
|
||||
rowDensity?: "condensed" | "normal" | "spacious";
|
||||
/**
|
||||
* Zebra striped table
|
||||
* @default false
|
||||
*/
|
||||
zebraStripes?: boolean;
|
||||
/**
|
||||
* Truncate content in cells and show ellipsis for overflowed text.
|
||||
*
|
||||
* **NB:** When using `layout="auto"`, you have to manually set a `maxWidth` on columns that should be truncated.
|
||||
* @default true
|
||||
*/
|
||||
truncateContent?: boolean; // TODO: Consider making this default false when layout=auto, and maybe disallow it but add a wrap prop on the td-comp.
|
||||
/**
|
||||
* Enables keyboard navigation for table rows and cells.
|
||||
* @default false
|
||||
*/
|
||||
withKeyboardNav?: boolean;
|
||||
/**
|
||||
* Custom callback to determine if navigation should be blocked.
|
||||
* Called before default blocking logic.
|
||||
* Requires `withKeyboardNav` to be `true`.
|
||||
*/
|
||||
shouldBlockNavigation?: (event: KeyboardEvent) => boolean;
|
||||
/**
|
||||
* Controls table layout.
|
||||
*
|
||||
* ### fixed
|
||||
* Gives you full control of column widths. This is required for resizable columns.
|
||||
*
|
||||
* ### auto
|
||||
* Makes the columns resize automatically based on the content.
|
||||
* The table will take up at least 100% of available width.
|
||||
*
|
||||
* **NB:** When using this with `truncateContent`, you have to manually
|
||||
* set a `contentMaxWidth` on cells that should be truncated.
|
||||
* @default "fixed"
|
||||
*/
|
||||
layout?: "fixed" | "auto";
|
||||
/**
|
||||
* Defines the columns of the table and how to render them.
|
||||
*
|
||||
*
|
||||
* Each column definition should have a unique `id` (or use the column index as fallback) and a `cell`-renderer function that takes the row data as argument and returns a React node.
|
||||
*/
|
||||
columnDefinitions: ColumnDefinitions<T>;
|
||||
/**
|
||||
* The data to display in the table.
|
||||
*
|
||||
*
|
||||
* Each object in the array represents a row, and the properties of the object are used to render the cells based on the `columnDefinitions`.
|
||||
*/
|
||||
data: T[];
|
||||
/**
|
||||
* Function to get unique row id from row data.
|
||||
*
|
||||
*
|
||||
* If not provided, the row index will be used as id. This can cause issues if your data changes dynamically, so it's recommended to provide a stable id if possible.
|
||||
*/
|
||||
getRowId?: (rowData: T, index: number) => string | number;
|
||||
/**
|
||||
* Sticky columns that remain visible when horizontally scrolling the table.
|
||||
*
|
||||
* You can specify 1 sticky column on the left and 1 on the right.
|
||||
*/
|
||||
stickyColumns?: {
|
||||
first?: "1";
|
||||
last?: "1";
|
||||
};
|
||||
/**
|
||||
* @default false
|
||||
*/
|
||||
stickyHeader?: boolean;
|
||||
/**
|
||||
* Callback invoked when a data row is clicked.
|
||||
* Not called when clicking header, loading, or empty-state rows.
|
||||
*/
|
||||
onRowClick?: (
|
||||
rowId: string | number,
|
||||
event: React.MouseEvent<HTMLTableRowElement>,
|
||||
) => void;
|
||||
/**
|
||||
* Content to render when `data` is empty.
|
||||
* Rendered inside a `DataTable.EmptyState` row spanning all columns.
|
||||
*/
|
||||
emptyState?: React.ReactNode;
|
||||
/**
|
||||
* Shows the table in a loading state.
|
||||
*
|
||||
* - When `loadingState` is provided, it is rendered inside a `DataTable.LoadingState` row.
|
||||
* - When `loadingState` is **not** provided, skeleton placeholder rows are rendered instead.
|
||||
* @default false
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
/**
|
||||
* Custom content to render when `isLoading` is `true`.
|
||||
* Rendered inside a `DataTable.LoadingState` row spanning all columns.
|
||||
* When omitted, skeleton rows are rendered based on `loadingRows`.
|
||||
*/
|
||||
loadingState?: React.ReactNode;
|
||||
/**
|
||||
* Number of skeleton rows to render when `isLoading` is `true` and no `loadingState` is provided.
|
||||
*
|
||||
*
|
||||
* If not provided, the rendered content will get a temporarily overlay while loading
|
||||
*/
|
||||
loadingRows?: number;
|
||||
/**
|
||||
* Visually hidden label announced to screen readers when skeleton rows are shown.
|
||||
* Only used when `isLoading` is `true` and no `loadingState` is provided.
|
||||
* @default "Laster innhold"
|
||||
*/
|
||||
loadingLabel?: string;
|
||||
/**
|
||||
* Renders a details panel below the row when expanded.
|
||||
* When provided, an expand toggle column is added automatically.
|
||||
*/
|
||||
getDetailsPanelContent?: (rowData: T) => React.ReactNode;
|
||||
/**
|
||||
* Determines whether a row can be expanded to show details panel content.
|
||||
* @default () => true
|
||||
*/
|
||||
isDetailsPanelExpandable?: (rowData: T) => boolean;
|
||||
/**
|
||||
* Controlled list of expanded row IDs.
|
||||
* Use with `onDetailsPanelChange` for controlled usage, or `defaultDetailsPanelRowIds` for uncontrolled.
|
||||
*/
|
||||
detailsPanelRowIds?: (string | number)[];
|
||||
/**
|
||||
* Initial list of expanded row IDs for uncontrolled usage.
|
||||
* @default []
|
||||
*/
|
||||
defaultDetailsPanelRowIds?: (string | number)[];
|
||||
/**
|
||||
* Called when the list of expanded row IDs changes.
|
||||
*
|
||||
*
|
||||
* TODO:
|
||||
* - Docs: This pattern is called "Master / Detail" in general terms
|
||||
*/
|
||||
onDetailsPanelChange?: (ids: (string | number)[]) => void;
|
||||
/**
|
||||
* Returns the height (in px) or `"auto"` for a row's details panel.
|
||||
* When a number is returned, the panel scrolls within that fixed height.
|
||||
* @default "auto"
|
||||
*/
|
||||
getDetailsPanelHeight?: (rowData: T) => number | "auto";
|
||||
/**
|
||||
* Shows an expand-all toggle button in the expand column header.
|
||||
* @default false
|
||||
*/
|
||||
showExpandAll?: boolean;
|
||||
/**
|
||||
* Function to get sub-rows for a given row, used for nested rows.
|
||||
* When provided, an expand toggle column is added automatically.
|
||||
*/
|
||||
getSubRows?: (rowData: T) => T[];
|
||||
expandedSubRowIds?: (string | number)[];
|
||||
defaultExpandedSubRowIds?: (string | number)[];
|
||||
isSubRowExpandable?: (rowData: T) => boolean;
|
||||
onExpandedSubRowIdsChange?: (ids: (string | number)[]) => void;
|
||||
}
|
||||
|
||||
function DataTableAutoInner<T>(
|
||||
{
|
||||
className,
|
||||
id,
|
||||
rowDensity = "normal",
|
||||
withKeyboardNav = false,
|
||||
zebraStripes = false,
|
||||
truncateContent = true,
|
||||
shouldBlockNavigation,
|
||||
layout = "fixed",
|
||||
selectionMode: selectionModeProp = "none",
|
||||
selectedKeys,
|
||||
defaultSelectedKeys,
|
||||
onSelectionChange,
|
||||
disabledSelectionKeys = [],
|
||||
data,
|
||||
columnDefinitions,
|
||||
getRowId,
|
||||
stickyColumns,
|
||||
stickyHeader = false,
|
||||
sort: sortProp,
|
||||
defaultSort = [],
|
||||
onSortChange,
|
||||
onRowClick,
|
||||
emptyState,
|
||||
isLoading = false,
|
||||
loadingState,
|
||||
loadingRows,
|
||||
loadingLabel = "Laster innhold",
|
||||
disableRowSelectionOnClick = false,
|
||||
getDetailsPanelContent,
|
||||
isDetailsPanelExpandable,
|
||||
getDetailsPanelHeight,
|
||||
showExpandAll = false,
|
||||
detailsPanelRowIds,
|
||||
defaultDetailsPanelRowIds,
|
||||
onDetailsPanelChange,
|
||||
getSubRows,
|
||||
expandedSubRowIds,
|
||||
defaultExpandedSubRowIds,
|
||||
isSubRowExpandable,
|
||||
onExpandedSubRowIdsChange,
|
||||
...rest
|
||||
}: DataTableProps<T>,
|
||||
forwardedRef: React.ForwardedRef<HTMLTableElement>,
|
||||
) {
|
||||
const { tabIndex, setTableRef } = useTableKeyboardNav({
|
||||
enabled: withKeyboardNav,
|
||||
shouldBlockNavigation,
|
||||
});
|
||||
|
||||
const { sortState, onSortClick } = useTableSort({
|
||||
defaultSort,
|
||||
onSortChange,
|
||||
sort: sortProp,
|
||||
});
|
||||
|
||||
const mergedRef = useMergeRefs(forwardedRef, setTableRef);
|
||||
|
||||
const tableItems = useTableItems({
|
||||
items: data,
|
||||
getRowId,
|
||||
getSubRows,
|
||||
expandedSubRowIds,
|
||||
defaultExpandedSubRowIds,
|
||||
isSubRowExpandable,
|
||||
onExpandedSubRowIdsChange,
|
||||
});
|
||||
|
||||
const tableSelectionState = useTableSelection({
|
||||
selectionMode: selectionModeProp,
|
||||
selectedKeys,
|
||||
defaultSelectedKeys,
|
||||
onSelectionChange,
|
||||
disabledSelectionKeys,
|
||||
visibleRowIds: tableItems.visibleRowIds,
|
||||
childRowIdsById: tableItems.childRowIdsById,
|
||||
});
|
||||
|
||||
const { columns, stickySelection } = useColumnOptions<T>(columnDefinitions, {
|
||||
stickyColumns,
|
||||
selectionMode: tableSelectionState.selection.selectionMode,
|
||||
});
|
||||
|
||||
const fullWidthColSpan =
|
||||
columns.length +
|
||||
(layout === "fixed" ? 1 : 0) +
|
||||
(tableSelectionState.selection.selectionMode !== "none" ? 1 : 0) +
|
||||
(getDetailsPanelContent ? 1 : 0);
|
||||
|
||||
const tableId = useId(id);
|
||||
|
||||
return (
|
||||
<DataTableContextProvider
|
||||
layout={layout}
|
||||
withKeyboardNav={withKeyboardNav}
|
||||
selectionState={tableSelectionState}
|
||||
stickySelection={stickySelection}
|
||||
stickyHeader={stickyHeader}
|
||||
tableId={tableId}
|
||||
showLoadingSkeletons={isLoading && loadingState == null}
|
||||
onRowClick={onRowClick}
|
||||
disableRowSelectionOnClick={disableRowSelectionOnClick}
|
||||
isLoading={isLoading}
|
||||
showLoadingOverlay={isLoading && !loadingState && !loadingRows}
|
||||
columns={columns}
|
||||
>
|
||||
<TableItemsProvider
|
||||
itemDetails={tableItems.itemDetails}
|
||||
items={tableItems.items}
|
||||
onExpandedSubRowIdsChange={tableItems.onExpandedSubRowIdsChange}
|
||||
isSubRowExpanded={tableItems.isSubRowExpanded}
|
||||
>
|
||||
<DataTableExpansionProvider
|
||||
detailsPanelRowIds={detailsPanelRowIds}
|
||||
defaultDetailsPanelRowIds={defaultDetailsPanelRowIds}
|
||||
onDetailsPanelChange={onDetailsPanelChange}
|
||||
getDetailsPanelContent={getDetailsPanelContent}
|
||||
isDetailsPanelExpandable={isDetailsPanelExpandable}
|
||||
getDetailsPanelHeight={getDetailsPanelHeight}
|
||||
showExpandAll={showExpandAll}
|
||||
>
|
||||
<div className="aksel-data-table__border-wrapper">
|
||||
<div className="aksel-data-table__scroll-wrapper">
|
||||
<table
|
||||
{...rest}
|
||||
ref={mergedRef}
|
||||
className={cl("aksel-data-table", className)}
|
||||
data-zebra-stripes={zebraStripes}
|
||||
data-truncate-content={truncateContent}
|
||||
data-density={rowDensity}
|
||||
data-layout={layout}
|
||||
data-loading={isLoading || undefined}
|
||||
tabIndex={tabIndex}
|
||||
aria-busy={isLoading || undefined}
|
||||
>
|
||||
<DataTableThead>
|
||||
<DataTableTr>
|
||||
{columns.map(({ isSticky, colDef }) => {
|
||||
const sortEntry = sortState.find(
|
||||
(s) => s.columnId === colDef.id,
|
||||
);
|
||||
const sortDirection = sortEntry?.direction ?? "none";
|
||||
return (
|
||||
<DataTableColumnHeader
|
||||
maxWidth={colDef.maxWidth}
|
||||
minWidth={colDef.minWidth}
|
||||
width={colDef.width}
|
||||
defaultWidth={colDef.defaultWidth ?? "100%"}
|
||||
textAlign={
|
||||
colDef.type === "number" ? "right" : "left"
|
||||
}
|
||||
key={colDef.id}
|
||||
isSticky={isSticky}
|
||||
sortable={colDef.sortable}
|
||||
sortDirection={sortDirection}
|
||||
onSortClick={(event) => onSortClick(colDef.id, event)}
|
||||
>
|
||||
{colDef.header}
|
||||
</DataTableColumnHeader>
|
||||
);
|
||||
})}
|
||||
</DataTableTr>
|
||||
</DataTableThead>
|
||||
|
||||
<DataTableTbody>
|
||||
<DataTableAutoTBodyContent
|
||||
loadingState={loadingState}
|
||||
loadingRows={loadingRows}
|
||||
loadingLabel={loadingLabel}
|
||||
emptyState={emptyState}
|
||||
fullWidthColSpan={fullWidthColSpan}
|
||||
/>
|
||||
</DataTableTbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</DataTableExpansionProvider>
|
||||
</TableItemsProvider>
|
||||
</DataTableContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataTableAutoTBodyContentProps {
|
||||
loadingState: React.ReactNode;
|
||||
loadingLabel: string;
|
||||
loadingRows?: number;
|
||||
emptyState: React.ReactNode;
|
||||
fullWidthColSpan: number;
|
||||
}
|
||||
|
||||
function DataTableAutoTBodyContent({
|
||||
loadingState,
|
||||
loadingRows,
|
||||
loadingLabel,
|
||||
emptyState,
|
||||
fullWidthColSpan,
|
||||
}: DataTableAutoTBodyContentProps) {
|
||||
const { items, itemDetails } = useTableItemsContext();
|
||||
const { columns, isLoading } = useDataTableContext();
|
||||
|
||||
if (isLoading && loadingState != null) {
|
||||
return (
|
||||
<DataTableLoadingState colSpan={fullWidthColSpan}>
|
||||
{loadingState}
|
||||
</DataTableLoadingState>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && loadingRows) {
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td colSpan={fullWidthColSpan} className="aksel-sr-only">
|
||||
{loadingLabel}
|
||||
</td>
|
||||
</tr>
|
||||
{Array.from({ length: loadingRows }, (_, rowIndex) => (
|
||||
<DataTableTr key={`skeleton-row-${rowIndex}`} aria-hidden>
|
||||
{columns.map(({ isSticky, colDef }, colDefIndex) => (
|
||||
<DataTableBaseCell
|
||||
textAlign={colDef.type === "number" ? "right" : "left"}
|
||||
key={colDef.id || colDefIndex}
|
||||
as={colDef.isRowHeader ? "th" : "td"}
|
||||
isSticky={isSticky}
|
||||
>
|
||||
<Skeleton variant="text" />
|
||||
</DataTableBaseCell>
|
||||
))}
|
||||
</DataTableTr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0 && emptyState !== undefined) {
|
||||
return (
|
||||
<DataTableEmptyState colSpan={fullWidthColSpan}>
|
||||
{emptyState}
|
||||
</DataTableEmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
const renderLoadingAnnouncement = isLoading && !loadingState && !loadingRows;
|
||||
|
||||
return items.map((rowData) => {
|
||||
const details = itemDetails.get(rowData);
|
||||
|
||||
/* Should in theory be impossible. Look about typing this? */
|
||||
if (!details) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasSubRows = details.children.length > 0;
|
||||
|
||||
return (
|
||||
<React.Fragment key={details.id}>
|
||||
{renderLoadingAnnouncement && (
|
||||
<tr>
|
||||
<td colSpan={fullWidthColSpan} className="aksel-sr-only">
|
||||
{loadingLabel}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<DataTableTr rowId={details.id}>
|
||||
{columns.map(({ isSticky, colDef }, colDefIndex) => {
|
||||
const renderNestedToggle = colDefIndex === 0 && hasSubRows;
|
||||
const renderNestedIndent =
|
||||
colDefIndex === 0 && (details.level > 0 || hasSubRows);
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
"--__axc-data-table-nested-depth": details.level,
|
||||
};
|
||||
|
||||
return (
|
||||
<DataTableBaseCell
|
||||
/* TODO: Make this configurable */
|
||||
textAlign={colDef.type === "number" ? "right" : "left"}
|
||||
key={colDef.id || colDefIndex}
|
||||
as={colDef.isRowHeader ? "th" : "td"}
|
||||
isSticky={isSticky}
|
||||
data-nested={renderNestedIndent || undefined}
|
||||
style={style}
|
||||
>
|
||||
{renderNestedToggle && <NestedRowToggle details={details} />}
|
||||
{colDef.cell(rowData)}
|
||||
</DataTableBaseCell>
|
||||
);
|
||||
})}
|
||||
</DataTableTr>
|
||||
<DataTableExpandedRow
|
||||
rowId={details.id}
|
||||
rowData={rowData}
|
||||
fullWidthColSpan={fullWidthColSpan}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function NestedRowToggle({ details }: { details: ItemDetail<any> }) {
|
||||
const { isSubRowExpanded, onExpandedSubRowIdsChange } =
|
||||
useTableItemsContext();
|
||||
|
||||
const subRows = details.children;
|
||||
const hasSubRows = subRows && subRows.length > 0;
|
||||
const isRowExpanded = isSubRowExpanded(details.id);
|
||||
|
||||
return (
|
||||
<div className="aksel-data-table__nested-toggle">
|
||||
{hasSubRows && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
data-color="neutral"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExpandedSubRowIdsChange(details.id);
|
||||
}}
|
||||
aria-expanded={isRowExpanded}
|
||||
aria-label={isRowExpanded ? "Skjul under-rader" : "Vis under-rader"}
|
||||
icon={
|
||||
isRowExpanded ? (
|
||||
<ChevronDownIcon aria-hidden />
|
||||
) : (
|
||||
<ChevronRightIcon aria-hidden />
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTableExpandedRow<T>({
|
||||
rowId,
|
||||
rowData,
|
||||
fullWidthColSpan,
|
||||
}: {
|
||||
rowId: string | number;
|
||||
rowData: T;
|
||||
fullWidthColSpan: number;
|
||||
}) {
|
||||
const { tableId } = useDataTableContext();
|
||||
const {
|
||||
enableDetailsPanel,
|
||||
isExpanded,
|
||||
getDetailsPanelContent,
|
||||
getDetailsPanelHeight,
|
||||
} = useDataTableExpansion();
|
||||
|
||||
if (!enableDetailsPanel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isExpanded(rowId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = getDetailsPanelContent?.(rowData);
|
||||
const expansionId = getDataTableExpansionId(tableId, rowId);
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td id={expansionId} colSpan={fullWidthColSpan}>
|
||||
<div style={{ height: getDetailsPanelHeight?.(rowData) }}>
|
||||
{content}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
const DataTableAuto = forwardRef(DataTableAutoInner) as <T>(
|
||||
props: DataTableProps<T> & React.RefAttributes<HTMLTableElement>,
|
||||
) => React.ReactElement | null;
|
||||
|
||||
export { DataTableAuto };
|
||||
export type { DataTableProps };
|
||||
export default DataTableAuto;
|
||||
@@ -14,10 +14,14 @@ type DataTableContextProps<T> = {
|
||||
rowId: string | number,
|
||||
event: React.MouseEvent<HTMLTableRowElement>,
|
||||
) => void;
|
||||
disableRowSelectionOnClick: boolean;
|
||||
isLoading?: boolean;
|
||||
showLoadingOverlay: boolean;
|
||||
columns: UseColumnOptionsResult<T>["columns"];
|
||||
/**
|
||||
* Used to set exact colspan for detailsPanel, loadingState and emptyState.
|
||||
* This is necessary to ensure that these components span the entire width of the table.
|
||||
*/
|
||||
fullWidthColSpan: number;
|
||||
};
|
||||
|
||||
const { Provider: DataTableContextProvider, useContext: useDataTableContext } =
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { useId } from "../../../utils-external";
|
||||
import { cl } from "../../../utils/helpers";
|
||||
import { useMergeRefs } from "../../../utils/hooks";
|
||||
import {
|
||||
DataTableCaption,
|
||||
type DataTableCaptionProps,
|
||||
} from "../caption/DataTableCaption";
|
||||
import {
|
||||
DataTableEmptyState,
|
||||
type DataTableEmptyStateProps,
|
||||
} from "../empty-state/DataTableEmptyState";
|
||||
import { DataTableDetailsPanelProvider } from "../hooks/useTableDetailsPanel";
|
||||
import { useTableKeyboardNav } from "../hooks/useTableKeyboardNav";
|
||||
import {
|
||||
type SelectionProps,
|
||||
noSelectionState,
|
||||
} from "../hooks/useTableSelection";
|
||||
import {
|
||||
DataTableLoadingState,
|
||||
type DataTableLoadingStateProps,
|
||||
} from "../loading-state/DataTableLoadingState";
|
||||
import {
|
||||
DataTableTbody,
|
||||
type DataTableTbodyProps,
|
||||
} from "../tbody/DataTableTbody";
|
||||
import { DataTableTd, type DataTableTdProps } from "../td/DataTableTd";
|
||||
import {
|
||||
DataTableTfoot,
|
||||
type DataTableTfootProps,
|
||||
} from "../tfoot/DataTableTfoot";
|
||||
import { DataTableTh, type DataTableThProps } from "../th/DataTableTh";
|
||||
import {
|
||||
DataTableThead,
|
||||
type DataTableTheadProps,
|
||||
} from "../thead/DataTableThead";
|
||||
import { DataTableTr, type DataTableTrProps } from "../tr/DataTableTr";
|
||||
import { DataTableContextProvider } from "./DataTableRoot.context";
|
||||
|
||||
interface DataTableProps
|
||||
extends React.HTMLAttributes<HTMLTableElement>, SelectionProps {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Controls vertical cell padding.
|
||||
* @default "normal"
|
||||
*/
|
||||
rowDensity?: "condensed" | "normal" | "spacious";
|
||||
/**
|
||||
* Zebra striped table
|
||||
* @default false
|
||||
*/
|
||||
zebraStripes?: boolean;
|
||||
/**
|
||||
* Truncate content in cells and show ellipsis for overflowed text.
|
||||
*
|
||||
* **NB:** When using `layout="auto"`, you have to manually set a `maxWidth` on columns that should be truncated.
|
||||
* @default true
|
||||
*/
|
||||
truncateContent?: boolean; // TODO: Consider making this default false when layout=auto, and maybe disallow it but add a wrap prop on the td-comp.
|
||||
/**
|
||||
* Enables keyboard navigation for table rows and cells.
|
||||
* @default false
|
||||
*/
|
||||
withKeyboardNav?: boolean;
|
||||
/**
|
||||
* Custom callback to determine if navigation should be blocked.
|
||||
* Called before default blocking logic.
|
||||
* Requires `withKeyboardNav` to be `true`.
|
||||
*/
|
||||
shouldBlockNavigation?: (event: KeyboardEvent) => boolean;
|
||||
/**
|
||||
* Controls table layout.
|
||||
*
|
||||
* ### fixed
|
||||
* Gives you full control of column widths. This is required for resizable columns.
|
||||
*
|
||||
* ### auto
|
||||
* Makes the columns resize automatically based on the content.
|
||||
* The table will take up at least 100% of available width.
|
||||
*
|
||||
* **NB:** When using this with `truncateContent`, you have to manually
|
||||
* set a `contentMaxWidth` on cells that should be truncated.
|
||||
* @default "fixed"
|
||||
*/
|
||||
layout?: "fixed" | "auto";
|
||||
}
|
||||
|
||||
interface DataTableRootComponent extends React.ForwardRefExoticComponent<
|
||||
DataTableProps & React.RefAttributes<HTMLTableElement>
|
||||
> {
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableCaptionProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Caption>
|
||||
* Lorem ipsum
|
||||
* </DataTable.Caption
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Caption: typeof DataTableCaption;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableTheadProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Thead>
|
||||
* ... TODO
|
||||
* </DataTable.Thead>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Thead: typeof DataTableThead;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableTbodyProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Tbody>
|
||||
* ... TODO
|
||||
* </DataTable.Tbody>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Tbody: typeof DataTableTbody;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableTrProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Tr>
|
||||
* ... TODO
|
||||
* </DataTable.Tr
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Tr: typeof DataTableTr;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableThProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* ```
|
||||
*/
|
||||
Th: typeof DataTableTh;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableTdProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Tbody>
|
||||
* <DataTable.Td>
|
||||
* Lorem ipsum
|
||||
* </DataTable.Td>
|
||||
* <DataTable.Td>
|
||||
* Dolor sit amet
|
||||
* </DataTable.Td>
|
||||
* </DataTable.Tbody>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Td: typeof DataTableTd;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableTfootProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Tfoot>
|
||||
* ...
|
||||
* </DataTable.Tfoot>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Tfoot: typeof DataTableTfoot;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableEmptyStateProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.TBody>
|
||||
* <DataTable.EmptyState />
|
||||
* </DataTable.TBody>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
EmptyState: typeof DataTableEmptyState;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableEmptyStateProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.TBody>
|
||||
* <DataTable.LoadingState />
|
||||
* </DataTable.TBody>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
LoadingState: typeof DataTableLoadingState;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO Component description etc.
|
||||
*
|
||||
* **NB:** To get sticky headers, you have to set a height restriction on the table container. You can use VStack for this:
|
||||
* TODO example
|
||||
*/
|
||||
const DataTable = forwardRef<HTMLTableElement, DataTableProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
rowDensity = "normal",
|
||||
withKeyboardNav = false,
|
||||
zebraStripes = false,
|
||||
truncateContent = true,
|
||||
shouldBlockNavigation,
|
||||
layout = "fixed",
|
||||
...rest
|
||||
},
|
||||
forwardedRef,
|
||||
) => {
|
||||
const { tabIndex, setTableRef } = useTableKeyboardNav({
|
||||
enabled: withKeyboardNav,
|
||||
shouldBlockNavigation,
|
||||
});
|
||||
|
||||
const mergedRef = useMergeRefs(forwardedRef, setTableRef);
|
||||
|
||||
return (
|
||||
<DataTableContextProvider
|
||||
layout={layout}
|
||||
withKeyboardNav={withKeyboardNav}
|
||||
selectionState={noSelectionState}
|
||||
stickySelection={false}
|
||||
stickyHeader={true}
|
||||
tableId={useId()}
|
||||
showLoadingSkeletons={false}
|
||||
onRowClick={undefined}
|
||||
showLoadingOverlay={false}
|
||||
columns={[]}
|
||||
fullWidthColSpan={9999}
|
||||
>
|
||||
<DataTableDetailsPanelProvider>
|
||||
<div className="aksel-data-table__border-wrapper">
|
||||
<div className="aksel-data-table__scroll-wrapper">
|
||||
<table
|
||||
{...rest}
|
||||
ref={mergedRef}
|
||||
className={cl("aksel-data-table", className)}
|
||||
data-zebra-stripes={zebraStripes}
|
||||
data-truncate-content={truncateContent}
|
||||
data-density={rowDensity}
|
||||
data-layout={layout}
|
||||
tabIndex={tabIndex}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DataTableDetailsPanelProvider>
|
||||
</DataTableContextProvider>
|
||||
);
|
||||
},
|
||||
) as DataTableRootComponent;
|
||||
|
||||
DataTable.Caption = DataTableCaption;
|
||||
DataTable.Thead = DataTableThead;
|
||||
DataTable.Tbody = DataTableTbody;
|
||||
DataTable.Th = DataTableTh;
|
||||
DataTable.Tr = DataTableTr;
|
||||
DataTable.Td = DataTableTd;
|
||||
DataTable.Tfoot = DataTableTfoot;
|
||||
DataTable.EmptyState = DataTableEmptyState;
|
||||
DataTable.LoadingState = DataTableLoadingState;
|
||||
|
||||
export {
|
||||
DataTable,
|
||||
DataTableCaption,
|
||||
DataTableEmptyState,
|
||||
DataTableLoadingState,
|
||||
DataTableTbody,
|
||||
DataTableTd,
|
||||
DataTableTfoot,
|
||||
DataTableTh,
|
||||
DataTableThead,
|
||||
DataTableTr,
|
||||
};
|
||||
export default DataTable;
|
||||
export type {
|
||||
DataTableCaptionProps,
|
||||
DataTableEmptyStateProps,
|
||||
DataTableLoadingStateProps,
|
||||
DataTableProps,
|
||||
DataTableTbodyProps,
|
||||
DataTableTdProps,
|
||||
DataTableTfootProps,
|
||||
DataTableTheadProps,
|
||||
DataTableThProps,
|
||||
DataTableTrProps,
|
||||
};
|
||||
@@ -1,45 +1,52 @@
|
||||
import React, { forwardRef } from "react";
|
||||
/** biome-ignore-all lint/correctness/useHookAtTopLevel: False positive because of the way forwardRef() is added */
|
||||
import React, {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Skeleton } from "../../../skeleton";
|
||||
import { useId } from "../../../utils-external";
|
||||
import { Slot } from "../../../utils/components/slot/Slot";
|
||||
import { cl } from "../../../utils/helpers";
|
||||
import { useMergeRefs } from "../../../utils/hooks";
|
||||
import { DataTableBaseCell } from "../base-cell/DataTableBaseCell";
|
||||
import { DataTableColumnHeader } from "../column-header/DataTableColumnHeader";
|
||||
import { DataTableDetailsPanelRow } from "../details-panel-row/DataTableDetailsPanelRow";
|
||||
import { DataTableEmptyState } from "../empty-state/DataTableEmptyState";
|
||||
import { useColumnOptions } from "../hooks/useColumnOptions";
|
||||
import {
|
||||
DataTableCaption,
|
||||
type DataTableCaptionProps,
|
||||
} from "../caption/DataTableCaption";
|
||||
DataTableDetailsPanelProvider,
|
||||
type DetailsPanelProps,
|
||||
} from "../hooks/useTableDetailsPanel";
|
||||
import {
|
||||
DataTableEmptyState,
|
||||
type DataTableEmptyStateProps,
|
||||
} from "../empty-state/DataTableEmptyState";
|
||||
import { DataTableExpansionProvider } from "../hooks/useTableExpansion";
|
||||
type SubRowsProps,
|
||||
TableItemsProvider,
|
||||
useTableItems,
|
||||
useTableItemsContext,
|
||||
} from "../hooks/useTableItems";
|
||||
import { useTableKeyboardNav } from "../hooks/useTableKeyboardNav";
|
||||
import {
|
||||
type SelectionProps,
|
||||
noSelectionState,
|
||||
useTableSelection,
|
||||
} from "../hooks/useTableSelection";
|
||||
import { type TableSortOptions, useTableSort } from "../hooks/useTableSort";
|
||||
import { DataTableLoadingState } from "../loading-state/DataTableLoadingState";
|
||||
import { DataTableSubRowToggle } from "../sub-row-toggle/DataTableSubRowToggle";
|
||||
import { DataTableTbody } from "../tbody/DataTableTbody";
|
||||
import { DataTableThead } from "../thead/DataTableThead";
|
||||
import { DataTableTr } from "../tr/DataTableTr";
|
||||
import type { ColumnDefinitions } from "./DataTable.types";
|
||||
import {
|
||||
DataTableLoadingState,
|
||||
type DataTableLoadingStateProps,
|
||||
} from "../loading-state/DataTableLoadingState";
|
||||
import {
|
||||
DataTableTbody,
|
||||
type DataTableTbodyProps,
|
||||
} from "../tbody/DataTableTbody";
|
||||
import { DataTableTd, type DataTableTdProps } from "../td/DataTableTd";
|
||||
import {
|
||||
DataTableTfoot,
|
||||
type DataTableTfootProps,
|
||||
} from "../tfoot/DataTableTfoot";
|
||||
import { DataTableTh, type DataTableThProps } from "../th/DataTableTh";
|
||||
import {
|
||||
DataTableThead,
|
||||
type DataTableTheadProps,
|
||||
} from "../thead/DataTableThead";
|
||||
import { DataTableTr, type DataTableTrProps } from "../tr/DataTableTr";
|
||||
import { DataTableContextProvider } from "./DataTableRoot.context";
|
||||
DataTableContextProvider,
|
||||
useDataTableContext,
|
||||
} from "./DataTableRoot.context";
|
||||
|
||||
interface DataTableProps
|
||||
extends React.HTMLAttributes<HTMLTableElement>, SelectionProps {
|
||||
children: React.ReactNode;
|
||||
interface DataTableProps<T>
|
||||
extends React.HTMLAttributes<HTMLTableElement>, TableSortOptions {
|
||||
children?: never;
|
||||
/**
|
||||
* Controls vertical cell padding.
|
||||
* @default "normal"
|
||||
@@ -59,7 +66,7 @@ interface DataTableProps
|
||||
truncateContent?: boolean; // TODO: Consider making this default false when layout=auto, and maybe disallow it but add a wrap prop on the td-comp.
|
||||
/**
|
||||
* Enables keyboard navigation for table rows and cells.
|
||||
* @default false
|
||||
* @default true
|
||||
*/
|
||||
withKeyboardNav?: boolean;
|
||||
/**
|
||||
@@ -83,215 +90,473 @@ interface DataTableProps
|
||||
* @default "fixed"
|
||||
*/
|
||||
layout?: "fixed" | "auto";
|
||||
/**
|
||||
* Defines the columns of the table and how to render them.
|
||||
*
|
||||
*
|
||||
* Each column definition should have a unique `id` (or use the column index as fallback) and a `cell`-renderer function that takes the row data as argument and returns a React node.
|
||||
*/
|
||||
columnDefinitions: ColumnDefinitions<T>;
|
||||
/**
|
||||
* The data to display in the table.
|
||||
*
|
||||
*
|
||||
* Each object in the array represents a row, and the properties of the object are used to render the cells based on the `columnDefinitions`.
|
||||
*/
|
||||
data: T[];
|
||||
/**
|
||||
* Function to get unique row id from row data.
|
||||
*
|
||||
*
|
||||
* If not provided, the row index will be used as id. This can cause issues if your data changes dynamically, so it's recommended to provide a stable id if possible.
|
||||
*/
|
||||
getRowId?: (rowData: T) => string | number;
|
||||
/**
|
||||
* Sticky columns that remain visible when horizontally scrolling the table.
|
||||
*
|
||||
* You can specify 1 sticky column on the left and 1 on the right.
|
||||
*/
|
||||
stickyColumns?: {
|
||||
first?: "1";
|
||||
last?: "1";
|
||||
};
|
||||
/**
|
||||
* @default true
|
||||
*/
|
||||
stickyHeader?: boolean;
|
||||
/**
|
||||
* Callback invoked when a data row is clicked.
|
||||
* Not called when clicking header, loading, or empty-state rows.
|
||||
*/
|
||||
onRowClick?: (
|
||||
rowId: string | number,
|
||||
event: React.MouseEvent<HTMLTableRowElement>,
|
||||
) => void;
|
||||
/**
|
||||
* Content to render when `data` is empty.
|
||||
* Rendered inside a `DataTable.EmptyState` row spanning all columns.
|
||||
*/
|
||||
emptyState?: React.ReactNode;
|
||||
loading?: {
|
||||
/**
|
||||
* Shows the table in a loading state.
|
||||
*
|
||||
* - When `loadingState` is provided, it is rendered inside a `DataTable.LoadingState` row.
|
||||
* - When `loadingState` is **not** provided, skeleton placeholder rows are rendered instead.
|
||||
* @default false
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
/**
|
||||
* Custom content to render when `isLoading` is `true`.
|
||||
* Rendered inside a `DataTable.LoadingState` row spanning all columns.
|
||||
* When omitted, skeleton rows are rendered based on `loadingRows`.
|
||||
*/
|
||||
loadingState?: React.ReactNode;
|
||||
/**
|
||||
* Number of skeleton rows to render when `isLoading` is `true` and no `loadingState` is provided.
|
||||
*
|
||||
*
|
||||
* If not provided, the rendered content will get a temporarily overlay while loading
|
||||
*/
|
||||
loadingRows?: number;
|
||||
/**
|
||||
* Visually hidden label announced to screen readers when skeleton rows are shown.
|
||||
* Only used when `isLoading` is `true` and no `loadingState` is provided.
|
||||
* @default "Laster innhold"
|
||||
*/
|
||||
loadingLabel?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to get sub-rows for a given row, used for nested rows.
|
||||
* When provided, an expand toggle column is added automatically.
|
||||
*
|
||||
*
|
||||
* TODO:
|
||||
* - Table might need to be implemented with role="treegrid" for a11y when having nested rows.
|
||||
*/
|
||||
selection?: SelectionProps;
|
||||
subRows?: SubRowsProps<T>;
|
||||
detailsPanel?: DetailsPanelProps<T>;
|
||||
}
|
||||
|
||||
interface DataTableRootComponent extends React.ForwardRefExoticComponent<
|
||||
DataTableProps & React.RefAttributes<HTMLTableElement>
|
||||
> {
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableCaptionProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Caption>
|
||||
* Lorem ipsum
|
||||
* </DataTable.Caption
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Caption: typeof DataTableCaption;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableTheadProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Thead>
|
||||
* ... TODO
|
||||
* </DataTable.Thead>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Thead: typeof DataTableThead;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableTbodyProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Tbody>
|
||||
* ... TODO
|
||||
* </DataTable.Tbody>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Tbody: typeof DataTableTbody;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableTrProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Tr>
|
||||
* ... TODO
|
||||
* </DataTable.Tr
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Tr: typeof DataTableTr;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableThProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* ```
|
||||
*/
|
||||
Th: typeof DataTableTh;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableTdProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Tbody>
|
||||
* <DataTable.Td>
|
||||
* Lorem ipsum
|
||||
* </DataTable.Td>
|
||||
* <DataTable.Td>
|
||||
* Dolor sit amet
|
||||
* </DataTable.Td>
|
||||
* </DataTable.Tbody>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Td: typeof DataTableTd;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableTfootProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.Tfoot>
|
||||
* ...
|
||||
* </DataTable.Tfoot>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
Tfoot: typeof DataTableTfoot;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableEmptyStateProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.TBody>
|
||||
* <DataTable.EmptyState />
|
||||
* </DataTable.TBody>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
EmptyState: typeof DataTableEmptyState;
|
||||
/**
|
||||
* @see 🏷️ {@link DataTableEmptyStateProps}
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DataTable>
|
||||
* <DataTable.TBody>
|
||||
* <DataTable.LoadingState />
|
||||
* </DataTable.TBody>
|
||||
* </DataTable>
|
||||
* ```
|
||||
*/
|
||||
LoadingState: typeof DataTableLoadingState;
|
||||
function DataTableInner<T>(
|
||||
{
|
||||
className,
|
||||
id,
|
||||
rowDensity = "normal",
|
||||
withKeyboardNav = true,
|
||||
zebraStripes = false,
|
||||
truncateContent = true,
|
||||
shouldBlockNavigation,
|
||||
layout = "fixed",
|
||||
selection,
|
||||
data,
|
||||
columnDefinitions,
|
||||
getRowId,
|
||||
stickyColumns,
|
||||
stickyHeader = true,
|
||||
sort: sortProp,
|
||||
defaultSort = [],
|
||||
onSortChange,
|
||||
onRowClick,
|
||||
emptyState,
|
||||
loading,
|
||||
detailsPanel,
|
||||
subRows,
|
||||
...rest
|
||||
}: DataTableProps<T>,
|
||||
forwardedRef: React.ForwardedRef<HTMLTableElement>,
|
||||
) {
|
||||
const { sortState, onSortClick } = useTableSort({
|
||||
defaultSort,
|
||||
onSortChange,
|
||||
sort: sortProp,
|
||||
});
|
||||
|
||||
const tableItems = useTableItems({
|
||||
items: data,
|
||||
getRowId,
|
||||
subRows,
|
||||
});
|
||||
|
||||
const tableSelectionState = useTableSelection({
|
||||
selection,
|
||||
visibleRowIds: tableItems.visibleRowIds,
|
||||
childRowIdsById: tableItems.childRowIdsById,
|
||||
});
|
||||
|
||||
const { columns, stickySelection } = useColumnOptions<T>(columnDefinitions, {
|
||||
stickyColumns,
|
||||
selectionMode: tableSelectionState.selection.selectionMode,
|
||||
});
|
||||
|
||||
const {
|
||||
isLoading = false,
|
||||
loadingState,
|
||||
loadingRows,
|
||||
loadingLabel = "Laster innhold",
|
||||
} = loading || {};
|
||||
|
||||
const fullWidthColSpan = useMemo(() => {
|
||||
return (
|
||||
columns.length +
|
||||
(layout === "fixed" ? 1 : 0) +
|
||||
(tableSelectionState.selection.selectionMode !== "none" ? 1 : 0) +
|
||||
(detailsPanel?.getContent ? 1 : 0)
|
||||
);
|
||||
}, [
|
||||
columns,
|
||||
layout,
|
||||
tableSelectionState.selection.selectionMode,
|
||||
detailsPanel,
|
||||
]);
|
||||
|
||||
const tableId = useId(id);
|
||||
|
||||
return (
|
||||
<DataTableContextProvider
|
||||
layout={layout}
|
||||
withKeyboardNav={withKeyboardNav}
|
||||
selectionState={tableSelectionState}
|
||||
stickySelection={stickySelection}
|
||||
stickyHeader={stickyHeader}
|
||||
tableId={tableId}
|
||||
showLoadingSkeletons={isLoading && loadingState == null}
|
||||
onRowClick={onRowClick}
|
||||
isLoading={isLoading}
|
||||
showLoadingOverlay={isLoading && !loadingState && !loadingRows}
|
||||
columns={columns}
|
||||
fullWidthColSpan={fullWidthColSpan}
|
||||
>
|
||||
<TableItemsProvider
|
||||
itemDetails={tableItems.itemDetails}
|
||||
items={tableItems.items}
|
||||
visibleRowIds={tableItems.visibleRowIds}
|
||||
onExpandedRowIdsChange={tableItems.onExpandedRowIdsChange}
|
||||
isSubRowExpanded={tableItems.isSubRowExpanded}
|
||||
>
|
||||
<DataTableDetailsPanelProvider detailsPanel={detailsPanel}>
|
||||
<TableElementWrapper
|
||||
shouldBlockNavigation={shouldBlockNavigation}
|
||||
enabled={withKeyboardNav}
|
||||
>
|
||||
<table
|
||||
{...rest}
|
||||
ref={forwardedRef}
|
||||
className={cl("aksel-data-table", className)}
|
||||
data-zebra-stripes={zebraStripes}
|
||||
data-truncate-content={truncateContent}
|
||||
data-density={rowDensity}
|
||||
data-layout={layout}
|
||||
data-loading={isLoading || undefined}
|
||||
aria-busy={isLoading || undefined}
|
||||
>
|
||||
<DataTableThead>
|
||||
<DataTableTr>
|
||||
{columns.map(({ isSticky, colDef }) => {
|
||||
const sortEntry = sortState.find(
|
||||
(s) => s.columnId === colDef.id,
|
||||
);
|
||||
const sortDirection = sortEntry?.direction ?? "none";
|
||||
return (
|
||||
<DataTableColumnHeader
|
||||
resizable={colDef.resizable}
|
||||
width={colDef.width}
|
||||
defaultWidth={colDef.defaultWidth}
|
||||
autoWidth={colDef.autoWidth}
|
||||
minWidth={colDef.minWidth}
|
||||
maxWidth={colDef.maxWidth}
|
||||
onWidthChange={colDef.onWidthChange}
|
||||
textAlign={colDef.align ?? "left"}
|
||||
key={colDef.id}
|
||||
isSticky={isSticky}
|
||||
sortable={colDef.sortable}
|
||||
sortDirection={sortDirection}
|
||||
onSortClick={(event) => onSortClick(colDef.id, event)}
|
||||
label={colDef.label}
|
||||
>
|
||||
{colDef.header ?? colDef.label}
|
||||
</DataTableColumnHeader>
|
||||
);
|
||||
})}
|
||||
</DataTableTr>
|
||||
</DataTableThead>
|
||||
|
||||
<DataTableTbody>
|
||||
<DataTableTBodyContent
|
||||
loadingState={loadingState}
|
||||
loadingRows={loadingRows}
|
||||
loadingLabel={loadingLabel}
|
||||
emptyState={emptyState}
|
||||
/>
|
||||
</DataTableTbody>
|
||||
</table>
|
||||
</TableElementWrapper>
|
||||
</DataTableDetailsPanelProvider>
|
||||
</TableItemsProvider>
|
||||
</DataTableContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO Component description etc.
|
||||
*
|
||||
* **NB:** To get sticky headers, you have to set a height restriction on the table container. You can use VStack for this:
|
||||
* TODO example
|
||||
* Temp optimization to avoid re-renders on every keyboard-move, selection change etc
|
||||
*/
|
||||
const DataTable = forwardRef<HTMLTableElement, DataTableProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
rowDensity = "normal",
|
||||
withKeyboardNav = false,
|
||||
zebraStripes = false,
|
||||
truncateContent = true,
|
||||
shouldBlockNavigation,
|
||||
layout = "fixed",
|
||||
...rest
|
||||
},
|
||||
forwardedRef,
|
||||
) => {
|
||||
const { tabIndex, setTableRef } = useTableKeyboardNav({
|
||||
enabled: withKeyboardNav,
|
||||
shouldBlockNavigation,
|
||||
function TableElementWrapper({
|
||||
children,
|
||||
enabled,
|
||||
shouldBlockNavigation,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
shouldBlockNavigation?: (event: KeyboardEvent) => boolean;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const [applyStickyStyles, setApplyStickyStyles] = useState<boolean>(false);
|
||||
|
||||
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const tableRef = useRef<HTMLTableElement>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const { tabIndex, setTableRef } = useTableKeyboardNav({
|
||||
enabled,
|
||||
shouldBlockNavigation,
|
||||
});
|
||||
|
||||
const mergedTableRefs = useMergeRefs(tableRef, setTableRef);
|
||||
|
||||
const updateStickyStyles = useCallback(() => {
|
||||
if (!tableWrapperRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const doesWrapperHasScroll =
|
||||
tableWrapperRef.current.scrollWidth > tableWrapperRef.current.clientWidth;
|
||||
|
||||
setApplyStickyStyles(doesWrapperHasScroll);
|
||||
}, []);
|
||||
|
||||
const scheduleStickyStylesUpdate = useCallback(() => {
|
||||
if (rafRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
updateStickyStyles();
|
||||
});
|
||||
}, [updateStickyStyles]);
|
||||
|
||||
const mergedRef = useMergeRefs(forwardedRef, setTableRef);
|
||||
useEffect(
|
||||
function observeAndUpdateStickyStyles() {
|
||||
const tableWrapperElement = tableWrapperRef.current;
|
||||
|
||||
if (!tableWrapperElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleResize = () => scheduleStickyStylesUpdate();
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
let resizeObserver: ResizeObserver | undefined;
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(tableWrapperElement);
|
||||
if (tableRef.current) {
|
||||
resizeObserver.observe(tableRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleStickyStylesUpdate();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
resizeObserver?.disconnect();
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
},
|
||||
[scheduleStickyStylesUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="aksel-data-table__border-wrapper">
|
||||
<div ref={tableWrapperRef} className="aksel-data-table__scroll-wrapper">
|
||||
<Slot
|
||||
tabIndex={tabIndex}
|
||||
/* @ts-expect-error Ref is not typed correctly to handle this case */
|
||||
ref={mergedTableRefs}
|
||||
data-scroll={applyStickyStyles ? "true" : undefined}
|
||||
>
|
||||
{children}
|
||||
</Slot>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataTableTBodyContentProps {
|
||||
loadingState: React.ReactNode;
|
||||
loadingLabel: string;
|
||||
loadingRows?: number;
|
||||
emptyState: React.ReactNode;
|
||||
}
|
||||
|
||||
function DataTableTBodyContent({
|
||||
loadingState,
|
||||
loadingRows,
|
||||
loadingLabel,
|
||||
emptyState,
|
||||
}: DataTableTBodyContentProps) {
|
||||
const { items, itemDetails, visibleRowIds } = useTableItemsContext();
|
||||
const { columns, isLoading, fullWidthColSpan } = useDataTableContext();
|
||||
|
||||
if (isLoading && loadingState != null) {
|
||||
return (
|
||||
<DataTableContextProvider
|
||||
layout={layout}
|
||||
withKeyboardNav={withKeyboardNav}
|
||||
selectionState={noSelectionState}
|
||||
stickySelection={false}
|
||||
stickyHeader={false}
|
||||
tableId={useId()}
|
||||
showLoadingSkeletons={false}
|
||||
onRowClick={undefined}
|
||||
disableRowSelectionOnClick={false}
|
||||
showLoadingOverlay={false}
|
||||
columns={[]}
|
||||
>
|
||||
<DataTableExpansionProvider>
|
||||
<div className="aksel-data-table__border-wrapper">
|
||||
<div className="aksel-data-table__scroll-wrapper">
|
||||
<table
|
||||
{...rest}
|
||||
ref={mergedRef}
|
||||
className={cl("aksel-data-table", className)}
|
||||
data-zebra-stripes={zebraStripes}
|
||||
data-truncate-content={truncateContent}
|
||||
data-density={rowDensity}
|
||||
data-layout={layout}
|
||||
tabIndex={tabIndex}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DataTableExpansionProvider>
|
||||
</DataTableContextProvider>
|
||||
<DataTableLoadingState colSpan={fullWidthColSpan}>
|
||||
{loadingState}
|
||||
</DataTableLoadingState>
|
||||
);
|
||||
},
|
||||
) as DataTableRootComponent;
|
||||
}
|
||||
|
||||
DataTable.Caption = DataTableCaption;
|
||||
DataTable.Thead = DataTableThead;
|
||||
DataTable.Tbody = DataTableTbody;
|
||||
DataTable.Th = DataTableTh;
|
||||
DataTable.Tr = DataTableTr;
|
||||
DataTable.Td = DataTableTd;
|
||||
DataTable.Tfoot = DataTableTfoot;
|
||||
DataTable.EmptyState = DataTableEmptyState;
|
||||
DataTable.LoadingState = DataTableLoadingState;
|
||||
if (isLoading && loadingRows) {
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td colSpan={fullWidthColSpan} className="aksel-sr-only">
|
||||
{loadingLabel}
|
||||
</td>
|
||||
</tr>
|
||||
{Array.from({ length: loadingRows }, (_, rowIndex) => (
|
||||
<DataTableTr key={`skeleton-row-${rowIndex}`} aria-hidden>
|
||||
{columns.map(({ isSticky, colDef }, colDefIndex) => (
|
||||
<DataTableBaseCell
|
||||
textAlign={colDef.align ?? "left"}
|
||||
key={colDef.id || colDefIndex}
|
||||
as={colDef.isRowHeader ? "th" : "td"}
|
||||
isSticky={isSticky}
|
||||
>
|
||||
<Skeleton variant="text" />
|
||||
</DataTableBaseCell>
|
||||
))}
|
||||
</DataTableTr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DataTable,
|
||||
DataTableCaption,
|
||||
DataTableEmptyState,
|
||||
DataTableLoadingState,
|
||||
DataTableTbody,
|
||||
DataTableTd,
|
||||
DataTableTfoot,
|
||||
DataTableTh,
|
||||
DataTableThead,
|
||||
DataTableTr,
|
||||
};
|
||||
if (items.length === 0 && emptyState !== undefined) {
|
||||
return (
|
||||
<DataTableEmptyState colSpan={fullWidthColSpan}>
|
||||
{emptyState}
|
||||
</DataTableEmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
const renderLoadingAnnouncement = isLoading && !loadingState && !loadingRows;
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderLoadingAnnouncement && (
|
||||
<tr>
|
||||
<td colSpan={fullWidthColSpan} className="aksel-sr-only">
|
||||
{loadingLabel}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{items.map((rowData, rowIndex) => {
|
||||
const rowId = visibleRowIds[rowIndex];
|
||||
const details = rowId != null ? itemDetails.get(rowId) : undefined;
|
||||
|
||||
/* Should in theory be impossible. Look about typing this? */
|
||||
if (!details) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasSubRows = details.children.length > 0;
|
||||
|
||||
return (
|
||||
<React.Fragment key={details.id}>
|
||||
<DataTableTr rowId={details.id}>
|
||||
{columns.map(({ isSticky, colDef }, colDefIndex) => {
|
||||
const renderNestedToggle = colDefIndex === 0 && hasSubRows;
|
||||
const renderNestedIndent =
|
||||
colDefIndex === 0 && (details.level > 0 || hasSubRows);
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
"--__axc-data-table-nested-depth": details.level,
|
||||
};
|
||||
|
||||
return (
|
||||
<DataTableBaseCell
|
||||
textAlign={colDef.align ?? "left"}
|
||||
key={colDef.id || colDefIndex}
|
||||
as={colDef.isRowHeader ? "th" : "td"}
|
||||
isSticky={isSticky}
|
||||
data-nested={renderNestedIndent || undefined}
|
||||
style={style}
|
||||
>
|
||||
{renderNestedToggle && (
|
||||
<DataTableSubRowToggle details={details} />
|
||||
)}
|
||||
{colDef.cell(rowData)}
|
||||
</DataTableBaseCell>
|
||||
);
|
||||
})}
|
||||
</DataTableTr>
|
||||
<DataTableDetailsPanelRow rowId={details.id} rowData={rowData} />
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const DataTable = forwardRef(DataTableInner) as <T>(
|
||||
props: DataTableProps<T> & React.RefAttributes<HTMLTableElement>,
|
||||
) => React.ReactElement | null;
|
||||
|
||||
export { DataTable };
|
||||
export type { DataTableProps };
|
||||
export default DataTable;
|
||||
export type {
|
||||
DataTableCaptionProps,
|
||||
DataTableEmptyStateProps,
|
||||
DataTableLoadingStateProps,
|
||||
DataTableProps,
|
||||
DataTableTbodyProps,
|
||||
DataTableTdProps,
|
||||
DataTableTfootProps,
|
||||
DataTableTheadProps,
|
||||
DataTableThProps,
|
||||
DataTableTrProps,
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
### Component: DataTable
|
||||
|
||||
**Primitive type:** Data table with optional selection, sorting, nested rows, details panels, sticky columns, column resizing, and keyboard cell navigation.
|
||||
**Reference libraries:** React Aria Table / Adobe Spectrum TableView, MUI Data Grid, Base UI table patterns. Cloudscape Table is a useful secondary reference for resize/details behavior.
|
||||
|
||||
### Launch Review Findings
|
||||
|
||||
1. **The auto API has no visible caption slot or caption prop.**
|
||||
Consumers can still pass `aria-label`/`aria-labelledby` through native table props, but `children?: never` means the preview API cannot render a native `caption` or a built-in visible summary/title. Mature table APIs usually provide at least one clear accessible naming path.
|
||||
|
||||
2. **Column resizing is present, but the preview API still reads as unfinished.**
|
||||
Resizing is effectively opt-out in fixed layout because `DataTableColumnHeader` defaults `resizable` to `true`. The interaction also still has unfinished accessibility and i18n details, and `onWidthChange` currently fires for every width step while dragging.
|
||||
|
||||
3. **Critical behaviors are under-tested for a preview launch.**
|
||||
Existing tests cover selection trees, item expansion state, and low-level keyboard helpers, but not the end-to-end behaviors users will notice first: sorting, details panels, loading states, row click behavior, column resizing, and selection accessibility.
|
||||
|
||||
### Feature Gap Table
|
||||
|
||||
| # | Feature | Status | Priority | Reference | Notes |
|
||||
| --- | --------------------------------------------------- | ---------- | --------------- | ---------------------------- | ----------------------------------------------------------------------- |
|
||||
| 1 | Arrow/Home/End keyboard cell navigation | ✅ Present | — | React Aria, MUI | Good base coverage in helper tests. |
|
||||
| 2 | Controlled/uncontrolled sorting | ✅ Present | — | React Aria, MUI | Supports controlled and uncontrolled sort arrays. |
|
||||
| 3 | Multi-column sorting | ✅ Present | — | MUI, Spectrum | Shift-click is supported. |
|
||||
| 4 | Controlled/uncontrolled row selection | ✅ Present | — | React Aria, MUI | Single and multiple selection both exist. |
|
||||
| 5 | Controlled/uncontrolled nested row expansion | ✅ Present | — | MUI, Spectrum | `subRows` API is already solid for preview. |
|
||||
| 6 | Controlled/uncontrolled details panel expansion | ✅ Present | — | MUI | Good baseline, limited to top-level rows by design. |
|
||||
| 7 | Loading overlay / loading rows / empty state | ✅ Present | — | MUI, Spectrum | Baseline states exist. |
|
||||
| 9 | Visible caption / built-in accessible naming path | ❌ Missing | 🔴 Expected | React Aria, Spectrum | Auto API cannot render `caption`; users only get raw HTML attrs. |
|
||||
| 13 | Opt-in / polished column resize API | ⚠️ Partial | 🟡 Valuable | MUI, Cloudscape | Feature exists, but defaults and a11y polish are not preview-ready yet. |
|
||||
| 14 | Per-row disabled selection callback | ❌ Missing | 🟡 Valuable | React Aria, MUI | Current API only accepts disabled keys, not row-driven logic. |
|
||||
| 15 | Row-level prop/className callback | ❌ Missing | 🟡 Valuable | MUI | No way to attach row-specific attrs/classes without custom rendering. |
|
||||
| 16 | Header-group / multi-row header support in auto API | ❌ Missing | 🟢 Nice-to-have | React Aria, MUI | Current auto API is intentionally flat. |
|
||||
| 17 | Accessor shorthand for simple columns | ❌ Missing | 🟢 Nice-to-have | MUI, TanStack-style patterns | Would reduce boilerplate for simple data tables. |
|
||||
| 18 | Typeahead row navigation | ❌ Missing | 🟢 Nice-to-have | React Aria | Useful on large data sets, not necessary for first preview. |
|
||||
| 19 | RTL-aware sticky/nesting layout | ❌ Missing | 🟢 Nice-to-have | React Aria, MUI | Current sticky/nesting CSS is mostly physical, not logical. |
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
#### 9. Visible caption / built-in accessible naming path
|
||||
|
||||
- **What it does:** Lets consumers provide a native table caption or a clearly supported naming API without dropping to raw HTML attributes.
|
||||
- **Reference behavior:** React Aria tables require an accessible label; Spectrum and MUI also expose a clear naming path.
|
||||
- **Approach here:** Keep `children` forbidden, but add a `caption?: React.ReactNode` prop that renders a native `caption`. Optionally add `captionProps` for styling hooks.
|
||||
- **Props/API additions needed:** `caption?: React.ReactNode`, optionally `captionProps?: React.HTMLAttributes<HTMLTableCaptionElement>`.
|
||||
|
||||
#### 13. Opt-in / polished column resize API
|
||||
|
||||
- **What it does:** Makes resize behavior predictable and avoids surprising every preview consumer with advanced header affordances.
|
||||
- **Reference behavior:** MUI and Cloudscape treat column resize as an explicit feature and spend a lot of API surface on labels, limits, and commit behavior.
|
||||
- **Approach here:** Default `resizable` to `false`, add missing slider aria metadata, and only call `onWidthChange` on commit or with a small debounce. Also move hard-coded Norwegian labels into the same i18n pattern as the rest of the component.
|
||||
- **Props/API additions needed:** No new public props required for a first pass. The main change is default behavior and polish.
|
||||
|
||||
#### 14. Per-row disabled selection callback
|
||||
|
||||
- **What it does:** Lets consumers disable selection from row data instead of precomputing key arrays.
|
||||
- **Reference behavior:** Many mature table APIs allow row-driven selection rules.
|
||||
- **Approach here:** Resolve disabled state during row collection so selection helpers can still work with ids internally.
|
||||
- **Props/API additions needed:** `isRowSelectionDisabled?: (rowData: T) => boolean`.
|
||||
|
||||
#### 15. Row-level prop/className callback
|
||||
|
||||
- **What it does:** Lets consumers add row-specific classes, test hooks, aria hooks, and state styling without replacing the table implementation.
|
||||
- **Reference behavior:** MUI exposes row-class callbacks and row metadata hooks.
|
||||
- **Approach here:** Apply callbacks when rendering `DataTableTr` so the row id and row data are both available.
|
||||
- **Props/API additions needed:** `getRowProps?: (rowData: T, rowId: string | number) => React.HTMLAttributes<HTMLTableRowElement>` or a narrower `getRowClassName` if you want to keep the API tighter.
|
||||
|
||||
### Internal Refactors
|
||||
|
||||
1. Split the current root into a small orchestration hook plus presentational sub-components. `DataTableRoot.tsx` currently owns state setup, context assembly, loading/empty rendering, row rendering, and sticky-wrapper logic in one file.
|
||||
|
||||
2. Replace object-keyed `itemDetails` lookups with id-keyed row entries. This fixes the duplicate-object bug and simplifies details-panel and selection wiring.
|
||||
|
||||
3. Unify loading-state presence checks. `DataTableTBodyContent` uses nullish checks for `loadingState`, while `showLoadingOverlay` in the root uses truthiness checks; those should match.
|
||||
|
||||
4. Make localization an explicit seam. The component currently hard-codes visible and aria labels such as sort-resize instructions, expand/collapse labels, and selection labels directly in render code.
|
||||
|
||||
5. Revisit the resize default and commit path before large-table performance work. Continuous `onWidthChange` callbacks can easily push rerenders through the whole table if consumers control widths externally.
|
||||
|
||||
6. Decide whether the preview API is a plain table or the start of a data-grid abstraction. A few current seams, especially keyboard navigation and selection, are already grid-like, while captioning and row-header semantics still assume a plain table model.
|
||||
|
||||
### Test Gaps Before Preview
|
||||
|
||||
1. Sorting interaction story/test covering click, keyboard activation, and multi-sort.
|
||||
2. Details-panel story/test covering expand, collapse, `aria-controls`, and controlled mode.
|
||||
3. Loading/empty-state tests covering overlay vs skeleton vs custom loading content.
|
||||
4. Row-click tests covering selection interaction, text-selection guard, and interactive-content guard.
|
||||
5. Resize tests covering keyboard resize, pointer resize, and form embedding.
|
||||
6. Accessibility test coverage for selection labels and row-header semantics once those are implemented.
|
||||
|
||||
### Suggested Order For Follow-up Work
|
||||
|
||||
1. Fix selection labeling and row-header semantics together.
|
||||
2. Add a built-in caption/naming API.
|
||||
3. Harden resize defaults and button semantics.
|
||||
4. Refactor row metadata to be id-based.
|
||||
5. Fill the missing user-facing stories/tests around sort, details, loading, and row click.
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "@navikt/aksel-icons";
|
||||
import { Button } from "../../../button";
|
||||
import { type ItemDetail, useTableItemsContext } from "../hooks/useTableItems";
|
||||
|
||||
function DataTableSubRowToggle({ details }: { details: ItemDetail<any> }) {
|
||||
const { isSubRowExpanded, onExpandedRowIdsChange } = useTableItemsContext();
|
||||
|
||||
const subRows = details.children;
|
||||
const hasSubRows = subRows && subRows.length > 0;
|
||||
const isRowExpanded = isSubRowExpanded(details.id);
|
||||
|
||||
return (
|
||||
<div className="aksel-data-table__nested-toggle">
|
||||
{hasSubRows && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
data-color="neutral"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExpandedRowIdsChange(details.id);
|
||||
}}
|
||||
aria-expanded={isRowExpanded}
|
||||
aria-label={isRowExpanded ? "Skjul under-rader" : "Vis under-rader"}
|
||||
icon={
|
||||
isRowExpanded ? (
|
||||
<ChevronDownIcon aria-hidden />
|
||||
) : (
|
||||
<ChevronRightIcon aria-hidden />
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { DataTableSubRowToggle };
|
||||
@@ -15,9 +15,9 @@ import { cl, composeEventHandlers } from "../../../utils/helpers";
|
||||
import { DataTableBaseCell } from "../base-cell/DataTableBaseCell";
|
||||
import { DataTableColumnHeader } from "../column-header/DataTableColumnHeader";
|
||||
import {
|
||||
getDataTableExpansionId,
|
||||
useDataTableExpansion,
|
||||
} from "../hooks/useTableExpansion";
|
||||
getDataTableDetailsPanelId,
|
||||
useDataTableDetailsPanel,
|
||||
} from "../hooks/useTableDetailsPanel";
|
||||
import {
|
||||
useDataTableContext,
|
||||
useDataTableLocation,
|
||||
@@ -46,13 +46,8 @@ const DataTableTr = forwardRef<HTMLTableRowElement, DataTableTrProps>(
|
||||
},
|
||||
forwardedRef,
|
||||
) => {
|
||||
const {
|
||||
layout,
|
||||
stickyHeader,
|
||||
selectionState,
|
||||
onRowClick,
|
||||
disableRowSelectionOnClick,
|
||||
} = useDataTableContext();
|
||||
const { layout, stickyHeader, selectionState, onRowClick } =
|
||||
useDataTableContext();
|
||||
const { location } = useDataTableLocation();
|
||||
|
||||
const renderFillerCell = layout === "fixed" && children;
|
||||
@@ -81,7 +76,7 @@ const DataTableTr = forwardRef<HTMLTableRowElement, DataTableTrProps>(
|
||||
}
|
||||
|
||||
if (
|
||||
!disableRowSelectionOnClick &&
|
||||
!selectionState.disableRowSelectionOnClick &&
|
||||
selectionState.selection.selectionMode !== "none"
|
||||
) {
|
||||
selectionState.selection.toggleSelection(rowId);
|
||||
@@ -130,7 +125,7 @@ function RowExpansionCell({ rowId }: { rowId?: string | number }) {
|
||||
isAllExpanded,
|
||||
toggleAll,
|
||||
showExpandAll,
|
||||
} = useDataTableExpansion();
|
||||
} = useDataTableDetailsPanel();
|
||||
|
||||
if (!enableDetailsPanel) {
|
||||
return null;
|
||||
@@ -143,6 +138,7 @@ function RowExpansionCell({ rowId }: { rowId?: string | number }) {
|
||||
width={SELECTION_CELL_WIDTH}
|
||||
UNSAFE_isSelection
|
||||
data-block-keyboard-nav
|
||||
label=""
|
||||
/* isSticky={stickySelection && "start"} */
|
||||
/>
|
||||
);
|
||||
@@ -160,6 +156,7 @@ function RowExpansionCell({ rowId }: { rowId?: string | number }) {
|
||||
width={SELECTION_CELL_WIDTH}
|
||||
UNSAFE_isSelection
|
||||
data-block-keyboard-nav
|
||||
label=""
|
||||
/* isSticky={stickySelection && "start"} */
|
||||
/>
|
||||
);
|
||||
@@ -171,6 +168,7 @@ function RowExpansionCell({ rowId }: { rowId?: string | number }) {
|
||||
textAlign="center"
|
||||
width={SELECTION_CELL_WIDTH}
|
||||
UNSAFE_isSelection
|
||||
label=""
|
||||
/* isSticky={stickySelection && "start"} */
|
||||
>
|
||||
<Button
|
||||
@@ -198,7 +196,7 @@ function RowExpansionCell({ rowId }: { rowId?: string | number }) {
|
||||
|
||||
const isRowExpanded = isExpanded(rowId);
|
||||
const canExpandRow = isDetailsPanelExpandable(rowId);
|
||||
const expansionId = getDataTableExpansionId(tableId, rowId);
|
||||
const expansionId = getDataTableDetailsPanelId(tableId, rowId);
|
||||
|
||||
if (!canExpandRow) {
|
||||
return <DataTableTd UNSAFE_isSelection preventRowClick />;
|
||||
@@ -247,6 +245,7 @@ function RowSelectionCell({ rowId }: { rowId?: string | number }) {
|
||||
<DataTableColumnHeader
|
||||
width={SELECTION_CELL_WIDTH}
|
||||
UNSAFE_isSelection
|
||||
label=""
|
||||
data-block-keyboard-nav
|
||||
isSticky={stickySelection && "start"}
|
||||
/>
|
||||
@@ -274,6 +273,7 @@ function RowSelectionCell({ rowId }: { rowId?: string | number }) {
|
||||
textAlign="center"
|
||||
width={SELECTION_CELL_WIDTH}
|
||||
UNSAFE_isSelection
|
||||
label=""
|
||||
isSticky={stickySelection && "start"}
|
||||
>
|
||||
<Label htmlFor={inputId} visuallyHidden>
|
||||
@@ -289,6 +289,7 @@ function RowSelectionCell({ rowId }: { rowId?: string | number }) {
|
||||
<DataTableColumnHeader
|
||||
width={SELECTION_CELL_WIDTH}
|
||||
UNSAFE_isSelection
|
||||
label=""
|
||||
data-block-keyboard-nav
|
||||
isSticky={stickySelection && "start"}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { StoryObj } from "@storybook/react-vite";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { VStack } from "../../primitives/stack";
|
||||
import { DataTable } from "../table";
|
||||
import { DataTableColumnHeader } from "../table/column-header/DataTableColumnHeader";
|
||||
import { DataTable } from "../table/root/DataTableRoot.legacy";
|
||||
import { TokenFilter } from "./TokenFilter";
|
||||
import type {
|
||||
ExternalPropertyDefinition,
|
||||
@@ -407,10 +407,14 @@ export const WithDataTable: Story = {
|
||||
<DataTable zebraStripes layout="auto">
|
||||
<DataTable.Thead>
|
||||
<DataTable.Tr>
|
||||
<DataTableColumnHeader>Name</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Status</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>Region</DataTableColumnHeader>
|
||||
<DataTableColumnHeader>CPU</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Name">Name</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Status">
|
||||
Status
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="Region">
|
||||
Region
|
||||
</DataTableColumnHeader>
|
||||
<DataTableColumnHeader label="CPU">CPU</DataTableColumnHeader>
|
||||
</DataTable.Tr>
|
||||
</DataTable.Thead>
|
||||
<DataTable.Tbody>
|
||||
|
||||
@@ -35,6 +35,7 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
"indeterminate",
|
||||
"errorId",
|
||||
"readOnly",
|
||||
"className",
|
||||
])}
|
||||
{...omit(inputProps, ["aria-invalid", "aria-describedby"])}
|
||||
aria-describedby={
|
||||
|
||||
@@ -24,7 +24,13 @@ export const Radio = forwardRef<HTMLInputElement, RadioProps>(
|
||||
>
|
||||
<RadioInput
|
||||
ref={forwardedRef}
|
||||
{...omit(props, ["children", "size", "description", "readOnly"])}
|
||||
{...omit(props, [
|
||||
"children",
|
||||
"size",
|
||||
"description",
|
||||
"readOnly",
|
||||
"className",
|
||||
])}
|
||||
{...omit(inputProps, ["aria-invalid", "aria-describedby"])}
|
||||
aria-describedby={
|
||||
cl(inputProps["aria-describedby"], {
|
||||
|
||||
@@ -39,7 +39,8 @@ interface ModalPropsBase extends React.DialogHTMLAttributes<HTMLDialogElement> {
|
||||
/**
|
||||
* Called when the user tries to close the modal by one of the built-in methods.
|
||||
* Used if you want to ask the user for confirmation before closing.
|
||||
* @warning Will not always be called when pressing Esc. See `onCancel` for more info.
|
||||
*
|
||||
* **NB:** Will not always be called when pressing Esc. See `onCancel` for more info.
|
||||
* @returns Whether to close the modal or not
|
||||
*/
|
||||
onBeforeClose?: () => boolean;
|
||||
@@ -52,7 +53,8 @@ interface ModalPropsBase extends React.DialogHTMLAttributes<HTMLDialogElement> {
|
||||
onCancel?: React.ReactEventHandler<HTMLDialogElement>;
|
||||
/**
|
||||
* Whether to close when clicking on the backdrop.
|
||||
* @warning Users may click outside by accident. Don't use if closing can cause data loss, or the modal contains important info.
|
||||
*
|
||||
* **NB:** Users may click outside by accident. Don't use if closing can cause data loss, or the modal contains important info.
|
||||
* @default false
|
||||
*/
|
||||
closeOnBackdropClick?: boolean;
|
||||
@@ -77,13 +79,15 @@ interface ModalPropsBase extends React.DialogHTMLAttributes<HTMLDialogElement> {
|
||||
/**
|
||||
* ID of the element that labels the modal.
|
||||
* No need to set this manually if the `header` prop is used. A reference to `header.heading` will be created automatically.
|
||||
* @warning If not using `header`, you should set either `aria-labelledby` or `aria-label`.
|
||||
*
|
||||
* **NB:** If not using `header`, you should set either `aria-labelledby` or `aria-label`.
|
||||
*/
|
||||
"aria-labelledby"?: string;
|
||||
/**
|
||||
* String value that labels the modal.
|
||||
* No need to set this if the `header` prop is used.
|
||||
* @warning If not using `header`, you should set either `aria-labelledby` or `aria-label`.
|
||||
*
|
||||
* **NB:** If not using `header`, you should set either `aria-labelledby` or `aria-label`.
|
||||
*/
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ function useGlobalId(idOverride?: string): string | undefined {
|
||||
// We can't use it server-side.
|
||||
// If you want to use random values please consider the Birthday Problem: https://en.wikipedia.org/wiki/Birthday_problem
|
||||
globalId += 1;
|
||||
setDefaultId(`aksel-id-${globalId}`);
|
||||
setDefaultId(`aksel-id-${globalId}`); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
}
|
||||
}, [defaultId]);
|
||||
return id;
|
||||
|
||||
+28
-28
@@ -215,40 +215,40 @@ export const DisableOutsidePointerEventsWhileHidden = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const Layer = ({
|
||||
disableOutsidePointerEvents,
|
||||
children,
|
||||
}: {
|
||||
disableOutsidePointerEvents?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const style = {
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "red",
|
||||
};
|
||||
|
||||
return (
|
||||
<DismissableLayer
|
||||
disableOutsidePointerEvents={disableOutsidePointerEvents}
|
||||
style={style}
|
||||
>
|
||||
<button onClick={() => setOpen(false)}>Close me</button>
|
||||
{children}
|
||||
</DismissableLayer>
|
||||
);
|
||||
};
|
||||
|
||||
export const ParallelDismissableLayer = () => {
|
||||
const [single, setSingle] = useState(false);
|
||||
const [double, setDouble] = useState(false);
|
||||
const [nestedSingle, setNestedSingle] = useState(false);
|
||||
const [nestedDouble, setNestedDouble] = useState(false);
|
||||
|
||||
const Layer = ({
|
||||
disableOutsidePointerEvents,
|
||||
children,
|
||||
}: {
|
||||
disableOutsidePointerEvents?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const style = {
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "red",
|
||||
};
|
||||
|
||||
return (
|
||||
<DismissableLayer
|
||||
disableOutsidePointerEvents={disableOutsidePointerEvents}
|
||||
style={style}
|
||||
>
|
||||
<button onClick={() => setOpen(false)}>Close me</button>
|
||||
{children}
|
||||
</DismissableLayer>
|
||||
);
|
||||
};
|
||||
|
||||
const state = (_state: boolean) => (_state ? "open" : "closed");
|
||||
|
||||
return (
|
||||
|
||||
@@ -342,7 +342,7 @@ const DismissableLayer = forwardRef<HTMLDivElement, DismissableLayerProps>(
|
||||
if (disableOutsidePointerEvents) {
|
||||
if (context.layersWithOutsidePointerEventsDisabled.size === 0) {
|
||||
originalBodyPointerEvents = ownerDoc.body.style.pointerEvents;
|
||||
ownerDoc.body.style.pointerEvents = "none"; // eslint-disable-line react-hooks/immutability
|
||||
ownerDoc.body.style.pointerEvents = "none";
|
||||
}
|
||||
context.layersWithOutsidePointerEventsDisabled.add(node);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useMergeRefs,
|
||||
} from "../../hooks";
|
||||
import { DismissableLayer } from "../dismissablelayer/DismissableLayer";
|
||||
import { Floating } from "../floating/Floating";
|
||||
import { Floating, MENU_COLLISION_AVOIDANCE } from "../floating/Floating";
|
||||
import { FocusBoundary } from "../focus-boundary/FocusBoundary";
|
||||
import { RovingFocus, RovingFocusProps } from "./parts/RovingFocus";
|
||||
import {
|
||||
@@ -328,6 +328,9 @@ const MenuContentInternal = forwardRef<
|
||||
data-state={getOpenState(context.open)}
|
||||
data-aksel-menu-content=""
|
||||
dir="ltr"
|
||||
fallbackAxisSideDirection={
|
||||
MENU_COLLISION_AVOIDANCE.fallbackAxisSide
|
||||
}
|
||||
{...rest}
|
||||
ref={composedRefs}
|
||||
style={{ outline: "none", ...rest.style }}
|
||||
|
||||
@@ -36,6 +36,22 @@ import {
|
||||
transformOrigin,
|
||||
} from "./Floating.utils";
|
||||
|
||||
/**
|
||||
* Used for menus that prefer top/bottom placements and
|
||||
* use height-var to limit their height.
|
||||
*/
|
||||
const MENU_COLLISION_AVOIDANCE = {
|
||||
fallbackAxisSide: "none",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Used by regular popovers that usually aren't scrollable and are allowed to
|
||||
* freely flip to any axis of placement.
|
||||
*/
|
||||
const POPOVER_COLLISION_AVOIDANCE = {
|
||||
fallbackAxisSide: "end",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Floating Root
|
||||
*/
|
||||
@@ -189,6 +205,7 @@ interface FloatingContentProps extends HTMLAttributes<HTMLDivElement> {
|
||||
hideWhenDetached?: boolean;
|
||||
updatePositionStrategy?: "optimized" | "always";
|
||||
fallbackPlacements?: FlipOptions["fallbackPlacements"];
|
||||
fallbackAxisSideDirection?: FlipOptions["fallbackAxisSideDirection"];
|
||||
onPlaced?: () => void;
|
||||
/**
|
||||
* @default true
|
||||
@@ -225,6 +242,7 @@ const FloatingContent = forwardRef<HTMLDivElement, FloatingContentProps>(
|
||||
fallbackPlacements,
|
||||
enabled = true,
|
||||
autoUpdateWhileMounted = true,
|
||||
fallbackAxisSideDirection = POPOVER_COLLISION_AVOIDANCE.fallbackAxisSide,
|
||||
...contentProps
|
||||
}: FloatingContentProps,
|
||||
forwardedRef,
|
||||
@@ -245,10 +263,31 @@ const FloatingContent = forwardRef<HTMLDivElement, FloatingContentProps>(
|
||||
const desiredPlacement = (side +
|
||||
(align !== "center" ? "-" + align : "")) as Placement;
|
||||
|
||||
/**
|
||||
* Create a bias to the preferred side.
|
||||
* On iOS, when the mobile software keyboard opens, the input is exactly centered
|
||||
* in the viewport, but this can cause it to flip to the top undesirably.
|
||||
*/
|
||||
const bias = 1;
|
||||
const biasTop = side === "bottom" ? bias : 0;
|
||||
const biasBottom = side === "top" ? bias : 0;
|
||||
const biasLeft = side === "right" ? bias : 0;
|
||||
const biasRight = side === "left" ? bias : 0;
|
||||
|
||||
const collisionPadding =
|
||||
typeof collisionPaddingProp === "number"
|
||||
? collisionPaddingProp
|
||||
: { top: 0, right: 0, bottom: 0, left: 0, ...collisionPaddingProp };
|
||||
? {
|
||||
top: collisionPaddingProp + biasTop,
|
||||
right: collisionPaddingProp + biasRight,
|
||||
bottom: collisionPaddingProp + biasBottom,
|
||||
left: collisionPaddingProp + biasLeft,
|
||||
}
|
||||
: {
|
||||
top: (collisionPaddingProp.top || 0) + biasTop,
|
||||
right: (collisionPaddingProp.right || 0) + biasRight,
|
||||
bottom: (collisionPaddingProp.bottom || 0) + biasBottom,
|
||||
left: (collisionPaddingProp.left || 0) + biasLeft,
|
||||
};
|
||||
|
||||
const boundary = Array.isArray(collisionBoundary)
|
||||
? collisionBoundary
|
||||
@@ -256,20 +295,13 @@ const FloatingContent = forwardRef<HTMLDivElement, FloatingContentProps>(
|
||||
|
||||
const hasExplicitBoundaries = boundary.length > 0;
|
||||
|
||||
/**
|
||||
* .filter(x => x !== null) does not narrow the type of the array enough.
|
||||
*/
|
||||
function isNotNull<T>(value: T | null): value is T {
|
||||
return value !== null;
|
||||
}
|
||||
|
||||
/* https://floating-ui.com/docs/detectOverflow#boundary */
|
||||
const detectOverflowOptions: FlipOptions = {
|
||||
padding: collisionPadding,
|
||||
boundary: boundary.filter(isNotNull),
|
||||
// with `strategy: 'fixed'`, this is the only way to get it to respect boundaries
|
||||
altBoundary: hasExplicitBoundaries,
|
||||
/* https://floating-ui.com/docs/flip#fallbackaxissidedirection */
|
||||
fallbackAxisSideDirection: "end",
|
||||
fallbackAxisSideDirection,
|
||||
fallbackPlacements,
|
||||
};
|
||||
|
||||
@@ -308,7 +340,11 @@ const FloatingContent = forwardRef<HTMLDivElement, FloatingContentProps>(
|
||||
crossAxis: false,
|
||||
limiter: limitShift(),
|
||||
}),
|
||||
avoidCollisions && flip({ ...detectOverflowOptions }),
|
||||
avoidCollisions &&
|
||||
flip({
|
||||
...detectOverflowOptions,
|
||||
padding: collisionPadding,
|
||||
}),
|
||||
size({
|
||||
...detectOverflowOptions,
|
||||
apply: ({ elements, rects, availableWidth, availableHeight }) => {
|
||||
@@ -437,7 +473,14 @@ const FloatingContent = forwardRef<HTMLDivElement, FloatingContentProps>(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* .filter(x => x !== null) does not narrow the type of the array enough.
|
||||
*/
|
||||
function isNotNull<T>(value: T | null): value is T {
|
||||
return value !== null;
|
||||
}
|
||||
|
||||
Floating.Anchor = FloatingAnchor;
|
||||
Floating.Content = FloatingContent;
|
||||
|
||||
export { Floating };
|
||||
export { Floating, MENU_COLLISION_AVOIDANCE, POPOVER_COLLISION_AVOIDANCE };
|
||||
@@ -65,7 +65,7 @@ function createStrictContext<T>(options: {
|
||||
|
||||
function Provider({ children, ...context }: ProviderProps<T>) {
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Object.values(context) includes all dependencies.
|
||||
const value = React.useMemo(() => context, Object.values(context)) as T; // eslint-disable-line react-hooks/exhaustive-deps, react-hooks/use-memo
|
||||
const value = React.useMemo(() => context, Object.values(context)) as T; // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return <Context.Provider value={value}>{children}</Context.Provider>;
|
||||
}
|
||||
|
||||
@@ -2,28 +2,31 @@
|
||||
import { useState } from "react";
|
||||
import { useEventCallback } from "./useEventCallback";
|
||||
|
||||
export interface UseControllableStateProps<T> {
|
||||
value?: T;
|
||||
defaultValue: T | (() => T);
|
||||
onChange?: (value: T) => void;
|
||||
export interface UseControllableStateProps<
|
||||
StateT,
|
||||
ChangeT extends StateT = StateT,
|
||||
> {
|
||||
value?: StateT;
|
||||
defaultValue: StateT | (() => StateT);
|
||||
onChange?: (value: ChangeT) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* `useControllableState` returns the state and function that updates the state, just like React.useState does.
|
||||
*/
|
||||
export function useControllableState<T>({
|
||||
export function useControllableState<StateT, ChangeT extends StateT = StateT>({
|
||||
value: valueProp,
|
||||
defaultValue,
|
||||
onChange,
|
||||
}: UseControllableStateProps<T>) {
|
||||
}: UseControllableStateProps<StateT, ChangeT>) {
|
||||
const onChangeProp = useEventCallback(onChange);
|
||||
|
||||
const [uncontrolledState, setUncontrolledState] = useState(defaultValue);
|
||||
const controlled = valueProp !== undefined;
|
||||
const value = controlled ? valueProp : uncontrolledState;
|
||||
|
||||
const setValue = useEventCallback((next: React.SetStateAction<T>) => {
|
||||
const setter = next as (prevState?: T) => T;
|
||||
const setValue = useEventCallback((next: React.SetStateAction<ChangeT>) => {
|
||||
const setter = next as (prevState?: StateT) => ChangeT;
|
||||
const nextValue = typeof next === "function" ? setter(value) : next;
|
||||
|
||||
if (!controlled) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useRefWithInit } from "./useRefWithInit";
|
||||
function useValueAsRef<T>(value: T) {
|
||||
const latest = useRefWithInit(createLatestRef, value).current!;
|
||||
|
||||
latest.next = value;
|
||||
latest.next = value; // eslint-disable-line react-hooks/immutability
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useClientLayoutEffect(latest.effect);
|
||||
|
||||
@@ -7,218 +7,139 @@ interface TranslationMap {
|
||||
export default {
|
||||
global: {
|
||||
dateLocale: nb,
|
||||
/** @default "Vis mer" */
|
||||
showMore: "Vis mer",
|
||||
/** @default "Vis mindre" */
|
||||
showLess: "Vis mindre",
|
||||
/** @default "Skrivebeskyttet" */
|
||||
readOnly: "Skrivebeskyttet",
|
||||
/** @default "Lukk" */
|
||||
close: "Lukk",
|
||||
/** @default "Feil" */
|
||||
error: "Feil",
|
||||
/** @default "Informasjon" */
|
||||
info: "Informasjon",
|
||||
/** @default "Suksess" */
|
||||
success: "Suksess",
|
||||
/** @default "Advarsel" */
|
||||
warning: "Advarsel",
|
||||
/** @default "Kunngjøring" */
|
||||
announcement: "Kunngjøring",
|
||||
},
|
||||
Chips: {
|
||||
Removable: {
|
||||
/** Will be appended to the accessible name for the button.
|
||||
* @default "slett" */
|
||||
/** Will be appended to the accessible name for the button. */
|
||||
labelSuffix: "slett",
|
||||
},
|
||||
},
|
||||
Combobox: {
|
||||
/** The input value will be appended to the end of this text, e.g. `Legg til "input value"`.
|
||||
* @default "Legg til" */
|
||||
/** The input value will be appended to the end of this text, e.g. `Legg til "input value"`. */
|
||||
addOption: "Legg til",
|
||||
/** @default "Ingen søketreff" */
|
||||
noMatches: "Ingen søketreff",
|
||||
/** Loader title
|
||||
* @default "Søker…" */
|
||||
/** Loader title */
|
||||
loading: "Søker…",
|
||||
/** @default "{selected} av maks {limit} er valgt." */
|
||||
maxSelected: "{selected} av maks {limit} er valgt.",
|
||||
},
|
||||
CopyButton: {
|
||||
/** @default "Kopier" */
|
||||
title: "Kopier",
|
||||
/** @default "Kopiert!" */
|
||||
activeText: "Kopiert!",
|
||||
},
|
||||
DatePicker: {
|
||||
/** @default "Velg dato" */
|
||||
chooseDate: "Velg dato",
|
||||
/** @default "Velg datoer" */
|
||||
chooseDates: "Velg datoer",
|
||||
/** @default "Velg start- og sluttdato" */
|
||||
chooseDateRange: "Velg start- og sluttdato",
|
||||
/** @default "Velg måned" */
|
||||
chooseMonth: "Velg måned",
|
||||
/** @default "Uke" */
|
||||
week: "Uke",
|
||||
/** @default "Uke {week}" */
|
||||
weekNumber: "Uke {week}",
|
||||
/** @default "Velg uke {week}" */
|
||||
selectWeekNumber: "Velg uke {week}",
|
||||
/** @default "Måned" */
|
||||
month: "Måned",
|
||||
/** @default "Gå til neste måned" */
|
||||
goToNextMonth: "Gå til neste måned",
|
||||
/** @default "Gå til forrige måned" */
|
||||
goToPreviousMonth: "Gå til forrige måned",
|
||||
/** @default "År" */
|
||||
year: "År",
|
||||
/** @default "Gå til neste år" */
|
||||
goToNextYear: "Gå til neste år",
|
||||
/** @default "Gå til forrige år" */
|
||||
goToPreviousYear: "Gå til forrige år",
|
||||
/** @default "Åpne datovelger" */
|
||||
openDatePicker: "Åpne datovelger",
|
||||
/** @default "Åpne månedsvelger" */
|
||||
openMonthPicker: "Åpne månedsvelger",
|
||||
/** @default "Lukk datovelger" */
|
||||
closeDatePicker: "Lukk datovelger",
|
||||
/** @default "Lukk månedsvelger" */
|
||||
closeMonthPicker: "Lukk månedsvelger",
|
||||
},
|
||||
ErrorSummary: {
|
||||
/** @default "Du må rette disse feilene før du kan fortsette:" */
|
||||
heading: "Du må rette disse feilene før du kan fortsette:",
|
||||
},
|
||||
FileUpload: {
|
||||
dropzone: {
|
||||
/** @default "Velg fil" */
|
||||
button: "Velg fil",
|
||||
/** @default "Velg filer" */
|
||||
buttonMultiple: "Velg filer",
|
||||
/** @default "Dra og slipp filen her" */
|
||||
dragAndDrop: "Dra og slipp filen her",
|
||||
/** @default "Dra og slipp filer her" */
|
||||
dragAndDropMultiple: "Dra og slipp filer her",
|
||||
/** @default "Slipp" */
|
||||
drop: "Slipp",
|
||||
/** @default "eller" */
|
||||
or: "eller",
|
||||
/** @default "Filopplasting er deaktivert" */
|
||||
disabled: "Filopplasting er deaktivert",
|
||||
/** @default "Du kan ikke laste opp flere filer" */
|
||||
disabledFilelimit: "Du kan ikke laste opp flere filer",
|
||||
},
|
||||
item: {
|
||||
/** @default "Prøv å laste opp filen på nytt" */
|
||||
retryButtonTitle: "Prøv å laste opp filen på nytt",
|
||||
/** @default "Slett filen" */
|
||||
deleteButtonTitle: "Slett filen",
|
||||
/** @default "Laster opp…" */
|
||||
uploading: "Laster opp…",
|
||||
/** @default "Laster ned…" */
|
||||
downloading: "Laster ned…",
|
||||
},
|
||||
},
|
||||
FormProgress: {
|
||||
/** @default "Steg {activeStep} av {totalSteps}" */
|
||||
step: "Steg {activeStep} av {totalSteps}",
|
||||
/** @default "Vis alle steg" */
|
||||
showAllSteps: "Vis alle steg",
|
||||
/** @default "Skjul alle steg" */
|
||||
hideAllSteps: "Skjul alle steg",
|
||||
},
|
||||
FormSummary: {
|
||||
/** @default "Endre svar" */
|
||||
editAnswer: "Endre svar",
|
||||
},
|
||||
GuidePanel: {
|
||||
/** @default "Illustrasjon av veileder" */
|
||||
illustrationLabel: "Illustrasjon av veileder",
|
||||
},
|
||||
HelpText: {
|
||||
/** @default "Mer informasjon" */
|
||||
title: "Mer informasjon",
|
||||
},
|
||||
Loader: {
|
||||
/** @default "Venter…" */
|
||||
title: "Venter…",
|
||||
},
|
||||
Pagination: {
|
||||
/** @default "Forrige" */
|
||||
previous: "Forrige",
|
||||
/** @default "Neste" */
|
||||
next: "Neste",
|
||||
},
|
||||
Process: {
|
||||
/** @default "Aktiv" */
|
||||
active: "Aktiv",
|
||||
},
|
||||
ProgressBar: {
|
||||
/** @default "{current} av {max}" */
|
||||
progress: "{current} av {max}",
|
||||
/** @default "Fremdrift kan ikke beregnes, antatt tid er {seconds} sekunder." */
|
||||
progressUnknown:
|
||||
"Fremdrift kan ikke beregnes, antatt tid er {seconds} sekunder.",
|
||||
},
|
||||
Search: {
|
||||
/** @default "Tøm feltet" */
|
||||
clear: "Tøm feltet",
|
||||
/** @default "Søk" */
|
||||
search: "Søk",
|
||||
},
|
||||
Textarea: {
|
||||
/** Screen readers only
|
||||
* @default "Tekstområde med plass til {maxLength} tegn." */
|
||||
/** Screen readers only */
|
||||
maxLength: "Tekstområde med plass til {maxLength} tegn.",
|
||||
/** @default "{chars} tegn for mye" */
|
||||
charsTooMany: "{chars} tegn for mye",
|
||||
/** @default "{chars} tegn igjen" */
|
||||
charsLeft: "{chars} tegn igjen",
|
||||
},
|
||||
Timeline: {
|
||||
/** @default "dd.MM.yyyy" */
|
||||
dateFormat: "dd.MM.yyyy",
|
||||
/** @default "dd.MM" */
|
||||
dayFormat: "dd.MM",
|
||||
/** @default "MMM yy" */
|
||||
monthFormat: "MMM yy",
|
||||
/** @default "yyyy" */
|
||||
yearFormat: "yyyy",
|
||||
Row: {
|
||||
/** @default "Ingen perioder" */
|
||||
noPeriods: "Ingen perioder",
|
||||
/** @default "{start} til {end}" */
|
||||
period: "{start} til {end}",
|
||||
},
|
||||
Period: {
|
||||
/** @default "Suksess" */
|
||||
success: "Suksess",
|
||||
/** @default "Advarsel" */
|
||||
warning: "Advarsel",
|
||||
/** @default "Fare" */
|
||||
danger: "Fare",
|
||||
/** @default "Info" */
|
||||
info: "Info",
|
||||
/** @default "Nøytral" */
|
||||
neutral: "Nøytral",
|
||||
/** @default "{status} fra {start} til {end}" */
|
||||
period: "{status} fra {start} til {end}",
|
||||
},
|
||||
Pin: {
|
||||
/** @default "Pin: {date}" */
|
||||
pin: "Pin: {date}",
|
||||
},
|
||||
Zoom: {
|
||||
/** @default "Zoom tidslinjen {start} til {end}" */
|
||||
zoom: "Zoom tidslinjen {start} til {end}",
|
||||
/** @default "Tilbakestill tidsperspektiv" */
|
||||
reset: "Tilbakestill tidsperspektiv",
|
||||
},
|
||||
},
|
||||
Tooltip: {
|
||||
/** @default "eller" */
|
||||
shortcutSeparator: "eller",
|
||||
},
|
||||
} satisfies TranslationMap;
|
||||
@@ -1,5 +1,11 @@
|
||||
# @navikt/ds-tailwind
|
||||
|
||||
## 8.10.5
|
||||
|
||||
## 8.10.4
|
||||
|
||||
## 8.10.3
|
||||
|
||||
## 8.10.2
|
||||
|
||||
## 8.10.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-tailwind",
|
||||
"version": "8.10.2",
|
||||
"version": "8.10.5",
|
||||
"description": "Tailwind config based on @navikt/ds-tokens",
|
||||
"author": "Aksel | Nav designsystem team",
|
||||
"keywords": [
|
||||
@@ -27,11 +27,11 @@
|
||||
},
|
||||
"homepage": "https://aksel.nav.no/grunnleggende/kode/tailwind/",
|
||||
"devDependencies": {
|
||||
"@navikt/ds-tokens": "^8.10.2",
|
||||
"@navikt/ds-tokens": "^8.10.5",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "6.0.2",
|
||||
"vitest": "4.1.4"
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.5"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @navikt/ds-tokens
|
||||
|
||||
## 8.10.5
|
||||
|
||||
## 8.10.4
|
||||
|
||||
## 8.10.3
|
||||
|
||||
## 8.10.2
|
||||
|
||||
## 8.10.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-tokens",
|
||||
"version": "8.10.2",
|
||||
"version": "8.10.5",
|
||||
"description": "Design-tokens for Nav designsystem",
|
||||
"author": "Aksel | Nav designsystem team",
|
||||
"keywords": [
|
||||
@@ -41,8 +41,8 @@
|
||||
"lodash": "4.18.1",
|
||||
"style-dictionary": "^4.1.1",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "6.0.2",
|
||||
"vitest": "4.1.4"
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.5"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## 8.10.5
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- Dependencies: Bumped `@floating-ui/react` from v0.27.8 -> v0.27.19 ([#4851](https://github.com/navikt/aksel/pull/4851))
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
- Pagination: Fix selected-styling in forced-colors mode ([#4858](https://github.com/navikt/aksel/pull/4858))
|
||||
|
||||
- Button: Fix issue where secondary and tertiary variants did not have correct text color is certain cases ([#4857](https://github.com/navikt/aksel/pull/4857))
|
||||
|
||||
## 8.10.4
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- ActionMenu: Now locked to bottom/top side placement, left/right for sub-menus. ([#4843](https://github.com/navikt/aksel/pull/4843))
|
||||
|
||||
## 8.10.3
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- Radio, Checkbox: Fix regression where `className` was mistakenly passed to `input`-element ([#4837](https://github.com/navikt/aksel/pull/4837))
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
- Add module declaration to avoid TypeScript error when importing ([`92f6d9e`](https://github.com/navikt/aksel/commit/92f6d9e93b879962df8415f7fad22203174dd3a8))
|
||||
|
||||
## 8.10.2
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
### Quick Assessment
|
||||
|
||||
This is a compact, task-oriented page. The before/after example in `App Router` does the most important job well: it shows the exact import pattern readers need to change. The biggest opportunities are stronger scope-signalling and more precise framework wording, so readers can tell what is required, what is optional, and what only applies in App Router server components.
|
||||
|
||||
### Missing Expected Documentation
|
||||
|
||||
- A short purpose-and-scope intro is missing under the H1. Why it matters: the page starts directly with implementation details, so readers cannot tell whether this is baseline setup for all Next.js projects or a small set of Aksel-specific caveats. Where to add it: directly under `# Next.js`, before `## Bundle-optimalisering`.
|
||||
- Explicit applicability cues are missing for each section. Why it matters: `Bundle-optimalisering` reads like general Next.js setup, while `App Router` only applies to App Router server components. Without a `Gjelder:` or equivalent label, both humans and LLMs can overgeneralize the workaround. Where to add it: the first line of each section.
|
||||
- A symptom-oriented note is missing in `## App Router`. Why it matters: the page gives the fix, but not how the problem typically presents itself, which makes the guidance harder to discover when someone is debugging. Where to add it: the opening paragraph of `## App Router`, before the explanation of the cause.
|
||||
|
||||
### Things To Change
|
||||
|
||||
- [Should fix] In `## Bundle-optimalisering`, the sentence `Nextjs vil ikke automatisk tree-shake Aksel sin React- og ikonpakke` and the claim about `flere hundre kb ekstra bundle-size` are more specific than the linked Next.js guidance. Risk: readers and LLMs can walk away with the wrong mental model that this is always a browser bundle-size problem, while the linked docs describe package-import optimization more broadly across dev/build/cold-start performance. Rephrase this around barrel-import optimization and remove the size claim unless there is Aksel-specific evidence for it.
|
||||
- [Should fix] In `## App Router`, the sentence about `Nextjs sin webpack-bundler` is too tied to an internal implementation detail. Risk: the explanation will age badly and is harder to trust as Next.js changes tooling and internals. Prefer a stable user-facing rule over an internal-cause explanation unless you plan to maintain that claim.
|
||||
- [Should fix] The exception `Dette er bare et problem ved direkte bruk i server-components` comes after the long before/after example. Risk: readers may over-apply the workaround and rewrite imports in client code even when they do not need to. Move that exception into a short note before the example or directly under the section heading.
|
||||
- [Should fix] Terminology is inconsistent: the page title uses `Next.js`, while body text uses `Nextjs`, and English terms such as `path`, `bundle-size`, `server-components`, and `client` are mixed unevenly into Norwegian prose. Risk: weaker scanability, weaker search matching, and noisier extraction for LLMs. Standardize the naming and format code-like terms consistently.
|
||||
- [Nice to have] `Relatert innhold` lists relevant sources, but not why each one matters. Risk: readers need to infer whether a link is required configuration, deeper background, or optional reading. Add a short label after each link to explain its purpose.
|
||||
|
||||
### Optimizations For Humans And LLMs
|
||||
|
||||
- Add a two-rule summary block near the top with the canonical answer from this page: `1. Legg til optimizePackageImports for @navikt/ds-react og @navikt/aksel-icons.` `2. I App Router server components: unngå dot-notation og importer fra @navikt/ds-react/<Komponentnavn>.`
|
||||
- Add `Gjelder:` and `Anbefalt/Påkrevd:` labels at the top of each section. This makes the page much easier to skim and much easier for machine consumers to extract correctly.
|
||||
- Restructure `## App Router` into a clearer sequence such as `Problem`, `Løsning`, `Når dette ikke gjelder`, even if that is only done with bold lead-ins instead of new headings.
|
||||
- Turn `Relatert innhold` into labeled bullets with explicit intent, for example `Offisiell Next.js-konfigurasjon` and `Bakgrunn om Server/Client Components`.
|
||||
- If the team knows the current verification point, add a small version cue such as `Sist verifisert mot Next.js ...`; otherwise avoid low-level implementation-cause wording that will become stale.
|
||||
|
||||
### Suggested Edits
|
||||
|
||||
- Under the H1, add: `Denne siden samler det som er spesielt for bruk av Aksel i Next.js: anbefalt bundle-optimalisering og en kjent begrensning i App Router server components.`
|
||||
- At the top of `## Bundle-optimalisering`, add: `Gjelder: Next.js-prosjekter som bruker @navikt/ds-react eller @navikt/aksel-icons.`
|
||||
- Replace the opening of `## Bundle-optimalisering` with: `Aksel-pakkene eksporteres via mange re-exports. I Next.js bør du derfor legge til optimizePackageImports for @navikt/ds-react og @navikt/aksel-icons, slik at unødvendig kostnad ved barrel-importer reduseres.`
|
||||
- At the top of `## App Router`, add: `Gjelder: bare App Router og direkte bruk i server components.`
|
||||
- Replace the cause sentence in `## App Router` with: `I App Router server components fungerer ikke ds-react-komponenter med dot-notation stabilt. Bruk derfor komponentens subpath-import i stedet.`
|
||||
- Move the current `"use client"` exception into a short note before the code example: `Dette gjelder ikke i filer som allerede er client components med "use client".`
|
||||
|
||||
### Open Questions
|
||||
|
||||
- Finnes det en upstream-issue eller intern issue for dot-notation-begrensningen som bør lenkes fra `## App Router`?
|
||||
- Skal siden dekke bare App Router, eller bør den uttrykkelig si at den andre seksjonen ikke er relevant for Pages Router-prosjekter?
|
||||
- Er påstanden om `flere hundre kb ekstra bundle-size` basert på en konkret Aksel-måling? Hvis ikke bør den mykes opp.
|
||||
@@ -15,11 +15,11 @@
|
||||
"playroom"
|
||||
],
|
||||
"dependencies": {
|
||||
"@navikt/aksel": "^8.10.2",
|
||||
"@navikt/aksel-icons": "^8.10.2",
|
||||
"@navikt/ds-css": "^8.10.2",
|
||||
"@navikt/ds-react": "^8.10.2",
|
||||
"@navikt/ds-tailwind": "^8.10.2",
|
||||
"@navikt/ds-tokens": "^8.10.2"
|
||||
"@navikt/aksel": "^8.10.5",
|
||||
"@navikt/aksel-icons": "^8.10.5",
|
||||
"@navikt/ds-css": "^8.10.5",
|
||||
"@navikt/ds-react": "^8.10.5",
|
||||
"@navikt/ds-tailwind": "^8.10.5",
|
||||
"@navikt/ds-tokens": "^8.10.5"
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
"playroom": "^1.2.1",
|
||||
"style-loader": "^4.0.0",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "6.0.2"
|
||||
"typescript": "6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.2.5"
|
||||
|
||||
@@ -63,6 +63,7 @@ function fuseSearch(icons: AkselIcon[]) {
|
||||
{ name: "variant", weight: 1 },
|
||||
],
|
||||
shouldSort: false,
|
||||
useTokenSearch: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -152,7 +152,6 @@ export const ShowMore = ({
|
||||
behavior: "instant",
|
||||
block: "start",
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setShouldScroll(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,9 +100,9 @@ export type Ds_propsReference = {
|
||||
|
||||
export type Props_seksjon = {
|
||||
_type: "props_seksjon";
|
||||
title?: string;
|
||||
komponenter?: Array<{
|
||||
title?: string;
|
||||
heading_level?: "3" | "4";
|
||||
overridable?: boolean;
|
||||
propref?: Ds_propsReference;
|
||||
_type: "komponent";
|
||||
@@ -3717,9 +3717,9 @@ export type KOMPONENT_BY_SLUG_QUERY_RESULT = {
|
||||
| {
|
||||
_key: string;
|
||||
_type: "props_seksjon";
|
||||
title?: string;
|
||||
komponenter: Array<{
|
||||
title?: string;
|
||||
heading_level?: "3" | "4";
|
||||
overridable?: boolean;
|
||||
propref: {
|
||||
_id: string;
|
||||
@@ -4740,9 +4740,9 @@ export type GRUNNLEGGENDE_BY_SLUG_QUERY_RESULT = {
|
||||
| {
|
||||
_key: string;
|
||||
_type: "props_seksjon";
|
||||
title?: string;
|
||||
komponenter: Array<{
|
||||
title?: string;
|
||||
heading_level?: "3" | "4";
|
||||
overridable?: boolean;
|
||||
propref: {
|
||||
_id: string;
|
||||
@@ -6321,9 +6321,9 @@ export type ENDRINGSLOGG_QUERY_RESULT = Array<{
|
||||
| {
|
||||
_key: string;
|
||||
_type: "props_seksjon";
|
||||
title?: string;
|
||||
komponenter: Array<{
|
||||
title?: string;
|
||||
heading_level?: "3" | "4";
|
||||
overridable?: boolean;
|
||||
propref: {
|
||||
_id: string;
|
||||
@@ -7113,9 +7113,9 @@ export type ENDRINGSLOGG_WITH_NEIGHBORS_QUERY_RESULT = {
|
||||
| {
|
||||
_key: string;
|
||||
_type: "props_seksjon";
|
||||
title?: string;
|
||||
komponenter: Array<{
|
||||
title?: string;
|
||||
heading_level?: "3" | "4";
|
||||
overridable?: boolean;
|
||||
propref: {
|
||||
_id: string;
|
||||
@@ -7900,9 +7900,9 @@ export type ENDRINGSLOGG_WITH_NEIGHBORS_QUERY_RESULT = {
|
||||
| {
|
||||
_key: string;
|
||||
_type: "props_seksjon";
|
||||
title?: string;
|
||||
komponenter: Array<{
|
||||
title?: string;
|
||||
heading_level?: "3" | "4";
|
||||
overridable?: boolean;
|
||||
propref: {
|
||||
_id: string;
|
||||
@@ -8687,9 +8687,9 @@ export type ENDRINGSLOGG_WITH_NEIGHBORS_QUERY_RESULT = {
|
||||
| {
|
||||
_key: string;
|
||||
_type: "props_seksjon";
|
||||
title?: string;
|
||||
komponenter: Array<{
|
||||
title?: string;
|
||||
heading_level?: "3" | "4";
|
||||
overridable?: boolean;
|
||||
propref: {
|
||||
_id: string;
|
||||
@@ -12211,9 +12211,9 @@ export type ALL_KOMPONENTS_MARKDOWN_QUERY_RESULT = Array<{
|
||||
| {
|
||||
_key: string;
|
||||
_type: "props_seksjon";
|
||||
title?: string;
|
||||
komponenter: Array<{
|
||||
title?: string;
|
||||
heading_level?: "3" | "4";
|
||||
overridable?: boolean;
|
||||
propref: {
|
||||
_id: string;
|
||||
@@ -13018,9 +13018,9 @@ export type ALL_GRUNNLEGGENDE_MARKDOWN_QUERY_RESULT = Array<{
|
||||
| {
|
||||
_key: string;
|
||||
_type: "props_seksjon";
|
||||
title?: string;
|
||||
komponenter: Array<{
|
||||
title?: string;
|
||||
heading_level?: "3" | "4";
|
||||
overridable?: boolean;
|
||||
propref: {
|
||||
_id: string;
|
||||
@@ -14575,9 +14575,9 @@ export type KOMPONENT_BY_SLUG_MARKDOWN_QUERY_RESULT = {
|
||||
| {
|
||||
_key: string;
|
||||
_type: "props_seksjon";
|
||||
title?: string;
|
||||
komponenter: Array<{
|
||||
title?: string;
|
||||
heading_level?: "3" | "4";
|
||||
overridable?: boolean;
|
||||
propref: {
|
||||
_id: string;
|
||||
@@ -15382,9 +15382,9 @@ export type GRUNNLEGGENDE_BY_SLUG_MARKDOWN_QUERY_RESULT = {
|
||||
| {
|
||||
_key: string;
|
||||
_type: "props_seksjon";
|
||||
title?: string;
|
||||
komponenter: Array<{
|
||||
title?: string;
|
||||
heading_level?: "3" | "4";
|
||||
overridable?: boolean;
|
||||
propref: {
|
||||
_id: string;
|
||||
@@ -16294,6 +16294,29 @@ export type ALL_MARKDOWN_ARTICLES_INDEX_QUERY_RESULT = Array<
|
||||
}
|
||||
>;
|
||||
|
||||
// Source: ../app/api/component-props/route.ts
|
||||
// Variable: COMPONENT_PROPS_QUERY
|
||||
// Query: *[_type == "komponent_artikkel" && slug.current == $slug][0] { "title": heading, "propSections": content[_type == "props_seksjon"][].komponenter[] { title, overridable, "props": propref->proplist[] { name, type, unpackedType, required, description, defaultValue, deprecated, example, params, return } } }
|
||||
export type COMPONENT_PROPS_QUERY_RESULT = {
|
||||
title: string | null;
|
||||
propSections: Array<{
|
||||
title: string | null;
|
||||
overridable: boolean | null;
|
||||
props: Array<{
|
||||
name: string | null;
|
||||
type: string | null;
|
||||
unpackedType: string | null;
|
||||
required: boolean | null;
|
||||
description: string | null;
|
||||
defaultValue: string | null;
|
||||
deprecated: string | null;
|
||||
example: string | null;
|
||||
params: Array<string> | null;
|
||||
return: string | null;
|
||||
}> | null;
|
||||
} | null> | null;
|
||||
} | null;
|
||||
|
||||
declare module "@sanity/client" {
|
||||
interface SanityQueries {
|
||||
'*[_type == "aksel_ds_forside"][0] {\n ds_forside_title,\n ds_forside_ingress,\n ds_getting_started[]{ description, icon, link, title },\n ds_layers_overview,\n ds_changelog { title, ingress },\n ds_aksel_in_numbers { ingress, statistics[]{number, title, unit}, title},\n ds_support[]{description, link, title},\n seo { image, meta }\n }': DS_FRONT_PAGE_QUERY_RESULT;
|
||||
@@ -16342,5 +16365,6 @@ declare module "@sanity/client" {
|
||||
'*[_type == "ds_artikkel" && slug.current == $slug][0]{\n ...,\n content[]{\n ...,\n \n\n_type == "language" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n_type == "alert" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n_type == "attachment" =>{\n ...,\n "downloadLink": asset->url,\n "size": asset->size,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n_type == "tips" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n_type == "token_ref"=>@->,\n\nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n},\n_type == "intro_komponent" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n_type == "relatert_innhold" =>{\n title,\n lenker[]{\n ...,\n "intern_lenke": intern_lenke->slug.current,\n }\n},\n_type == "live_demo" =>{\n ...,\n "sandbox_ref": sandbox_ref->{...},\n},\n_type == "installasjon_seksjon" =>{\n ...,\n "code_ref": code_ref->{...},\n},\n_type == "accordion"=>{\n ...,\n list[]{\n ...,\n content[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n},\n \n _type == "bilde" =>{\n ...,\n floating_text[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "video" =>{\n ...,\n "webm": {\n "url": webm.asset->url,\n "extension": webm.asset->extension\n },\n "fallback": {\n "url": fallback.asset->url,\n "extension": fallback.asset->extension\n },\n "track": track.asset->url\n },\n _type == "alert" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "kode" =>{\n ...,\n "ref": ref->{...},\n },\n _type == "kode_eksempler" =>{\n ...,\n dir->,\n },\n _type == "kode_ref" => @->,\n _type == "tips" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n _type == "relatert_innhold" =>{\n title,\n lenker[]{\n ...,\n "intern_lenke": intern_lenke->slug.current,\n }\n}\n\n }\n }\n}\n,\n_type == "expansioncard"=>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n},\n \n _type == "bilde" =>{\n ...,\n floating_text[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "video" =>{\n ...,\n "webm": {\n "url": webm.asset->url,\n "extension": webm.asset->extension\n },\n "fallback": {\n "url": fallback.asset->url,\n "extension": fallback.asset->extension\n },\n "track": track.asset->url\n },\n _type == "alert" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "kode" =>{\n ...,\n "ref": ref->{...},\n },\n _type == "kode_eksempler" =>{\n ...,\n dir->,\n },\n _type == "kode_ref" => @->,\n _type == "tips" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n _type == "relatert_innhold" =>{\n title,\n lenker[]{\n ...,\n "intern_lenke": intern_lenke->slug.current,\n }\n}\n\n }\n},\n\n _type == "bilde" =>{\n ...,\n floating_text[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "video" =>{\n ...,\n "webm": {\n "url": webm.asset->url,\n "extension": webm.asset->extension\n },\n "fallback": {\n "url": fallback.asset->url,\n "extension": fallback.asset->extension\n },\n "track": track.asset->url\n },\n _type == "alert" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "kode" =>{\n ...,\n "ref": ref->{...},\n },\n _type == "kode_eksempler" =>{\n ...,\n dir->,\n },\n _type == "kode_ref" => @->,\n _type == "tips" =>{\Line truncated
|
||||
'*[_type == "templates_artikkel" && slug.current == $slug][0]{\n ...,\n content[]{\n ...,\n \n\n_type == "language" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n_type == "alert" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n_type == "attachment" =>{\n ...,\n "downloadLink": asset->url,\n "size": asset->size,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n_type == "tips" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n_type == "token_ref"=>@->,\n\nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n},\n_type == "intro_komponent" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n_type == "relatert_innhold" =>{\n title,\n lenker[]{\n ...,\n "intern_lenke": intern_lenke->slug.current,\n }\n},\n_type == "live_demo" =>{\n ...,\n "sandbox_ref": sandbox_ref->{...},\n},\n_type == "installasjon_seksjon" =>{\n ...,\n "code_ref": code_ref->{...},\n},\n_type == "accordion"=>{\n ...,\n list[]{\n ...,\n content[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n},\n \n _type == "bilde" =>{\n ...,\n floating_text[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "video" =>{\n ...,\n "webm": {\n "url": webm.asset->url,\n "extension": webm.asset->extension\n },\n "fallback": {\n "url": fallback.asset->url,\n "extension": fallback.asset->extension\n },\n "track": track.asset->url\n },\n _type == "alert" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "kode" =>{\n ...,\n "ref": ref->{...},\n },\n _type == "kode_eksempler" =>{\n ...,\n dir->,\n },\n _type == "kode_ref" => @->,\n _type == "tips" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n _type == "relatert_innhold" =>{\n title,\n lenker[]{\n ...,\n "intern_lenke": intern_lenke->slug.current,\n }\n}\n\n }\n }\n}\n,\n_type == "expansioncard"=>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n},\n \n _type == "bilde" =>{\n ...,\n floating_text[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "video" =>{\n ...,\n "webm": {\n "url": webm.asset->url,\n "extension": webm.asset->extension\n },\n "fallback": {\n "url": fallback.asset->url,\n "extension": fallback.asset->extension\n },\n "track": track.asset->url\n },\n _type == "alert" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "kode" =>{\n ...,\n "ref": ref->{...},\n },\n _type == "kode_eksempler" =>{\n ...,\n dir->,\n },\n _type == "kode_ref" => @->,\n _type == "tips" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n},\n _type == "relatert_innhold" =>{\n title,\n lenker[]{\n ...,\n "intern_lenke": intern_lenke->slug.current,\n }\n}\n\n }\n},\n\n _type == "bilde" =>{\n ...,\n floating_text[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "video" =>{\n ...,\n "webm": {\n "url": webm.asset->url,\n "extension": webm.asset->extension\n },\n "fallback": {\n "url": fallback.asset->url,\n "extension": fallback.asset->extension\n },\n "track": track.asset->url\n },\n _type == "alert" =>{\n ...,\n body[]{\n ...,\n \nmarkDefs[]{\n ...,\n _type == \'internalLink\' => {\n "slug": @.reference->slug,\n anchor,\n },\n}\n }\n },\n _type == "kode" =>{\n ...,\n "ref": ref->{...},\n },\n _type == "kode_eksempler" =>{\n ...,\n dir->,\n },\n _type == "kode_ref" => @->,\n _type == "tipLine truncated
|
||||
'*[_type in ["komponent_artikkel", "ds_artikkel", "templates_artikkel"] && defined(slug.current)]{\n _type,\n heading,\n "slug": slug.current,\n kategori,\n "tag": status.tag,\n sidebarindex\n }': ALL_MARKDOWN_ARTICLES_INDEX_QUERY_RESULT;
|
||||
'*[_type == "komponent_artikkel" && slug.current == $slug][0] {\n "title": heading,\n "propSections": content[_type == "props_seksjon"][].komponenter[] {\n title,\n overridable,\n "props": propref->proplist[] {\n name,\n type,\n unpackedType,\n required,\n description,\n defaultValue,\n deprecated,\n example,\n params,\n return\n }\n }\n }': COMPONENT_PROPS_QUERY_RESULT;
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ async function fuseGlobalSearch(query: string) {
|
||||
includeMatches: true,
|
||||
threshold: 0.18,
|
||||
distance: 50,
|
||||
useTokenSearch: true,
|
||||
});
|
||||
|
||||
const fuseResults = fuse
|
||||
|
||||
@@ -61,7 +61,7 @@ function KodeEksemplerIFrame(props: {
|
||||
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
useEffect(() => {
|
||||
setHasMounted(true);
|
||||
setHasMounted(true); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,9 +7,11 @@ import styles from "./PropsSeksjon.module.css";
|
||||
function PropsSeksjonDescription({
|
||||
description,
|
||||
params,
|
||||
returnVal,
|
||||
}: {
|
||||
description?: string;
|
||||
params?: string[];
|
||||
description: string | undefined;
|
||||
params: string[] | undefined;
|
||||
returnVal: string | undefined;
|
||||
}) {
|
||||
if (!description) {
|
||||
return null;
|
||||
@@ -30,20 +32,26 @@ function PropsSeksjonDescription({
|
||||
>
|
||||
{description}
|
||||
</Markdown>
|
||||
{params && (
|
||||
<ul>
|
||||
{params.map((param) => {
|
||||
const [name, ...desc] = param.split(" ");
|
||||
return (
|
||||
<li key={param}>
|
||||
<strong>{name}: </strong>
|
||||
{desc.join(" ")}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{returnVal && (
|
||||
<div>
|
||||
<strong>Return: </strong>
|
||||
{returnVal}
|
||||
</div>
|
||||
)}
|
||||
</VStack>
|
||||
{params && (
|
||||
<ul>
|
||||
{params.map((param) => {
|
||||
const [name, ...desc] = param.split(" ");
|
||||
return (
|
||||
<li key={param}>
|
||||
<strong>{name}: </strong>
|
||||
{desc.join(" ")}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { stegaClean } from "next-sanity";
|
||||
import { BodyShort, Box, Heading } from "@navikt/ds-react";
|
||||
import type { AkselColorRole } from "@navikt/ds-tokens/types";
|
||||
import { ExtractPortableComponentProps } from "@/app/_sanity/types";
|
||||
@@ -13,9 +14,9 @@ type PropsSeksjonComponentT = NonNullable<
|
||||
>[number];
|
||||
|
||||
function PropsSeksjon(props: ExtractPortableComponentProps<"props_seksjon">) {
|
||||
const { komponenter, title } = props.value;
|
||||
const { komponenter } = props.value;
|
||||
|
||||
if (!komponenter || komponenter.length === 0 || !title) {
|
||||
if (!komponenter || komponenter.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -62,7 +63,7 @@ function PropTable({ component }: { component: PropsSeksjonComponentT }) {
|
||||
<div lang="en" data-block-margin="space-28" className={styles.propsSeksjon}>
|
||||
<Heading
|
||||
size="xsmall"
|
||||
level="3"
|
||||
level={stegaClean(component.heading_level) || "3"}
|
||||
className={styles.propsSeksjonHeader}
|
||||
id={component._key}
|
||||
>
|
||||
@@ -86,55 +87,22 @@ const PropEntry = ({
|
||||
NonNullable<PropsSeksjonComponentT["propref"]>["proplist"]
|
||||
>[0];
|
||||
}) => {
|
||||
if (prop.deprecated) {
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
as="dt"
|
||||
paddingBlock="space-8 space-0"
|
||||
paddingInline="space-8"
|
||||
className="inline-block"
|
||||
>
|
||||
<Code as="h4" className="mr-2" strikethrough highlighted>{`${
|
||||
prop.name
|
||||
}${prop?.required ? "" : "?"}`}</Code>
|
||||
</Box>
|
||||
<BodyShort as="dd">
|
||||
<Box as="ul" overflowX="auto">
|
||||
<PropsSeksjonDeprecation text={prop.deprecated} />
|
||||
<PropsSeksjonCode code={prop.type} title="Type" wrap />
|
||||
<PropsSeksjonCode
|
||||
/* We assume that if type starts with ", its an union-type */
|
||||
code={prop.defaultValue}
|
||||
title="Default"
|
||||
wrap
|
||||
/>
|
||||
<PropsSeksjonDescription
|
||||
description={prop.description}
|
||||
params={prop.params}
|
||||
/>
|
||||
<PropsSeksjonCode code={prop.example} title="Example" />
|
||||
</Box>
|
||||
</BodyShort>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
let type = prop.type;
|
||||
|
||||
if (prop.type === "AkselColor") {
|
||||
type = unpackAkselColorType();
|
||||
}
|
||||
const type = prop.type === "AkselColor" ? unpackedAkselColorType : prop.type;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box as="dt" paddingBlock="space-8 space-0" paddingInline="space-8">
|
||||
<Code as="h4" highlighted>{`${prop.name}${
|
||||
prop?.required ? "" : "?"
|
||||
}`}</Code>
|
||||
<Code
|
||||
as="h4"
|
||||
highlighted
|
||||
strikethrough={!!prop.deprecated}
|
||||
>{`${prop.name}${prop?.required ? "" : "?"}`}</Code>
|
||||
</Box>
|
||||
<BodyShort as="dd">
|
||||
<Box as="ul" overflowX="auto">
|
||||
{prop.deprecated && (
|
||||
<PropsSeksjonDeprecation text={prop.deprecated} />
|
||||
)}
|
||||
<PropsSeksjonCode code={type} title="Type" wrap />
|
||||
<PropsSeksjonCode
|
||||
/* We assume that if type starts with ", its an union-type */
|
||||
@@ -145,6 +113,7 @@ const PropEntry = ({
|
||||
<PropsSeksjonDescription
|
||||
description={prop.description}
|
||||
params={prop.params}
|
||||
returnVal={prop.return}
|
||||
/>
|
||||
<PropsSeksjonCode code={prop.example} title="Example" />
|
||||
</Box>
|
||||
@@ -167,10 +136,8 @@ const colors: Record<AkselColorRole, boolean> = {
|
||||
"brand-magenta": true,
|
||||
};
|
||||
|
||||
function unpackAkselColorType() {
|
||||
return Object.keys(colors)
|
||||
.map((key) => `"${key}"`)
|
||||
.join(" | ");
|
||||
}
|
||||
const unpackedAkselColorType = Object.keys(colors)
|
||||
.map((key) => `"${key}"`)
|
||||
.join(" | ");
|
||||
|
||||
export { PropsSeksjon };
|
||||
@@ -0,0 +1,144 @@
|
||||
import { defineQuery } from "next-sanity";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { sanityMarkdownFetch } from "@/app/_sanity/live";
|
||||
|
||||
export const revalidate = 7200;
|
||||
|
||||
const COMPONENT_PROPS_QUERY = defineQuery(
|
||||
`*[_type == "komponent_artikkel" && slug.current == $slug][0] {
|
||||
"title": heading,
|
||||
"propSections": content[_type == "props_seksjon"][].komponenter[] {
|
||||
title,
|
||||
overridable,
|
||||
"props": propref->proplist[] {
|
||||
name,
|
||||
type,
|
||||
unpackedType,
|
||||
required,
|
||||
description,
|
||||
defaultValue,
|
||||
deprecated,
|
||||
example,
|
||||
params,
|
||||
return
|
||||
}
|
||||
}
|
||||
}`,
|
||||
);
|
||||
|
||||
/**
|
||||
* Route allows external tools (Aksel-mcp) to fetch props related to a specific component:
|
||||
* Example: https://aksel.nav.no/api/component-props?slug=komponenter/core/button
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const rawSlug = request.nextUrl.searchParams.get("slug");
|
||||
|
||||
if (!rawSlug) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required query parameter: slug" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const slug = normalizeComponentSlug(rawSlug);
|
||||
|
||||
if (!slug) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Invalid slug. Expected a component slug starting with 'komponenter/'.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await sanityMarkdownFetch({
|
||||
query: COMPONENT_PROPS_QUERY,
|
||||
params: { slug },
|
||||
});
|
||||
|
||||
if (!data?.propSections || data.propSections.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Component not found or has no props documentation", slug },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const parts: {
|
||||
title: string | null;
|
||||
props: {
|
||||
type: string | null;
|
||||
name: string | null;
|
||||
required: boolean | null;
|
||||
description: string | null;
|
||||
defaultValue: string | null;
|
||||
deprecated: string | null;
|
||||
example: string | null;
|
||||
params: string[] | null;
|
||||
return: string | null;
|
||||
}[];
|
||||
}[] = [];
|
||||
|
||||
for (const section of data.propSections) {
|
||||
if (!section?.title) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const props = (section?.props ?? [])
|
||||
.filter((prop) => !prop.description?.includes("@private"))
|
||||
.sort((a, b) => {
|
||||
if (a.deprecated && !b.deprecated) return 1;
|
||||
if (!a.deprecated && b.deprecated) return -1;
|
||||
return 0;
|
||||
})
|
||||
.map(({ unpackedType, type, ...rest }) => ({
|
||||
...rest,
|
||||
type: unpackedType ?? type,
|
||||
}));
|
||||
|
||||
if (section.overridable) {
|
||||
props.push({
|
||||
name: "as",
|
||||
type: "React.ElementType",
|
||||
required: false,
|
||||
description: "Override the root element (OverridableComponent API).",
|
||||
defaultValue: null,
|
||||
deprecated: null,
|
||||
example: null,
|
||||
params: null,
|
||||
return: null,
|
||||
});
|
||||
}
|
||||
|
||||
parts.push({ title: section.title, props });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ title: data.title, slug, parts },
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": `public, max-age=${revalidate}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch component props", { slug, error });
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch component props", slug },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const COMPONENT_SLUG_PATTERN = /^komponenter(?:\/[a-z0-9-]+){2}$/;
|
||||
|
||||
function normalizeComponentSlug(rawSlug: string) {
|
||||
const normalizedSlug = rawSlug.trim().replace(/^\/+|\/+$/g, "");
|
||||
|
||||
if (!COMPONENT_SLUG_PATTERN.test(normalizedSlug)) {
|
||||
return null;
|
||||
}
|
||||
return normalizedSlug;
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import { buildMarkdown } from "@/app/api/markdown/helpers/build-markdown";
|
||||
function PropsSeksjonMarkdown(
|
||||
data: ExtractPortableMarkdownComponentProps<"props_seksjon">,
|
||||
) {
|
||||
const { komponenter, title } = data.value;
|
||||
const { komponenter } = data.value;
|
||||
|
||||
if (!komponenter || komponenter.length === 0 || !title) {
|
||||
if (!komponenter || komponenter.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
@@ -30,31 +30,30 @@
|
||||
"cp:assets": "yarn cp:playroom && yarn cp:public && yarn cp:static"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "0.27.8",
|
||||
"@navikt/analytics-types": "0.0.7",
|
||||
"@navikt/next-logger": "4.4.1",
|
||||
"@navikt/oasis": "3.2.1",
|
||||
"@neshca/cache-handler": "1.9.0",
|
||||
"@portabletext/react": "^6.0.2",
|
||||
"@sanity/client": "7.20.0",
|
||||
"@portabletext/react": "^6.0.3",
|
||||
"@sanity/client": "7.21.0",
|
||||
"@sanity/code-input": "7.1.0",
|
||||
"@sanity/color-input": "6.0.4",
|
||||
"@sanity/image-url": "2.1.1",
|
||||
"@sanity/locale-nb-no": "1.1.33",
|
||||
"@sanity/preview-url-secret": "4.0.4",
|
||||
"@sanity/preview-url-secret": "4.0.5",
|
||||
"@sanity/table": "2.0.1",
|
||||
"@sanity/vision": "5.20.0",
|
||||
"@sanity/vision": "5.22.0",
|
||||
"@slack/web-api": "^7.8.0",
|
||||
"codesandbox-import-utils": "^2.2.3",
|
||||
"date-fns": "^4.0.0",
|
||||
"fuse.js": "^7.0.0",
|
||||
"fuse.js": "^7.3.0",
|
||||
"isomorphic-dompurify": "2.26.0",
|
||||
"jscodeshift": "17.3.0",
|
||||
"lodash": "4.18.1",
|
||||
"lz-string": "^1.5.0",
|
||||
"next": "16.2.3",
|
||||
"next-logger": "^5.0.2",
|
||||
"next-sanity": "12.2.2",
|
||||
"next-sanity": "12.3.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cache": "^5.1.2",
|
||||
"pino": "^8.21.0",
|
||||
@@ -68,7 +67,7 @@
|
||||
"react-snowfall": "^2.4.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"sanity": "5.20.0",
|
||||
"sanity": "5.22.0",
|
||||
"sanity-plugin-iframe-pane": "5.0.11",
|
||||
"sanity-plugin-media": "4.1.1",
|
||||
"sanity-plugin-references": "1.0.1",
|
||||
@@ -88,13 +87,13 @@
|
||||
"@babel/traverse": "^7.27.4",
|
||||
"@next/bundle-analyzer": "16.2.3",
|
||||
"@next/eslint-plugin-next": "16.2.3",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@portabletext/block-tools": "^5.0.1",
|
||||
"@portabletext/markdown": "^1.1.2",
|
||||
"@sanity/blueprints": "0.15.0",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@portabletext/block-tools": "^5.1.1",
|
||||
"@portabletext/markdown": "^1.2.0",
|
||||
"@sanity/blueprints": "0.16.0",
|
||||
"@sanity/export": "6.1.0",
|
||||
"@sanity/functions": "1.2.1",
|
||||
"@sanity/schema": "5.20.0",
|
||||
"@sanity/functions": "1.3.1",
|
||||
"@sanity/schema": "5.22.0",
|
||||
"@types/jscodeshift": "17.3.0",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/lodash": "4.17.20",
|
||||
@@ -106,14 +105,14 @@
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"concurrently": "9.2.1",
|
||||
"copyfiles": "^2.4.1",
|
||||
"cross-env": "^10.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"jsdom": "27.1.0",
|
||||
"json5": "2.2.1",
|
||||
"pino-pretty": "^10.0.0",
|
||||
"rss": "^1.2.2",
|
||||
"showdown": "^2.1.0",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "6.0.2",
|
||||
"vitest": "4.1.4"
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.5"
|
||||
}
|
||||
}
|
||||
@@ -387,13 +387,6 @@
|
||||
"value": "props_seksjon"
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"type": "objectAttribute",
|
||||
"value": {
|
||||
"type": "string"
|
||||
},
|
||||
"optional": true
|
||||
},
|
||||
"komponenter": {
|
||||
"type": "objectAttribute",
|
||||
"value": {
|
||||
@@ -408,6 +401,23 @@
|
||||
},
|
||||
"optional": true
|
||||
},
|
||||
"heading_level": {
|
||||
"type": "objectAttribute",
|
||||
"value": {
|
||||
"type": "union",
|
||||
"of": [
|
||||
{
|
||||
"type": "string",
|
||||
"value": "3"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"value": "4"
|
||||
}
|
||||
]
|
||||
},
|
||||
"optional": true
|
||||
},
|
||||
"overridable": {
|
||||
"type": "objectAttribute",
|
||||
"value": {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineField, defineType } from "sanity";
|
||||
import { BulletListIcon } from "@navikt/aksel-icons";
|
||||
import type { Props_seksjon } from "@/app/_sanity/query-types";
|
||||
|
||||
export const PropsSeksjon = defineType({
|
||||
title: "Props",
|
||||
@@ -7,13 +8,6 @@ export const PropsSeksjon = defineType({
|
||||
type: "object",
|
||||
icon: () => <BulletListIcon aria-hidden />,
|
||||
fields: [
|
||||
defineField({
|
||||
title: "Tittel (h2)",
|
||||
name: "title",
|
||||
type: "string",
|
||||
initialValue: "Props",
|
||||
readOnly: true,
|
||||
}),
|
||||
defineField({
|
||||
title: "Props",
|
||||
type: "array",
|
||||
@@ -25,20 +19,28 @@ export const PropsSeksjon = defineType({
|
||||
name: "komponent",
|
||||
fields: [
|
||||
{
|
||||
title: "Komponent navn",
|
||||
description: "Slik man ville brukt den, eks Accordion.Item",
|
||||
title: "Komponentnavn",
|
||||
description: "Slik man ville brukt den, f.eks. Accordion.Item",
|
||||
type: "string",
|
||||
name: "title",
|
||||
},
|
||||
defineField({
|
||||
title: "Overstyr overskriftsnivå",
|
||||
name: "heading_level",
|
||||
type: "string",
|
||||
options: {
|
||||
list: ["3", "4"],
|
||||
},
|
||||
}),
|
||||
{
|
||||
title: "Bruker komponenten OverridableComponent API-et",
|
||||
title: "Komponenten bruker OverridableComponent API-et",
|
||||
type: "boolean",
|
||||
name: "overridable",
|
||||
initialValue: false,
|
||||
},
|
||||
{
|
||||
name: "propref",
|
||||
title: "Komponent referanse",
|
||||
title: "Komponent-referanse",
|
||||
type: "reference",
|
||||
to: [{ type: "ds_props" }],
|
||||
},
|
||||
@@ -48,9 +50,17 @@ export const PropsSeksjon = defineType({
|
||||
}),
|
||||
],
|
||||
preview: {
|
||||
prepare() {
|
||||
select: {
|
||||
komponenter: "komponenter",
|
||||
},
|
||||
prepare({ komponenter }) {
|
||||
return {
|
||||
title: "Props",
|
||||
subtitle: (komponenter as Props_seksjon["komponenter"])
|
||||
?.map((k) =>
|
||||
k.heading_level ? `${k.title} (h${k.heading_level})` : k.title,
|
||||
)
|
||||
.join(", "),
|
||||
media: () => <BulletListIcon aria-hidden />,
|
||||
};
|
||||
},
|
||||
|
||||
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user