mirror of
https://github.com/navikt/aksel.git
synced 2026-10-13 01:02:02 +00:00
Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8826a79021 | ||
|
|
c2f3da84cc | ||
|
|
2c1476022d | ||
|
|
dacf648c47 | ||
|
|
5ec986771b | ||
|
|
ca6af8a5f7 | ||
|
|
9410840f74 | ||
|
|
d88884f785 | ||
|
|
0c8239ee00 | ||
|
|
987118fd47 | ||
|
|
8d13f11689 | ||
|
|
158e18454e | ||
|
|
be26bf22c9 | ||
|
|
c2968a0e6d | ||
|
|
3f92773273 | ||
|
|
0c3ab1d0e7 | ||
|
|
3a73a029f6 | ||
|
|
6fd0f501f5 | ||
|
|
a11b8ddf4e | ||
|
|
171db6a813 | ||
|
|
085cc532ee | ||
|
|
deae4ecba5 | ||
|
|
cd2adcb655 | ||
|
|
0d4385a545 | ||
|
|
6f1b9751f3 | ||
|
|
7fb97808f0 | ||
|
|
d8ac958de2 | ||
|
|
b417c74374 | ||
|
|
ff1d3534a6 | ||
|
|
57d015209d | ||
|
|
5969859024 | ||
|
|
9bbc3e6813 | ||
|
|
5250b47ca4 | ||
|
|
fc7709ae2a | ||
|
|
2364e689ed | ||
|
|
d57c0b2c2c | ||
|
|
868bb543a5 | ||
|
|
8f9ed7a404 | ||
|
|
5ea6255f6d | ||
|
|
491fccbe80 | ||
|
|
cca2a8263f | ||
|
|
3140ec2765 | ||
|
|
d8fce83ae3 | ||
|
|
f88b00daf6 | ||
|
|
6d0baea5c1 | ||
|
|
880e77b9c2 | ||
|
|
6d16373f85 | ||
|
|
ae078254dc | ||
|
|
638e8260b4 | ||
|
|
d991011690 | ||
|
|
b6086f7b1f | ||
|
|
05462081aa | ||
|
|
63e42de3ee | ||
|
|
2e85178166 | ||
|
|
456d4e2e07 | ||
|
|
b5a0cb8cdf | ||
|
|
a2af9bcc37 | ||
|
|
bd45754adb |
No files matched your search
@@ -25,6 +25,7 @@ jobs:
|
||||
|
||||
- name: add tokens to .env
|
||||
run: |
|
||||
echo "SLACK_BOT_TOKEN=${{ secrets.SLACK_BOT_TOKEN }}" >> aksel.nav.no/website/.env
|
||||
echo "SANITY_WRITE_KEY=${{ secrets.SANITY_FEEDBACK_WRITE_KEY }}" >> aksel.nav.no/website/.env
|
||||
echo "SANITY_PREVIEW_TOKEN=${{ secrets.SANITY_PREVIEW_TOKEN }}" >> aksel.nav.no/website/.env
|
||||
echo "SANITY_PRIVATE_NO_DRAFTS=${{ secrets.SANITY_PRIVATE_NO_DRAFTS }}" >> aksel.nav.no/website/.env
|
||||
|
||||
@@ -59,6 +59,7 @@ jobs:
|
||||
|
||||
- name: add tokens to .env
|
||||
run: |
|
||||
echo "SLACK_BOT_TOKEN=${{ secrets.SLACK_BOT_TOKEN }}" >> aksel.nav.no/website/.env
|
||||
echo "SANITY_WRITE_KEY=${{ secrets.SANITY_FEEDBACK_WRITE_KEY }}" >> aksel.nav.no/website/.env
|
||||
echo "SANITY_PREVIEW_TOKEN=${{ secrets.SANITY_PREVIEW_TOKEN }}" >> aksel.nav.no/website/.env
|
||||
echo "SANITY_PRIVATE_NO_DRAFTS=${{ secrets.SANITY_PRIVATE_NO_DRAFTS }}" >> aksel.nav.no/website/.env
|
||||
|
||||
+2
-2
@@ -1,4 +1,3 @@
|
||||
import { mergeConfig } from "vite";
|
||||
import turbosnap from "vite-plugin-turbosnap";
|
||||
|
||||
export default {
|
||||
@@ -7,7 +6,7 @@ export default {
|
||||
stories: () => ["../@navikt/**/*.stories.@(js|jsx|ts|tsx|mdx)", "./*.mdx"],
|
||||
addons: [
|
||||
"@storybook/addon-a11y",
|
||||
"@xfinx/storybook-addon-html",
|
||||
"@whitespace/storybook-addon-html",
|
||||
"@storybook/addon-interactions",
|
||||
{
|
||||
name: "@storybook/addon-storysource",
|
||||
@@ -42,6 +41,7 @@ export default {
|
||||
},
|
||||
|
||||
async viteFinal(config, { configType }) {
|
||||
const { mergeConfig } = await import("vite");
|
||||
return mergeConfig(config, {
|
||||
plugins:
|
||||
configType === "PRODUCTION"
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @navikt/aksel-icons
|
||||
|
||||
## 6.6.0
|
||||
|
||||
## 6.5.0
|
||||
|
||||
## 6.4.1
|
||||
|
||||
## 6.4.0
|
||||
|
||||
## 6.3.6
|
||||
|
||||
## 6.3.5
|
||||
|
||||
## 6.3.4
|
||||
|
||||
## 6.3.3
|
||||
|
||||
## 6.3.2
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"postcss": "^8.4.31",
|
||||
"postcss-url": "^10.1.3",
|
||||
"typescript": "^5.1.6",
|
||||
"vite": "^5.0.12",
|
||||
"vite": "^5.1.7",
|
||||
"vite-plugin-singlefile": "^1.0.0",
|
||||
"vite-plugin-svgr": "^4.2.0"
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import React from "react";
|
||||
import * as AkselIcons from "@navikt/aksel-icons";
|
||||
import { StarFillIcon } from "@navikt/aksel-icons";
|
||||
|
||||
const meta = {
|
||||
const meta: Meta<typeof StarFillIcon> = {
|
||||
title: "aksel-icons/Icons",
|
||||
} satisfies Meta<typeof StarFillIcon>;
|
||||
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
type Story = StoryObj<typeof StarFillIcon>;
|
||||
|
||||
export const Icons: Story = {
|
||||
render: () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel-icons",
|
||||
"version": "6.3.3",
|
||||
"version": "6.6.0",
|
||||
"author": "Aksel | NAV",
|
||||
"homepage": "https://aksel.nav.no/ikoner",
|
||||
"repository": {
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @navikt/aksel-stylelint
|
||||
|
||||
## 6.6.0
|
||||
|
||||
## 6.5.0
|
||||
|
||||
## 6.4.1
|
||||
|
||||
## 6.4.0
|
||||
|
||||
## 6.3.6
|
||||
|
||||
## 6.3.5
|
||||
|
||||
## 6.3.4
|
||||
|
||||
## 6.3.3
|
||||
|
||||
## 6.3.2
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel-stylelint",
|
||||
"version": "6.3.3",
|
||||
"version": "6.6.0",
|
||||
"author": "Aksel | NAV",
|
||||
"homepage": "https://aksel.nav.no/grunnleggende/kode/stylelint",
|
||||
"repository": {
|
||||
@@ -35,8 +35,8 @@
|
||||
"dev": "yarn watch:lint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@navikt/ds-css": "^6.3.3",
|
||||
"@navikt/ds-tokens": "^6.3.3",
|
||||
"@navikt/ds-css": "^6.6.0",
|
||||
"@navikt/ds-tokens": "^6.6.0",
|
||||
"concurrently": "7.2.1",
|
||||
"postcss-selector-parser": "^6.0.13",
|
||||
"postcss-value-parser": "^4.2.0",
|
||||
|
||||
@@ -1,5 +1,56 @@
|
||||
# @navikt/aksel
|
||||
|
||||
## 6.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Aksel CLI: Fjernet deprecated komponenter fra css oversikt. ([#2860](https://github.com/navikt/aksel/pull/2860))
|
||||
|
||||
- Updated dependencies [[`3a73a029f`](https://github.com/navikt/aksel/commit/3a73a029f6272f76e10cb6baefbaa2040feaef6b)]:
|
||||
- @navikt/ds-css@6.6.0
|
||||
|
||||
## 6.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-css@6.5.0
|
||||
|
||||
## 6.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`5250b47ca`](https://github.com/navikt/aksel/commit/5250b47ca410f9cd57c5350bacc77b1b2ec9b2c0)]:
|
||||
- @navikt/ds-css@6.4.1
|
||||
|
||||
## 6.4.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-css@6.4.0
|
||||
|
||||
## 6.3.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-css@6.3.6
|
||||
|
||||
## 6.3.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-css@6.3.5
|
||||
|
||||
## 6.3.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`a2af9bcc3`](https://github.com/navikt/aksel/commit/a2af9bcc37eb12cc7cf3a1596d57dc0421898657)]:
|
||||
- @navikt/ds-css@6.3.4
|
||||
|
||||
## 6.3.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel",
|
||||
"version": "6.3.3",
|
||||
"version": "6.6.0",
|
||||
"description": "Aksel command line interface. Handles css-imports, codemods and more",
|
||||
"author": "Aksel | NAV designsystem team",
|
||||
"license": "MIT",
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"homepage": "https://aksel.nav.no/grunnleggende/kode/kommandolinje",
|
||||
"dependencies": {
|
||||
"@navikt/ds-css": "6.3.3",
|
||||
"@navikt/ds-css": "6.6.0",
|
||||
"axios": "1.6.0",
|
||||
"chalk": "4.1.0",
|
||||
"clipboardy": "^2.3.0",
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
# @navikt/ds-css
|
||||
|
||||
## 6.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- :sparkles: Ny komponent FormSummary ([#2802](https://github.com/navikt/aksel/pull/2802))
|
||||
|
||||
## 6.5.0
|
||||
|
||||
## 6.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- FileUpload: Item-illustrasjon er nå radius-full ([#2834](https://github.com/navikt/aksel/pull/2834))
|
||||
|
||||
## 6.4.0
|
||||
|
||||
## 6.3.6
|
||||
|
||||
## 6.3.5
|
||||
|
||||
## 6.3.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ReadMore: Padding-right på button er bumpet noen px ([#2806](https://github.com/navikt/aksel/pull/2806))
|
||||
|
||||
## 6.3.3
|
||||
|
||||
## 6.3.2
|
||||
|
||||
@@ -97,10 +97,6 @@ const StyleMappings = {
|
||||
main: formCss,
|
||||
dependencies: [typoCss],
|
||||
},
|
||||
{
|
||||
component: "ContentContainer",
|
||||
main: "content-container.css",
|
||||
},
|
||||
{
|
||||
component: "CopyButton",
|
||||
main: "copybutton.css",
|
||||
@@ -125,7 +121,6 @@ const StyleMappings = {
|
||||
main: formCss,
|
||||
dependencies: [typoCss, "button.css", "loader.css", "link.css"],
|
||||
},
|
||||
{ component: "Grid", main: "grid.css", dependencies: [typoCss] },
|
||||
{
|
||||
component: "GuidePanel",
|
||||
main: "guide-panel.css",
|
||||
@@ -178,6 +173,11 @@ const StyleMappings = {
|
||||
{ component: "Tag", main: "tag.css", dependencies: [typoCss] },
|
||||
{ component: "TextField", main: formCss, dependencies: [typoCss] },
|
||||
{ component: "Textarea", main: formCss, dependencies: [typoCss] },
|
||||
{
|
||||
component: "FormSummary",
|
||||
main: formCss,
|
||||
dependencies: [typoCss, "link.css"],
|
||||
},
|
||||
{
|
||||
component: "ToggleGroup",
|
||||
main: "toggle-group.css",
|
||||
|
||||
@@ -182,7 +182,7 @@
|
||||
.navds-file-item__icon {
|
||||
background-color: var(--a-surface-neutral-subtle);
|
||||
color: var(--a-icon-default);
|
||||
border-radius: var(--a-border-radius-xlarge);
|
||||
border-radius: var(--a-border-radius-full);
|
||||
min-height: 3rem;
|
||||
min-width: 3rem;
|
||||
display: grid;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
.navds-form-summary {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--a-border-subtle);
|
||||
border-radius: var(--a-border-radius-large);
|
||||
}
|
||||
|
||||
.navds-form-summary__header {
|
||||
background-color: var(--a-surface-subtle);
|
||||
padding: var(--a-spacing-4) var(--a-spacing-6);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0 var(--a-spacing-2);
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.navds-form-summary__header {
|
||||
padding: var(--a-spacing-3) var(--a-spacing-4);
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.navds-form-summary__edit {
|
||||
flex-shrink: 0;
|
||||
margin-top: var(--a-spacing-1);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.navds-form-summary > .navds-form-summary__answers {
|
||||
border-top: 1px solid var(--a-border-subtle);
|
||||
}
|
||||
|
||||
.navds-form-summary__answers {
|
||||
padding: var(--a-spacing-5) var(--a-spacing-6) var(--a-spacing-6);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.navds-form-summary__answers {
|
||||
padding: var(--a-spacing-4);
|
||||
}
|
||||
}
|
||||
|
||||
.navds-form-summary__answer:not(:last-child) {
|
||||
margin-bottom: var(--a-spacing-4);
|
||||
padding-bottom: var(--a-spacing-4);
|
||||
border-bottom: 1px solid var(--a-border-divider);
|
||||
}
|
||||
|
||||
.navds-form-summary__answer:has(.navds-form-summary__answer) {
|
||||
padding-bottom: var(--a-spacing-6);
|
||||
}
|
||||
|
||||
.navds-form-summary__answer:has(.navds-form-summary__answer):last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.navds-form-summary__answer:not(:last-child) {
|
||||
margin-bottom: var(--a-spacing-3);
|
||||
padding-bottom: var(--a-spacing-3);
|
||||
}
|
||||
|
||||
.navds-form-summary__answer:has(.navds-form-summary__answer) {
|
||||
padding-bottom: var(--a-spacing-5);
|
||||
}
|
||||
}
|
||||
|
||||
.navds-form-summary__value:first-of-type {
|
||||
margin-top: var(--a-spacing-1);
|
||||
}
|
||||
|
||||
.navds-form-summary__value :where(.navds-form-summary__answers) {
|
||||
margin-top: var(--a-spacing-2);
|
||||
padding: var(--a-spacing-4);
|
||||
background: var(--a-surface-selected);
|
||||
border-radius: var(--a-border-radius-large);
|
||||
}
|
||||
@@ -13,3 +13,4 @@
|
||||
@import "textarea.css";
|
||||
@import "search.css";
|
||||
@import "combobox.css";
|
||||
@import "form-summary.css";
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-css",
|
||||
"version": "6.3.3",
|
||||
"version": "6.6.0",
|
||||
"description": "CSS for NAV Designsystem",
|
||||
"author": "Aksel | NAV designsystem team",
|
||||
"keywords": [
|
||||
@@ -27,7 +27,7 @@
|
||||
"css:get-version": "node config/get-version.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@navikt/ds-tokens": "^6.3.3",
|
||||
"@navikt/ds-tokens": "^6.6.0",
|
||||
"cssnano": "6.0.0",
|
||||
"fast-glob": "3.2.11",
|
||||
"lodash": "4.17.21",
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
gap: var(--a-spacing-05);
|
||||
color: var(--ac-read-more-text, var(--a-text-action));
|
||||
border-radius: var(--a-border-radius-small);
|
||||
padding: var(--a-spacing-1) var(--a-spacing-1) var(--a-spacing-1) var(--a-spacing-05);
|
||||
padding: var(--a-spacing-1) var(--a-spacing-2) var(--a-spacing-1) var(--a-spacing-05);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.navds-read-more--small .navds-read-more__button {
|
||||
padding: var(--a-spacing-05) var(--a-spacing-1) var(--a-spacing-05) var(--a-spacing-05);
|
||||
padding: var(--a-spacing-05) var(--a-spacing-1-alt) var(--a-spacing-05) var(--a-spacing-05);
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
.navds-tabs__tablist {
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
width: 100%;
|
||||
scroll-behavior: smooth;
|
||||
-ms-overflow-style: none; /* for Internet Explorer, Edge */
|
||||
scrollbar-width: none; /* for Firefox */
|
||||
@@ -83,6 +83,10 @@
|
||||
gap: var(--a-spacing-1);
|
||||
}
|
||||
|
||||
.navds-tabs__tab-inner > * {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.navds-tabs__tab-inner svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -117,6 +121,10 @@
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.navds-tabs__tab--fill {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.navds-tabs__tabpanel:focus-visible {
|
||||
outline: 2px solid transparent;
|
||||
box-shadow: inset 0 0 0 2px var(--a-border-focus);
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
gap: var(--a-spacing-2);
|
||||
}
|
||||
|
||||
.navds-toggle-group__wrapper--fill {
|
||||
justify-items: initial;
|
||||
}
|
||||
|
||||
.navds-toggle-group {
|
||||
border-radius: var(--a-border-radius-medium);
|
||||
background-color: var(--ac-toggle-group-bg, var(--a-surface-transparent));
|
||||
|
||||
@@ -1,5 +1,95 @@
|
||||
# @navikt/ds-react
|
||||
|
||||
## 6.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- :sparkles: Ny komponent FormSummary ([#2802](https://github.com/navikt/aksel/pull/2802))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Combobox: Prevents "Enter" while Combobox is focused from submitting form. ([#2861](https://github.com/navikt/aksel/pull/2861))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.6.0
|
||||
- @navikt/aksel-icons@6.6.0
|
||||
|
||||
## 6.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Tabs: Ny prop `lazy` som rendrer innhold i TabPanel selv når panel er skjult (har fortsatt display:none) ([#2621](https://github.com/navikt/aksel/pull/2621))
|
||||
|
||||
- ToggleGroup: Erstattet bruk av dependency `@radix-ui/react-toggle-group` med egen implementasjon. ([#2620](https://github.com/navikt/aksel/pull/2620))
|
||||
|
||||
- Tabs: La til ny prop `fill` som lar Tabs.Tab-elementer strekke seg over tilgjengelig bredde. ([#2621](https://github.com/navikt/aksel/pull/2621))
|
||||
|
||||
- Tabs: Erstattet bruk av dependency `@radix-ui/react-tabs` med egen implementasjon. ([#2621](https://github.com/navikt/aksel/pull/2621))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- DatePicker/MonthPicker: Valgte datoer får nå `aria-pressed` for å bedre indikere valg for skjermleser. ([#2838](https://github.com/navikt/aksel/pull/2838))
|
||||
|
||||
- DatePicker/MonthPicker: `required`-prop stoppet ikke de-select av allerede valgt dato. ([#2838](https://github.com/navikt/aksel/pull/2838))
|
||||
|
||||
- ToggleGroup: La til ny prop `fill` som strekker ToggleGroup til å ta opp all tilgjengelig bredde. ([#2620](https://github.com/navikt/aksel/pull/2620))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.5.0
|
||||
- @navikt/aksel-icons@6.5.0
|
||||
|
||||
## 6.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.4.1
|
||||
- @navikt/aksel-icons@6.4.1
|
||||
|
||||
## 6.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- FileUpload.Item: :boom: Endret API og støtte for å sette ID på knappen ([#2824](https://github.com/navikt/aksel/pull/2824))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.4.0
|
||||
- @navikt/aksel-icons@6.4.0
|
||||
|
||||
## 6.3.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- DatePicker: Fjernet role gridcell fra dato-knapper. ([#2826](https://github.com/navikt/aksel/pull/2826))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.3.6
|
||||
- @navikt/aksel-icons@6.3.6
|
||||
|
||||
## 6.3.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Modal: Fjern tomt element ([#2819](https://github.com/navikt/aksel/pull/2819))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.3.5
|
||||
- @navikt/aksel-icons@6.3.5
|
||||
|
||||
## 6.3.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Switch: Fikset edgecase der checked Switch var visuelt unchecked ([#2810](https://github.com/navikt/aksel/pull/2810))
|
||||
|
||||
- FileUpload.Dropzone: id prop settes på button ([#2817](https://github.com/navikt/aksel/pull/2817))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.3.4
|
||||
- @navikt/aksel-icons@6.3.4
|
||||
|
||||
## 6.3.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-react",
|
||||
"version": "6.3.3",
|
||||
"version": "6.6.0",
|
||||
"description": "React components from the Norwegian Labour and Welfare Administration.",
|
||||
"author": "Aksel, a team part of the Norwegian Labour and Welfare Administration.",
|
||||
"license": "MIT",
|
||||
@@ -24,7 +24,8 @@
|
||||
"/cjs",
|
||||
"/esm",
|
||||
"/src",
|
||||
"!**/*.tsbuildinfo"
|
||||
"!**/*.tsbuildinfo",
|
||||
"!**/*.stories.*"
|
||||
],
|
||||
"main": "./cjs/index.js",
|
||||
"module": "./esm/index.js",
|
||||
@@ -549,6 +550,16 @@
|
||||
"default": "./cjs/form/file-upload/index.js"
|
||||
}
|
||||
},
|
||||
"./FormSummary": {
|
||||
"import": {
|
||||
"types": "./esm/form/form-summary/index.d.ts",
|
||||
"default": "./esm/form/form-summary/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./cjs/form/form-summary/index.d.ts",
|
||||
"default": "./cjs/form/form-summary/index.js"
|
||||
}
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -562,10 +573,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "0.25.4",
|
||||
"@navikt/aksel-icons": "^6.3.3",
|
||||
"@navikt/ds-tokens": "^6.3.3",
|
||||
"@radix-ui/react-tabs": "1.0.0",
|
||||
"@radix-ui/react-toggle-group": "1.0.0",
|
||||
"@navikt/aksel-icons": "^6.6.0",
|
||||
"@navikt/ds-tokens": "^6.6.0",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.0.0",
|
||||
"react-day-picker": "8.10.0"
|
||||
|
||||
@@ -183,3 +183,10 @@ export const Chromatic: Story = {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
};
|
||||
|
||||
export const ChromaticForcedColors: Story = {
|
||||
render: Chromatic.render,
|
||||
parameters: {
|
||||
chromatic: { disable: false, forcedColors: "active" },
|
||||
},
|
||||
};
|
||||
@@ -33,6 +33,7 @@ export interface CopyButtonProps
|
||||
/**
|
||||
* Text shown when button is clicked.
|
||||
* Only set if used with `text`-prop.
|
||||
* @default "Kopiert!"
|
||||
*/
|
||||
activeText?: string;
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import { expect, userEvent, within } from "@storybook/test";
|
||||
import { isSameDay } from "date-fns";
|
||||
import React, { useId, useState } from "react";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { Button } from "../../button";
|
||||
import { HGrid } from "../../layout/grid";
|
||||
import { VStack } from "../../layout/stack";
|
||||
@@ -440,6 +442,43 @@ export const WeekDayClick = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const Required = {
|
||||
render: () => {
|
||||
const { datepickerProps } = useDatepicker({
|
||||
defaultSelected: new Date("Apr 10 2024"),
|
||||
required: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ height: "20rem" }}>
|
||||
<DatePicker.Standalone {...datepickerProps} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const button10 = canvas.getByRole("button", { pressed: true });
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(button10);
|
||||
});
|
||||
|
||||
expect(button10.ariaPressed).toBe("true");
|
||||
|
||||
const button17 = canvas.getByText("17").closest("button");
|
||||
|
||||
expect(button17?.ariaPressed).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
button17 && (await userEvent.click(button17));
|
||||
});
|
||||
|
||||
expect(button17?.ariaPressed).toBe("true");
|
||||
expect(button10.ariaPressed).toBe("false");
|
||||
},
|
||||
};
|
||||
|
||||
export const ModalDemo = () => {
|
||||
const { datepickerProps, inputProps } = useDatepicker({
|
||||
fromDate: new Date("Aug 23 2019"),
|
||||
|
||||
@@ -20,8 +20,11 @@ const DayButton = (props: DayProps) => {
|
||||
name="day"
|
||||
ref={buttonRef}
|
||||
{...dayRender.buttonProps}
|
||||
role={undefined}
|
||||
aria-label={dateTime}
|
||||
aria-hidden={dayRender.activeModifiers.outside}
|
||||
aria-selected={undefined}
|
||||
aria-pressed={!!dayRender.activeModifiers.selected}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -229,12 +229,16 @@ export const useDatepicker = (
|
||||
|
||||
/* Only allow de-selecting if not required */
|
||||
const handleDayClick: DayClickEventHandler = (day, { selected }) => {
|
||||
if (selected && required) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (day && !selected) {
|
||||
handleOpen(false);
|
||||
anchorRef?.focus();
|
||||
}
|
||||
|
||||
if (!required && selected) {
|
||||
if (selected) {
|
||||
updateDate(undefined);
|
||||
setInputValue("");
|
||||
updateValidation({ isValidDate: false, isEmpty: true });
|
||||
|
||||
@@ -225,13 +225,17 @@ export const useMonthpicker = (
|
||||
|
||||
/* Only allow de-selecting if not required */
|
||||
const handleMonthClick = (month?: Date) => {
|
||||
if (!month && required) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (month) {
|
||||
handleOpen(false);
|
||||
setYear(month);
|
||||
anchorRef?.focus();
|
||||
}
|
||||
|
||||
if (!required && !month) {
|
||||
if (!month) {
|
||||
updateMonth(undefined);
|
||||
updateValidation({ isValidMonth: false, isEmpty: true });
|
||||
setInputValue("");
|
||||
|
||||
@@ -66,6 +66,7 @@ export const MonthButton = ({
|
||||
type="button"
|
||||
onClick={() => onSelect(isSelected ? undefined : month)}
|
||||
disabled={isDisabled}
|
||||
aria-pressed={!!isSelected}
|
||||
className={cl("navds-date__month-button", {
|
||||
"rdp-day_today": dateIsInCurrentMonth(month, year),
|
||||
"rdp-day_selected": isSelected,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Meta, StoryFn, StoryObj } from "@storybook/react";
|
||||
import { expect, userEvent, within } from "@storybook/test";
|
||||
import { setYear } from "date-fns";
|
||||
import React, { useId, useState } from "react";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { Button } from "../../button";
|
||||
import { useMonthpicker } from "../hooks";
|
||||
import { DateInputProps } from "../parts/DateInput";
|
||||
@@ -121,25 +123,41 @@ export const UseMonthpickerFormat = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const Required = () => {
|
||||
const { inputProps, monthpickerProps } = useMonthpicker({
|
||||
locale: "nb",
|
||||
defaultSelected: new Date(),
|
||||
disabled: [new Date("Apr 1 2022")],
|
||||
required: true,
|
||||
});
|
||||
export const Required = {
|
||||
render: () => {
|
||||
const { monthpickerProps } = useMonthpicker({
|
||||
defaultSelected: new Date("Apr 10 2024"),
|
||||
required: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ height: "20rem" }}>
|
||||
<MonthPicker {...monthpickerProps}>
|
||||
<MonthPicker.Input
|
||||
{...inputProps}
|
||||
label="Velg måned"
|
||||
variant="monthpicker"
|
||||
/>
|
||||
</MonthPicker>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{ height: "20rem" }}>
|
||||
<MonthPicker.Standalone {...monthpickerProps} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const buttonApr = canvas.getByRole("button", { pressed: true });
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(buttonApr);
|
||||
});
|
||||
|
||||
expect(buttonApr.ariaPressed).toBe("true");
|
||||
|
||||
const buttonSep = canvas.getByText("september").closest("button");
|
||||
|
||||
expect(buttonSep?.ariaPressed).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
buttonSep && (await userEvent.click(buttonSep));
|
||||
});
|
||||
|
||||
expect(buttonSep?.ariaPressed).toBe("true");
|
||||
expect(buttonApr.ariaPressed).toBe("false");
|
||||
},
|
||||
};
|
||||
|
||||
export const UserControlled = () => {
|
||||
@@ -193,7 +211,6 @@ export const Chromatic: Story = {
|
||||
<DisabledMonths />
|
||||
<UseMonthpicker />
|
||||
<UseMonthpickerFormat />
|
||||
<Required />
|
||||
<UserControlled />
|
||||
<FollowYear />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef, useRef, useState } from "react";
|
||||
import React, { forwardRef, useRef } from "react";
|
||||
import { useControllableState } from "../util/hooks";
|
||||
import { OverridableComponent } from "../util/types";
|
||||
import ExpansionCardContent, {
|
||||
ExpansionCardContentProps,
|
||||
@@ -116,24 +117,24 @@ export const ExpansionCard = forwardRef<HTMLDivElement, ExpansionCardProps>(
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [_open, _setOpen] = useState(defaultOpen);
|
||||
|
||||
const shouldFade = useRef<boolean>(!(Boolean(open) || defaultOpen));
|
||||
|
||||
const handleOpen = () => {
|
||||
if (open === undefined) {
|
||||
const newOpen = !_open;
|
||||
_setOpen(newOpen);
|
||||
onToggle?.(newOpen);
|
||||
} else {
|
||||
onToggle?.(!open);
|
||||
}
|
||||
shouldFade.current = true;
|
||||
};
|
||||
const [_open, _setOpen] = useControllableState({
|
||||
value: open,
|
||||
onChange: (newValue) => {
|
||||
onToggle?.(newValue);
|
||||
shouldFade.current = true;
|
||||
},
|
||||
defaultValue: defaultOpen,
|
||||
});
|
||||
|
||||
return (
|
||||
<ExpansionCardContext.Provider
|
||||
value={{ open: open ?? _open, toggleOpen: handleOpen, size }}
|
||||
value={{
|
||||
open: open ?? _open,
|
||||
toggleOpen: () => _setOpen((x) => !x),
|
||||
size,
|
||||
}}
|
||||
>
|
||||
<section
|
||||
{...rest}
|
||||
|
||||
@@ -16,7 +16,7 @@ export const CheckboxGroupContext = createContext<CheckboxGroupState | null>(
|
||||
export interface CheckboxGroupProps
|
||||
extends Omit<
|
||||
FieldsetProps,
|
||||
"onChange" | "errorPropagation" | "defaultValue"
|
||||
"onChange" | "errorPropagation" | "defaultValue" | "nativeReadOnly"
|
||||
> {
|
||||
/**
|
||||
* Collection of `<Checkbox/>`.
|
||||
@@ -27,7 +27,7 @@ export interface CheckboxGroupProps
|
||||
*/
|
||||
value?: any[];
|
||||
/**
|
||||
* Default checked checkboxes on render.
|
||||
* Default checked checkboxes.
|
||||
*/
|
||||
defaultValue?: any[];
|
||||
/**
|
||||
@@ -44,10 +44,10 @@ export interface CheckboxGroupProps
|
||||
*
|
||||
* @example
|
||||
* ```jsx
|
||||
* <CheckboxGroup legend="Hvor vil du sitte?">
|
||||
* <Checkbox value="Bakerst">Bakerst</Checkbox>
|
||||
* <Checkbox value="Midterst">Midterst</Checkbox>
|
||||
* <Checkbox value="Fremst">Fremst</Checkbox>
|
||||
* <CheckboxGroup legend="Transportmiddel">
|
||||
* <Checkbox value="car">Bil</Checkbox>
|
||||
* <Checkbox value="taxi">Drosje</Checkbox>
|
||||
* <Checkbox value="public">Kollektivt</Checkbox>
|
||||
* </CheckboxGroup>
|
||||
* ```
|
||||
*/
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface CheckboxProps
|
||||
*/
|
||||
errorId?: string;
|
||||
/**
|
||||
* Checkbox label
|
||||
* Checkbox label.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
@@ -22,7 +22,7 @@ export interface CheckboxProps
|
||||
*/
|
||||
hideLabel?: boolean;
|
||||
/**
|
||||
* The value of the HTML element.
|
||||
* Checkbox value.
|
||||
*/
|
||||
value?: any;
|
||||
/**
|
||||
@@ -31,7 +31,7 @@ export interface CheckboxProps
|
||||
*/
|
||||
indeterminate?: boolean;
|
||||
/**
|
||||
* Adds a description to extend labeling of Checkbox.
|
||||
* Adds a description to extend the labeling.
|
||||
*/
|
||||
description?: string;
|
||||
}
|
||||
@@ -97,9 +97,17 @@ export const FilteredOptionsProvider = ({
|
||||
[filteredOptionsUtils.getAddNewOptionId(id)]: allowNewValues
|
||||
? toComboboxOption(value)
|
||||
: undefined,
|
||||
...customOptions.reduce(
|
||||
(acc, customOption) => ({
|
||||
...acc,
|
||||
[filteredOptionsUtils.getOptionId(id, customOption.label)]:
|
||||
customOption,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
},
|
||||
),
|
||||
[allowNewValues, id, options, value],
|
||||
[allowNewValues, customOptions, id, options, value],
|
||||
);
|
||||
|
||||
useClientLayoutEffect(() => {
|
||||
|
||||
@@ -128,6 +128,10 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
removeSelectedOption(lastSelectedOption);
|
||||
}
|
||||
}
|
||||
} else if (e.key === "Enter" || e.key === "Accept") {
|
||||
if (activeDecendantId || value) {
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if (e.key === "ArrowDown") {
|
||||
// Check that cursor position is at the end of the input field,
|
||||
// so we don't interfere with text editing
|
||||
@@ -182,6 +186,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
{...omit(inputProps, ["aria-invalid"])}
|
||||
ref={ref}
|
||||
value={value}
|
||||
onBlur={() => virtualFocus.moveFocusToTop()}
|
||||
onChange={onChangeHandler}
|
||||
type="text"
|
||||
role="combobox"
|
||||
|
||||
@@ -624,3 +624,71 @@ export const TestHoverAndFocusSwitching: StoryObject = {
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const TestEnterNotSubmittingForm: StoryObj<{
|
||||
onSubmit: ReturnType<typeof fn>;
|
||||
}> = {
|
||||
args: {
|
||||
onSubmit: fn(),
|
||||
},
|
||||
render: ({ onSubmit }) => {
|
||||
return (
|
||||
<form action="https://www.aksel.nav.no" method="get" onSubmit={onSubmit}>
|
||||
<UNSAFE_Combobox
|
||||
options={options}
|
||||
label="Hva er dine favorittfrukter?"
|
||||
isMultiSelect
|
||||
allowNewValues
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
args.onSubmit.mockClear();
|
||||
const canvas = within(canvasElement);
|
||||
const waitTime = 0; // Change for debugging
|
||||
|
||||
await sleep(waitTime);
|
||||
|
||||
const getInput = () =>
|
||||
canvas.getByRole("combobox", {
|
||||
name: "Hva er dine favorittfrukter?",
|
||||
});
|
||||
|
||||
const getOption = (name: string, selected: boolean) =>
|
||||
canvas.getByRole("option", { name, selected });
|
||||
await userEvent.click(getInput(), { delay: waitTime });
|
||||
|
||||
await userEvent.keyboard("{ArrowDown}", { delay: waitTime });
|
||||
expect(getInput().getAttribute("aria-activedescendant")).toBe(
|
||||
getOption("banana", false).getAttribute("id"),
|
||||
);
|
||||
|
||||
await userEvent.keyboard("{Enter}", { delay: waitTime });
|
||||
expect(args.onSubmit).not.toHaveBeenCalled();
|
||||
expect(getOption("banana", true)).toBeVisible();
|
||||
|
||||
await userEvent.keyboard("{Shift>}{Tab}", { delay: waitTime });
|
||||
|
||||
await userEvent.keyboard("{Enter}", { delay: waitTime });
|
||||
expect(args.onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.keyboard("test"); // Type option that does not exist
|
||||
await userEvent.keyboard("{Enter}", { delay: waitTime });
|
||||
expect(args.onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.keyboard("{ArrowDown}", { delay: waitTime }); // Select "test" custom option
|
||||
expect(
|
||||
canvas.getByRole("option", { name: "test", selected: true }),
|
||||
).toBeVisible();
|
||||
await userEvent.keyboard("{Enter}", { delay: waitTime }); // De-select "test"
|
||||
expect(
|
||||
canvas.queryByRole("option", { name: "test", selected: false }),
|
||||
).toBeNull();
|
||||
expect(args.onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.keyboard("{Escape}", { delay: waitTime }); // Clear input field
|
||||
await userEvent.keyboard("{Enter}", { delay: waitTime }); // Enter on empty Input with closed list
|
||||
expect(args.onSubmit).toHaveBeenCalledOnce();
|
||||
},
|
||||
};
|
||||
@@ -89,8 +89,8 @@ interface FileUploadComponent
|
||||
* ```jsx
|
||||
* <FileUpload.Item file={file} status="uploading" />
|
||||
* <FileUpload.Item file={file} status="downloading" />
|
||||
* <FileUpload.Item file={file} itemAction="retry" onRetry={...} />
|
||||
* <FileUpload.Item file={file} itemAction="delete" onDelete={...} />
|
||||
* <FileUpload.Item file={file} button={{ action:"retry", onClick:... }} />
|
||||
* <FileUpload.Item file={file} button={{ action:"delete", onClick:... }} />
|
||||
* ```
|
||||
*/
|
||||
Item: typeof Item;
|
||||
|
||||
@@ -57,51 +57,63 @@ export const States: StoryObj<typeof FileUpload.Item> = {
|
||||
file={fileTxt}
|
||||
error="Error og uploading"
|
||||
status="uploading"
|
||||
onRetry={() => onRetry(fileTxt)}
|
||||
onDelete={() => onDelete(fileTxt)}
|
||||
button={{
|
||||
onClick: () => onDelete(fileTxt),
|
||||
action: "delete",
|
||||
}}
|
||||
/>
|
||||
<h2>Item Actions</h2>
|
||||
<h3>status + delete</h3>
|
||||
<FileUpload.Item
|
||||
file={fileDocx}
|
||||
onDelete={() => onDelete(fileDocx)}
|
||||
onRetry={() => onRetry(fileDocx)}
|
||||
status="uploading"
|
||||
itemAction="delete"
|
||||
button={{
|
||||
onClick: () => onDelete(fileDocx),
|
||||
action: "delete",
|
||||
}}
|
||||
/>
|
||||
<h3>status + retry</h3>
|
||||
<FileUpload.Item
|
||||
file={fileDocx}
|
||||
onDelete={() => onDelete(fileDocx)}
|
||||
onRetry={() => onRetry(fileDocx)}
|
||||
status="downloading"
|
||||
itemAction="retry"
|
||||
button={{
|
||||
onClick: () => onRetry(fileDocx),
|
||||
action: "retry",
|
||||
}}
|
||||
/>
|
||||
<h3>retry</h3>
|
||||
<FileUpload.Item
|
||||
file={fileCsv}
|
||||
onRetry={() => onRetry(fileCsv)}
|
||||
itemAction="retry"
|
||||
button={{
|
||||
onClick: () => onRetry(fileCsv),
|
||||
action: "retry",
|
||||
}}
|
||||
/>
|
||||
<h3>delete</h3>
|
||||
<FileUpload.Item
|
||||
file={filePptx}
|
||||
onDelete={() => onDelete(filePptx)}
|
||||
itemAction="delete"
|
||||
button={{
|
||||
onClick: () => onDelete(filePptx),
|
||||
action: "delete",
|
||||
}}
|
||||
/>
|
||||
<h3>retry + error</h3>
|
||||
<FileUpload.Item
|
||||
file={fileCsv}
|
||||
error="Error og onRetry"
|
||||
onRetry={() => onRetry(fileCsv)}
|
||||
itemAction="retry"
|
||||
button={{
|
||||
onClick: () => onRetry(fileCsv),
|
||||
action: "retry",
|
||||
}}
|
||||
/>
|
||||
<h3>delete + error</h3>
|
||||
<FileUpload.Item
|
||||
file={filePptx}
|
||||
error="Error og onDelete"
|
||||
onDelete={() => onDelete(filePptx)}
|
||||
itemAction="delete"
|
||||
button={{
|
||||
onClick: () => onDelete(filePptx),
|
||||
action: "delete",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -25,6 +25,7 @@ const MAX_SIZE = MAX_SIZE_MB * 1024 * 1024;
|
||||
|
||||
const CustomItem = ({
|
||||
index,
|
||||
onDelete,
|
||||
...props
|
||||
}: FileUploadItemProps & {
|
||||
index: number;
|
||||
@@ -45,7 +46,10 @@ const CustomItem = ({
|
||||
<FileUpload.Item
|
||||
{...props}
|
||||
status={loading ? "uploading" : "idle"}
|
||||
itemAction="delete"
|
||||
button={{
|
||||
action: "delete",
|
||||
onClick: onDelete,
|
||||
}}
|
||||
as="li"
|
||||
/>
|
||||
);
|
||||
@@ -165,7 +169,10 @@ export const Single: StoryFn = () => {
|
||||
key={file.file.name}
|
||||
file={file.file}
|
||||
error={file.error ? errors[file.reasons[0]] : undefined}
|
||||
onDelete={removeFile}
|
||||
button={{
|
||||
action: "delete",
|
||||
onClick: removeFile,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
@@ -194,12 +201,17 @@ export const Translation = () => (
|
||||
<FileUpload.Dropzone label="Last opp bilder" onSelect={console.log} />
|
||||
<FileUpload.Item
|
||||
file={{ name: "eksempel.png", size: 200000 }}
|
||||
onDelete={() => null}
|
||||
button={{
|
||||
action: "delete",
|
||||
onClick: () => null,
|
||||
}}
|
||||
/>
|
||||
<FileUpload.Item
|
||||
file={{ name: "eksempel.png", size: 200000 }}
|
||||
itemAction="retry"
|
||||
onRetry={() => null}
|
||||
button={{
|
||||
action: "retry",
|
||||
onClick: () => null,
|
||||
}}
|
||||
/>
|
||||
<FileUpload.Item
|
||||
file={{ name: "eksempel.png", size: 200000 }}
|
||||
|
||||
@@ -30,6 +30,7 @@ const Dropzone = forwardRef<HTMLInputElement, FileUploadDropzoneProps>(
|
||||
disabled,
|
||||
translations,
|
||||
onClick,
|
||||
id: buttonId,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
@@ -46,7 +47,10 @@ const Dropzone = forwardRef<HTMLInputElement, FileUploadDropzoneProps>(
|
||||
const _disabled = disabled ?? fileLimitReached;
|
||||
|
||||
const { inputProps, errorId, showErrorMsg, hasError, inputDescriptionId } =
|
||||
useFormField({ ...props, disabled: _disabled }, "fileUpload");
|
||||
useFormField(
|
||||
{ ...omit(props, ["id"]), disabled: _disabled },
|
||||
"fileUpload",
|
||||
);
|
||||
const {
|
||||
id: inputId,
|
||||
"aria-describedby": ariaDescribedby,
|
||||
@@ -129,8 +133,9 @@ const Dropzone = forwardRef<HTMLInputElement, FileUploadDropzoneProps>(
|
||||
<BodyShort as="div">{translate("dropzone.or")}</BodyShort>
|
||||
</div>
|
||||
<Button
|
||||
{...omit(rest, ["errorId", "id"])}
|
||||
{...omit(rest, ["errorId"])}
|
||||
{...inputPropsRest}
|
||||
id={buttonId}
|
||||
aria-describedby={cl(labelId, ariaDescribedby)}
|
||||
className="navds-dropzone__area-button"
|
||||
type="button"
|
||||
|
||||
@@ -12,12 +12,13 @@ import ItemIcon from "./ItemIcon";
|
||||
import ItemName from "./ItemName";
|
||||
import { formatFileSize } from "./utils/format-file-size";
|
||||
|
||||
export interface FileItemBaseProps {
|
||||
export interface FileUploadItemProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Overrides html-tag
|
||||
* @default "div"
|
||||
*/
|
||||
as?: "div" | "li";
|
||||
as?: ("div" | "li") & React.ElementType;
|
||||
/**
|
||||
* Either a native File or file metadata.
|
||||
*/
|
||||
@@ -43,37 +44,20 @@ export interface FileItemBaseProps {
|
||||
* @default "idle"
|
||||
*/
|
||||
status?: "downloading" | "uploading" | "idle";
|
||||
/**
|
||||
* Props for the action button.
|
||||
*/
|
||||
button?: {
|
||||
action: "delete" | "retry";
|
||||
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
id?: string;
|
||||
};
|
||||
/**
|
||||
* i18n-API for customizing texts and labels
|
||||
*/
|
||||
translations?: ComponentTranslation<"FileUpload">["item"];
|
||||
onRetry?: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
onDelete?: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
||||
type FileItemActionDelete = {
|
||||
onDelete: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
itemAction: "delete";
|
||||
};
|
||||
|
||||
type FileItemActionRetry = {
|
||||
onRetry: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
itemAction: "retry";
|
||||
};
|
||||
|
||||
type FileItemActionNone = {
|
||||
itemAction?: "none";
|
||||
};
|
||||
|
||||
type FileItemConditionalProps =
|
||||
| FileItemActionDelete
|
||||
| FileItemActionRetry
|
||||
| FileItemActionNone;
|
||||
|
||||
export type FileUploadItemProps = FileItemBaseProps &
|
||||
FileItemConditionalProps &
|
||||
React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const Item: OverridableComponent<FileUploadItemProps, HTMLDivElement> =
|
||||
forwardRef(
|
||||
(
|
||||
@@ -81,16 +65,14 @@ export const Item: OverridableComponent<FileUploadItemProps, HTMLDivElement> =
|
||||
as: Component = "div",
|
||||
file,
|
||||
status = "idle",
|
||||
onDelete,
|
||||
onRetry,
|
||||
error,
|
||||
className,
|
||||
href,
|
||||
onFileClick,
|
||||
itemAction = "delete",
|
||||
button,
|
||||
translations,
|
||||
...rest
|
||||
},
|
||||
}: FileUploadItemProps,
|
||||
ref,
|
||||
) => {
|
||||
const context = useFileUploadTranslation(false);
|
||||
@@ -148,14 +130,14 @@ export const Item: OverridableComponent<FileUploadItemProps, HTMLDivElement> =
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status === "idle" && (
|
||||
{status === "idle" && button && (
|
||||
<ItemButton
|
||||
file={file}
|
||||
onRetry={onRetry}
|
||||
onDelete={onDelete}
|
||||
action={itemAction}
|
||||
retryTitle={translate("item.retryButtonTitle")}
|
||||
deleteTitle={translate("item.deleteButtonTitle")}
|
||||
{...button}
|
||||
title={translate(
|
||||
button.action === "retry"
|
||||
? "item.retryButtonTitle"
|
||||
: "item.deleteButtonTitle",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,52 +1,27 @@
|
||||
import React from "react";
|
||||
import { ArrowsCirclepathIcon, TrashIcon } from "@navikt/aksel-icons";
|
||||
import { Button } from "../../../../button";
|
||||
import { FileItem } from "./Item.types";
|
||||
|
||||
interface Props {
|
||||
file: FileItem;
|
||||
onRetry?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onDelete?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
action: "delete" | "retry" | "none";
|
||||
retryTitle: string;
|
||||
deleteTitle: string;
|
||||
action: "delete" | "retry";
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
id?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const ItemButton = ({
|
||||
onRetry,
|
||||
onDelete,
|
||||
action,
|
||||
retryTitle,
|
||||
deleteTitle,
|
||||
}: Props) => {
|
||||
if (action === "none") {
|
||||
return null;
|
||||
}
|
||||
const ItemButton = ({ action, onClick, id, title }: Props) => {
|
||||
const Icon = action === "delete" ? TrashIcon : ArrowsCirclepathIcon;
|
||||
|
||||
if (onRetry && action === "retry") {
|
||||
return (
|
||||
<Button
|
||||
className="navds-file-item__button"
|
||||
type="button"
|
||||
variant="tertiary-neutral"
|
||||
onClick={onRetry}
|
||||
icon={<ArrowsCirclepathIcon title={retryTitle} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (onDelete && action === "delete") {
|
||||
return (
|
||||
<Button
|
||||
className="navds-file-item__button"
|
||||
type="button"
|
||||
variant="tertiary-neutral"
|
||||
onClick={onDelete}
|
||||
icon={<TrashIcon title={deleteTitle} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return (
|
||||
<Button
|
||||
id={id}
|
||||
className="navds-file-item__button"
|
||||
type="button"
|
||||
variant="tertiary-neutral"
|
||||
onClick={onClick}
|
||||
icon={<Icon title={title} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemButton;
|
||||
@@ -0,0 +1,388 @@
|
||||
import { Meta, StoryFn, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { LinkIcon } from "@navikt/aksel-icons";
|
||||
import { VStack } from "../../layout/stack";
|
||||
import { Link } from "../../link";
|
||||
import FormSummary from "./FormSummary";
|
||||
|
||||
const meta: Meta<typeof FormSummary> = {
|
||||
title: "ds-react/FormSummary",
|
||||
component: FormSummary,
|
||||
decorators: [
|
||||
(story) => (
|
||||
<div style={{ width: 600, maxWidth: "100%", margin: "0 auto" }}>
|
||||
{story()}
|
||||
</div>
|
||||
),
|
||||
],
|
||||
parameters: { layout: "padded", chromatic: { disable: true } },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
export const Default: StoryFn<typeof FormSummary> = () => (
|
||||
<FormSummary>
|
||||
<FormSummary.Header>
|
||||
<FormSummary.Heading level="2">Personalia</FormSummary.Heading>
|
||||
<FormSummary.EditLink href="#" />
|
||||
</FormSummary.Header>
|
||||
|
||||
<FormSummary.Answers>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Navn</FormSummary.Label>
|
||||
<FormSummary.Value>Ola Nordmann</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Adresse</FormSummary.Label>
|
||||
<FormSummary.Value>
|
||||
Gate 123
|
||||
<br />
|
||||
1234 Sted
|
||||
</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Fødselsnummer</FormSummary.Label>
|
||||
<FormSummary.Value>12345678910</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Barn nr. 1</FormSummary.Label>
|
||||
<FormSummary.Value>
|
||||
<FormSummary.Answers>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Navn</FormSummary.Label>
|
||||
<FormSummary.Value>Kari Nordmann</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Kjønn</FormSummary.Label>
|
||||
<FormSummary.Value>Jente</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Alder</FormSummary.Label>
|
||||
<FormSummary.Value>6</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
</FormSummary.Answers>
|
||||
</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Barn nr. 2</FormSummary.Label>
|
||||
<FormSummary.Value>
|
||||
<FormSummary.Answers>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Navn</FormSummary.Label>
|
||||
<FormSummary.Value>Per Nordmann</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Kjønn</FormSummary.Label>
|
||||
<FormSummary.Value>Gutt</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Alder</FormSummary.Label>
|
||||
<FormSummary.Value>7</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
</FormSummary.Answers>
|
||||
</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Kontaktinformasjon</FormSummary.Label>
|
||||
<FormSummary.Value>
|
||||
<Link>Fyll ut kontaktinformasjon</Link>
|
||||
</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Hvordan vil du bli varslet?</FormSummary.Label>
|
||||
<FormSummary.Value>E-post</FormSummary.Value>
|
||||
<FormSummary.Value>SMS</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
</FormSummary.Answers>
|
||||
</FormSummary>
|
||||
);
|
||||
|
||||
export const LongTexts: StoryFn<typeof FormSummary> = () => (
|
||||
<FormSummary>
|
||||
<FormSummary.Header>
|
||||
<FormSummary.Heading level="2">
|
||||
Arbeidsforhold som du har i eller utenfor EØS-området
|
||||
</FormSummary.Heading>
|
||||
<FormSummary.EditLink href="#">
|
||||
<LinkIcon />
|
||||
Jeg vil endre dette svaret
|
||||
</FormSummary.EditLink>
|
||||
</FormSummary.Header>
|
||||
<FormSummary.Answers>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>
|
||||
Har du jobbet i et annet EØS-land, Sveits eller Storbritannia i løpet
|
||||
av de siste 36 månedene? Om ja, hvilket land og hvor lenge jobbet du
|
||||
der? Kan du fortelle litt om hva du jobbet med?
|
||||
</FormSummary.Label>
|
||||
<FormSummary.Value>
|
||||
Nei, jeg har ikke jobbet i et annet EØS-land, Sveits eller
|
||||
Storbritannia i løpet av de siste 36 månedene. Og jeg har heller ikke
|
||||
jobbet i Norge i løpet av de siste 36 månedene. Men jeg har jobbet i
|
||||
et annet land utenfor EØS-området. Det var i Sør-Korea, og jeg jobbet
|
||||
som lærer på en internasjonal skole. Jeg jobbet der i 12 måneder, og
|
||||
det var en veldig spennende opplevelse. Jeg lærte mye om koreansk
|
||||
kultur og språk, og jeg fikk mange nye venner. Jeg savner Sør-Korea
|
||||
veldig mye, og jeg håper å kunne dra tilbake.
|
||||
<FormSummary.Answers>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>
|
||||
Har du jobbet i et annet EØS-land, Sveits eller Storbritannia i
|
||||
løpet av de siste 36 månedene? Om ja, hvilket land og hvor lenge
|
||||
jobbet du der? Kan du fortelle litt om hva du jobbet med?
|
||||
</FormSummary.Label>
|
||||
<FormSummary.Value>
|
||||
Nei, jeg har ikke jobbet i et annet EØS-land, Sveits eller
|
||||
Storbritannia i løpet av de siste 36 månedene. Og jeg har heller
|
||||
ikke jobbet i Norge i løpet av de siste 36 månedene. Men jeg har
|
||||
jobbet i et annet land utenfor EØS-området. Det var i Sør-Korea,
|
||||
og jeg jobbet som lærer på en internasjonal skole. Jeg jobbet
|
||||
der i 12 måneder, og det var en veldig spennende opplevelse. Jeg
|
||||
lærte mye om koreansk kultur og språk, og jeg fikk mange nye
|
||||
venner. Jeg savner Sør-Korea veldig mye, og jeg håper å kunne
|
||||
dra tilbake.
|
||||
</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
</FormSummary.Answers>
|
||||
</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
</FormSummary.Answers>
|
||||
</FormSummary>
|
||||
);
|
||||
|
||||
export const NoLink: StoryFn<typeof FormSummary> = () => (
|
||||
<FormSummary>
|
||||
<FormSummary.Header>
|
||||
<FormSummary.Heading level="2">
|
||||
Arbeidsforhold som du har i eller utenfor EØS-området
|
||||
</FormSummary.Heading>
|
||||
</FormSummary.Header>
|
||||
|
||||
<FormSummary.Answers>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Navn</FormSummary.Label>
|
||||
<FormSummary.Value>Ola Nordmann</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Fødselsnummer</FormSummary.Label>
|
||||
<FormSummary.Value>12345678910</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
</FormSummary.Answers>
|
||||
</FormSummary>
|
||||
);
|
||||
|
||||
const answers = [
|
||||
{
|
||||
label: "Fortell din livshistorie",
|
||||
value: `Det hele startet da jeg ble født en kald vinterdag i desember.
|
||||
Jeg var en litt spesiell baby, for jeg hadde masse hår på hodet. Mamma
|
||||
og pappa ble veldig overrasket da de så meg for første gang. De hadde egentlig
|
||||
forventet at jeg skulle være helt normal og kjedelig. Du vil kanskje ikke høre resten
|
||||
av historien min, for den er ganske lang og kjedelig...`,
|
||||
},
|
||||
{
|
||||
label: "Fødselsnummer",
|
||||
value: "12345678910",
|
||||
},
|
||||
{
|
||||
label: "Ting du har i sekken.",
|
||||
value: [
|
||||
{
|
||||
label: "Saks x2",
|
||||
value: `Jeg liker å ha med meg en saks i sekken min. Det er alltid kjekt å ha en saks. Jeg pakker gjerne med meg to saks. Da har jeg en reserve hvis den første saksen min skulle bli borte. Det er veldig kjekt.`,
|
||||
},
|
||||
{
|
||||
label: "Blyant",
|
||||
value:
|
||||
"Det er ikke mulig å skrive uten en blyant. Da blir det bare rot.",
|
||||
},
|
||||
{
|
||||
label: "Kake",
|
||||
value: `Jeg har alltid en kake i sekken min. Den blir litt most etter en
|
||||
stund i sekken min men det gjør ingenting. Jeg liker moste kaker. Det er veldig godt.`,
|
||||
},
|
||||
{
|
||||
label: "Kaffekopp",
|
||||
value: "Dette har jeg ikke i sekken min",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Flere ting du har i sekken.",
|
||||
value: [
|
||||
{
|
||||
label: "Saks",
|
||||
value: `En saks til`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const RealisticUsage: StoryFn<typeof FormSummary> = () => (
|
||||
<FormSummary>
|
||||
<FormSummary.Header>
|
||||
<FormSummary.Heading level="2">
|
||||
Diverse informasjon om forskjellige ting og tang. Kjekt å vite.
|
||||
</FormSummary.Heading>
|
||||
<FormSummary.EditLink href="#" />
|
||||
</FormSummary.Header>
|
||||
|
||||
<FormSummary.Answers>
|
||||
{answers.map((answer, index) => (
|
||||
<FormSummary.Answer key={index}>
|
||||
<FormSummary.Label>{answer.label}</FormSummary.Label>
|
||||
<FormSummary.Value>
|
||||
{Array.isArray(answer.value) ? (
|
||||
<FormSummary.Answers>
|
||||
{answer.value.map((subAnswer, subIndex) => (
|
||||
<FormSummary.Answer key={subIndex}>
|
||||
<FormSummary.Label>{subAnswer.label}</FormSummary.Label>
|
||||
<FormSummary.Value>{subAnswer.value}</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
))}
|
||||
</FormSummary.Answers>
|
||||
) : (
|
||||
answer.value
|
||||
)}
|
||||
</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
))}
|
||||
</FormSummary.Answers>
|
||||
</FormSummary>
|
||||
);
|
||||
|
||||
export const Empty: StoryFn<typeof FormSummary> = () => (
|
||||
<VStack gap="8">
|
||||
<FormSummary>
|
||||
<FormSummary.Header>
|
||||
<FormSummary.Heading level="2">Just Header</FormSummary.Heading>
|
||||
<FormSummary.EditLink href="#" />
|
||||
</FormSummary.Header>
|
||||
</FormSummary>
|
||||
|
||||
<FormSummary>
|
||||
<FormSummary.Header>
|
||||
<FormSummary.Heading level="2">Empty Answers</FormSummary.Heading>
|
||||
<FormSummary.EditLink href="#" />
|
||||
</FormSummary.Header>
|
||||
|
||||
<FormSummary.Answers>{null}</FormSummary.Answers>
|
||||
</FormSummary>
|
||||
|
||||
<FormSummary>
|
||||
<FormSummary.Header>
|
||||
<FormSummary.Heading level="2">Empty Answer</FormSummary.Heading>
|
||||
<FormSummary.EditLink href="#" />
|
||||
</FormSummary.Header>
|
||||
|
||||
<FormSummary.Answers>
|
||||
<FormSummary.Answer>{null}</FormSummary.Answer>
|
||||
</FormSummary.Answers>
|
||||
</FormSummary>
|
||||
|
||||
<FormSummary>
|
||||
<FormSummary.Header>
|
||||
<FormSummary.Heading level="2">Empty Label & Value</FormSummary.Heading>
|
||||
<FormSummary.EditLink href="#" />
|
||||
</FormSummary.Header>
|
||||
|
||||
<FormSummary.Answers>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>{null}</FormSummary.Label>
|
||||
<FormSummary.Value>{null}</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
</FormSummary.Answers>
|
||||
</FormSummary>
|
||||
|
||||
<FormSummary>
|
||||
<FormSummary.Header>
|
||||
<FormSummary.Heading level="2">Empty Value</FormSummary.Heading>
|
||||
<FormSummary.EditLink href="#" />
|
||||
</FormSummary.Header>
|
||||
|
||||
<FormSummary.Answers>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Adresse</FormSummary.Label>
|
||||
<FormSummary.Value>{null}</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
</FormSummary.Answers>
|
||||
</FormSummary>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
export const Chromatic: StoryObj<typeof FormSummary> = {
|
||||
render: () => (
|
||||
<div>
|
||||
<div>
|
||||
<h2>Default</h2>
|
||||
<Default>{null}</Default>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Long Texts</h2>
|
||||
<LongTexts>{null}</LongTexts>
|
||||
</div>
|
||||
<div>
|
||||
<h2>No Link</h2>
|
||||
<NoLink>{null}</NoLink>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Realistic Usage</h2>
|
||||
<RealisticUsage>{null}</RealisticUsage>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Empty</h2>
|
||||
<Empty>{null}</Empty>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
chromatic: {
|
||||
disable: false,
|
||||
modes: {
|
||||
default: {
|
||||
viewport: {
|
||||
width: 620,
|
||||
},
|
||||
},
|
||||
sm: {
|
||||
viewport: {
|
||||
width: 479,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CommaSeparated: StoryFn<typeof FormSummary> = () => (
|
||||
<FormSummary>
|
||||
<FormSummary.Header>
|
||||
<FormSummary.Heading level="2">Personalia</FormSummary.Heading>
|
||||
<FormSummary.EditLink href="#" />
|
||||
</FormSummary.Header>
|
||||
|
||||
<FormSummary.Answers>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>
|
||||
Hvilket transportmiddel er du mest kjent for?
|
||||
</FormSummary.Label>
|
||||
<FormSummary.Value>
|
||||
sykkel, tog, bil, buss, sparkesykkel
|
||||
</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Favoritt slagord?</FormSummary.Label>
|
||||
<FormSummary.Value>{`I'll be back.`}</FormSummary.Value>
|
||||
<FormSummary.Value>Hasta la vista, baby.</FormSummary.Value>
|
||||
<FormSummary.Value>Say hello to my little friend.</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
</FormSummary.Answers>
|
||||
</FormSummary>
|
||||
);
|
||||
@@ -0,0 +1,106 @@
|
||||
import cl from "clsx";
|
||||
import React, { HTMLAttributes, forwardRef } from "react";
|
||||
import FormSummaryAnswer from "./FormSummaryAnswer";
|
||||
import FormSummaryAnswers from "./FormSummaryAnswers";
|
||||
import FormSummaryEditLink from "./FormSummaryEditLink";
|
||||
import FormSummaryHeader from "./FormSummaryHeader";
|
||||
import FormSummaryHeading from "./FormSummaryHeading";
|
||||
import FormSummaryLabel from "./FormSummaryLabel";
|
||||
import FormSummaryValue from "./FormSummaryValue";
|
||||
|
||||
interface FormSummaryComponent
|
||||
extends React.ForwardRefExoticComponent<
|
||||
FormSummaryProps & React.RefAttributes<HTMLDivElement>
|
||||
> {
|
||||
/**
|
||||
* Must include `<FormSummary.Heading>` and optionally `<FormSummary.EditLink>`.
|
||||
*/
|
||||
Header: typeof FormSummaryHeader;
|
||||
/**
|
||||
* Typographic Heading to use in the `FormSummary.Header` component.
|
||||
*/
|
||||
Heading: typeof FormSummaryHeading;
|
||||
/**
|
||||
* Link to edit the answers to use in the `FormSummary.Header` component. Should link to the relevant part of the form.
|
||||
*/
|
||||
EditLink: typeof FormSummaryEditLink;
|
||||
/**
|
||||
* Wrapper component for the answers.
|
||||
*/
|
||||
Answers: typeof FormSummaryAnswers;
|
||||
/**
|
||||
* Wrapper component for each answer. To be used in the `FormSummary.Answers` component.
|
||||
*/
|
||||
Answer: typeof FormSummaryAnswer;
|
||||
/**
|
||||
* Corresponds to the question in the form. To be used in the `FormSummary.Answer` component.
|
||||
*/
|
||||
Label: typeof FormSummaryLabel;
|
||||
/**
|
||||
* Corresponds to the answer in the form. To be used in the `FormSummary.Answer` component.
|
||||
*/
|
||||
Value: typeof FormSummaryValue;
|
||||
}
|
||||
|
||||
export interface FormSummaryProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Must include:
|
||||
*
|
||||
* - `<FormSummary.Header>`
|
||||
* - `<FormSummary.Answers>`
|
||||
*
|
||||
* @example
|
||||
* <FormSummary>
|
||||
* <FormSummary.Header>
|
||||
* <FormSummary.Heading level="2">HeadingTekst</FormSummary.Heading>
|
||||
* <FormSummary.EditLink href="#" />
|
||||
* </FormSummary.Header>
|
||||
* <FormSummary.Answers>
|
||||
* <FormSummary.Answer>
|
||||
* <FormSummary.Label>Navn</FormSummary.Label>
|
||||
* <FormSummary.Value>Ola Nordmann</FormSummary.Value>
|
||||
* </FormSummary.Answer>
|
||||
* </FormSummary.Answers>
|
||||
* </FormSummary>
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A summary of a previously answered form.
|
||||
*
|
||||
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/formsummary)
|
||||
*
|
||||
* @example
|
||||
* <FormSummary>
|
||||
* <FormSummary.Header>
|
||||
* <FormSummary.Heading level="2">HeadingTekst</FormSummary.Heading>
|
||||
* <FormSummary.EditLink href="#" />
|
||||
* </FormSummary.Header>
|
||||
* <FormSummary.Answers>
|
||||
* <FormSummary.Answer>
|
||||
* <FormSummary.Label>Navn</FormSummary.Label>
|
||||
* <FormSummary.Value>Ola Nordmann</FormSummary.Value>
|
||||
* </FormSummary.Answer>
|
||||
* </FormSummary.Answers>
|
||||
* </FormSummary>
|
||||
*/
|
||||
export const FormSummary = forwardRef<HTMLDivElement, FormSummaryProps>(
|
||||
({ children, className, ...rest }, ref) => {
|
||||
return (
|
||||
<div ref={ref} {...rest} className={cl("navds-form-summary", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
) as FormSummaryComponent;
|
||||
|
||||
FormSummary.Header = FormSummaryHeader;
|
||||
FormSummary.Heading = FormSummaryHeading;
|
||||
FormSummary.EditLink = FormSummaryEditLink;
|
||||
FormSummary.Answers = FormSummaryAnswers;
|
||||
FormSummary.Answer = FormSummaryAnswer;
|
||||
FormSummary.Label = FormSummaryLabel;
|
||||
FormSummary.Value = FormSummaryValue;
|
||||
|
||||
export default FormSummary;
|
||||
@@ -0,0 +1,27 @@
|
||||
import cl from "clsx";
|
||||
import React from "react";
|
||||
|
||||
export interface FormSummaryAnswerProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Must include:
|
||||
* - `<FormSummary.Label>`
|
||||
* - `<FormSummary.Value>`
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FormSummaryAnswer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
FormSummaryAnswerProps
|
||||
>(({ children, className, ...rest }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
{...rest}
|
||||
className={cl("navds-form-summary__answer", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
));
|
||||
|
||||
export default FormSummaryAnswer;
|
||||
@@ -0,0 +1,25 @@
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
export interface FormSummaryAnswersProps
|
||||
extends React.HTMLAttributes<HTMLDListElement> {
|
||||
/**
|
||||
* Must include one or more of `<FormSummary.Answer>`.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FormSummaryAnswers = forwardRef<
|
||||
HTMLDListElement,
|
||||
FormSummaryAnswersProps
|
||||
>(({ children, className, ...rest }: FormSummaryAnswersProps, ref) => (
|
||||
<dl
|
||||
ref={ref}
|
||||
{...rest}
|
||||
className={cl("navds-form-summary__answers", className)}
|
||||
>
|
||||
{children}
|
||||
</dl>
|
||||
));
|
||||
|
||||
export default FormSummaryAnswers;
|
||||
@@ -0,0 +1,35 @@
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef } from "react";
|
||||
import { Link } from "../../link";
|
||||
import { OverridableComponent } from "../../util/types";
|
||||
|
||||
// export type FormSummaryEditProps = Partial<LinkProps>;
|
||||
export interface FormSummaryEditProps
|
||||
extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
/**
|
||||
* URL to the relevant part of the form, where the answers can be edited.
|
||||
*/
|
||||
href?: string;
|
||||
/**
|
||||
* Link text.
|
||||
*
|
||||
* [How to write good links](https://aksel.nav.no/god-praksis/artikler/lenker).
|
||||
* @default "Endre svar"
|
||||
*/
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FormSummaryEditLink: OverridableComponent<
|
||||
FormSummaryEditProps,
|
||||
HTMLAnchorElement
|
||||
> = forwardRef(({ children = "Endre svar", className, ...rest }, ref) => (
|
||||
<Link
|
||||
ref={ref}
|
||||
{...rest}
|
||||
className={cl("navds-form-summary__edit", className)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
));
|
||||
|
||||
export default FormSummaryEditLink;
|
||||
@@ -0,0 +1,25 @@
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
export interface FormSummaryHeaderProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Must include `<FormSummary.Heading>` and optionally `<FormSummary.EditLink>`.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FormSummaryHeader = forwardRef<
|
||||
HTMLDivElement,
|
||||
FormSummaryHeaderProps
|
||||
>(({ children, className, ...rest }, ref) => (
|
||||
<header
|
||||
ref={ref}
|
||||
{...rest}
|
||||
className={cl("navds-form-summary__header", className)}
|
||||
>
|
||||
{children}
|
||||
</header>
|
||||
));
|
||||
|
||||
export default FormSummaryHeader;
|
||||
@@ -0,0 +1,23 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { Heading, HeadingProps } from "../../typography";
|
||||
|
||||
export interface FormSummaryHeadingProps
|
||||
extends React.HTMLAttributes<HTMLHeadingElement> {
|
||||
/**
|
||||
* Heading text.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* The heading level.
|
||||
*/
|
||||
level: Exclude<HeadingProps["level"], "1">;
|
||||
}
|
||||
|
||||
export const FormSummaryHeading = forwardRef<
|
||||
HTMLHeadingElement,
|
||||
FormSummaryHeadingProps
|
||||
>((props: FormSummaryHeadingProps, ref) => (
|
||||
<Heading ref={ref} {...props} size="medium" />
|
||||
));
|
||||
|
||||
export default FormSummaryHeading;
|
||||
@@ -0,0 +1,17 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { Label } from "../../typography";
|
||||
|
||||
export interface FormSummaryLabelProps
|
||||
extends React.HTMLAttributes<HTMLElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FormSummaryLabel = forwardRef<HTMLElement, FormSummaryLabelProps>(
|
||||
({ children, ...rest }, ref) => (
|
||||
<Label ref={ref} {...rest} as="dt">
|
||||
{children}
|
||||
</Label>
|
||||
),
|
||||
);
|
||||
|
||||
export default FormSummaryLabel;
|
||||
@@ -0,0 +1,24 @@
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef } from "react";
|
||||
import { BodyLong } from "../../typography";
|
||||
|
||||
export interface FormSummaryValueProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FormSummaryValue = forwardRef<
|
||||
HTMLDivElement,
|
||||
FormSummaryValueProps
|
||||
>(({ children, className, ...rest }, ref) => (
|
||||
<BodyLong
|
||||
ref={ref}
|
||||
{...rest}
|
||||
as="dd"
|
||||
className={cl("navds-form-summary__value", className)}
|
||||
>
|
||||
{children}
|
||||
</BodyLong>
|
||||
));
|
||||
|
||||
export default FormSummaryValue;
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
export { default as FormSummary, type FormSummaryProps } from "./FormSummary";
|
||||
export {
|
||||
default as FormSummaryAnswer,
|
||||
type FormSummaryAnswerProps,
|
||||
} from "./FormSummaryAnswer";
|
||||
export {
|
||||
default as FormSummaryAnswers,
|
||||
type FormSummaryAnswersProps,
|
||||
} from "./FormSummaryAnswers";
|
||||
export {
|
||||
default as FormSummaryEditLink,
|
||||
type FormSummaryEditProps,
|
||||
} from "./FormSummaryEditLink";
|
||||
export {
|
||||
default as FormSummaryHeader,
|
||||
type FormSummaryHeaderProps,
|
||||
} from "./FormSummaryHeader";
|
||||
export {
|
||||
default as FormSummaryHeading,
|
||||
type FormSummaryHeadingProps,
|
||||
} from "./FormSummaryHeading";
|
||||
export {
|
||||
default as FormSummaryLabel,
|
||||
type FormSummaryLabelProps,
|
||||
} from "./FormSummaryLabel";
|
||||
export {
|
||||
default as FormSummaryValue,
|
||||
type FormSummaryValueProps,
|
||||
} from "./FormSummaryValue";
|
||||
@@ -18,7 +18,7 @@ export const RadioGroupContext =
|
||||
export interface RadioGroupProps
|
||||
extends Omit<
|
||||
FieldsetProps,
|
||||
"onChange" | "errorPropagation" | "defaultValue"
|
||||
"onChange" | "errorPropagation" | "defaultValue" | "nativeReadOnly"
|
||||
> {
|
||||
/**
|
||||
* Collection of `<Radio />`-elements
|
||||
|
||||
@@ -9,11 +9,11 @@ export interface RadioProps
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* The value of the HTML element.
|
||||
* Radio value.
|
||||
*/
|
||||
value: any;
|
||||
/**
|
||||
* Adds a description to extend labeling of Radio.
|
||||
* Adds a description to extend the labeling.
|
||||
*/
|
||||
description?: string;
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const [checked, setChecked] = useState(
|
||||
const [_checked, setChecked] = useState(
|
||||
defaultChecked ?? checkedProp ?? false,
|
||||
);
|
||||
|
||||
@@ -93,6 +93,8 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
|
||||
checkedProp !== undefined && setChecked(checkedProp);
|
||||
}, [checkedProp]);
|
||||
|
||||
const checked = checkedProp ?? _checked;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cl(
|
||||
|
||||
@@ -5,8 +5,7 @@ import { FieldsetContext } from "./fieldset/context";
|
||||
|
||||
export interface FormFieldProps {
|
||||
/**
|
||||
* Adds a description to extend labeling of the field.
|
||||
* Error message for element.
|
||||
* Error message.
|
||||
*/
|
||||
error?: React.ReactNode;
|
||||
/**
|
||||
@@ -24,7 +23,7 @@ export interface FormFieldProps {
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Adds a description to extend labeling of a field.
|
||||
* Adds a description to extend the labeling.
|
||||
*/
|
||||
description?: React.ReactNode;
|
||||
/**
|
||||
|
||||
@@ -41,16 +41,14 @@ export const PosterVariants = {
|
||||
render: () => (
|
||||
<VStack gap="6" align="start">
|
||||
<GuidePanel>
|
||||
If you exclude the <code>poster</code> prop, you will get the poster
|
||||
variant on mobile (<480px) and the non-poster variant otherwise.
|
||||
If you exclude the `poster` prop, you will get the poster variant on
|
||||
mobile (<480px) and the non-poster variant otherwise.
|
||||
</GuidePanel>
|
||||
<GuidePanel poster>
|
||||
Use the <code>poster</code> prop to get the poster variant on all
|
||||
viewports.
|
||||
Use the `poster` prop to get the poster variant on all viewports.
|
||||
</GuidePanel>
|
||||
<GuidePanel poster={false}>
|
||||
Set <code>poster=false</code> to get the non-poster variant on all
|
||||
viewports.
|
||||
Set `poster=false` to get the non-poster variant on all viewports.
|
||||
</GuidePanel>
|
||||
</VStack>
|
||||
),
|
||||
|
||||
@@ -26,8 +26,8 @@ export { Dropdown, type DropdownProps } from "./dropdown";
|
||||
export { ExpansionCard, type ExpansionCardProps } from "./expansion-card";
|
||||
export {
|
||||
GuidePanel,
|
||||
type GuidePanelProps,
|
||||
GuidePanelDefaultIllustration,
|
||||
type GuidePanelProps,
|
||||
} from "./guide-panel";
|
||||
export { HelpText, type HelpTextProps } from "./help-text";
|
||||
export {
|
||||
@@ -125,6 +125,21 @@ export {
|
||||
} from "./form/confirmation-panel";
|
||||
export { ErrorSummary, type ErrorSummaryProps } from "./form/error-summary";
|
||||
export { Fieldset, type FieldsetProps } from "./form/fieldset";
|
||||
export {
|
||||
UNSAFE_FileUpload,
|
||||
type FileAccepted,
|
||||
type FileItem,
|
||||
type FileMetadata,
|
||||
type FileObject,
|
||||
type FileRejected,
|
||||
type FileRejectedPartitioned,
|
||||
type FileRejectionReason,
|
||||
type FileUploadDropzoneProps,
|
||||
type FileUploadItemProps,
|
||||
type FileUploadTriggerProps,
|
||||
type FilesPartitioned,
|
||||
} from "./form/file-upload";
|
||||
export { FormSummary, type FormSummaryProps } from "./form/form-summary";
|
||||
export {
|
||||
Radio,
|
||||
RadioGroup,
|
||||
@@ -136,20 +151,6 @@ export { Select, type SelectProps } from "./form/select";
|
||||
export { Switch, type SwitchProps } from "./form/switch";
|
||||
export { Textarea, type TextareaProps } from "./form/textarea";
|
||||
export { TextField, type TextFieldProps } from "./form/textfield";
|
||||
export {
|
||||
UNSAFE_FileUpload,
|
||||
type FileUploadDropzoneProps,
|
||||
type FileUploadTriggerProps,
|
||||
type FileObject,
|
||||
type FileRejected,
|
||||
type FileAccepted,
|
||||
type FileRejectedPartitioned,
|
||||
type FilesPartitioned,
|
||||
type FileRejectionReason,
|
||||
type FileUploadItemProps,
|
||||
type FileItem,
|
||||
type FileMetadata,
|
||||
} from "./form/file-upload";
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
|
||||
@@ -238,7 +238,11 @@ export const Modal = forwardRef<HTMLDialogElement, ModalProps>(
|
||||
level="1"
|
||||
id={ariaLabelId}
|
||||
>
|
||||
<span className="navds-modal__header-icon">{header.icon}</span>
|
||||
{header.icon && (
|
||||
<span className="navds-modal__header-icon">
|
||||
{header.icon}
|
||||
</span>
|
||||
)}
|
||||
{header.heading}
|
||||
</Heading>
|
||||
</ModalHeader>
|
||||
|
||||
@@ -146,6 +146,7 @@ export const WithUseState: StoryFn = () => {
|
||||
);
|
||||
};
|
||||
WithUseState.storyName = "With useState";
|
||||
WithUseState.parameters = { chromatic: { disable: true } };
|
||||
|
||||
export const EmptyHeader: StoryFn = () => (
|
||||
<Modal open onClose={() => null} aria-label="Modal with empty header">
|
||||
@@ -225,6 +226,7 @@ export const WithTooltip: StoryFn = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
WithTooltip.parameters = { chromatic: { disable: true } }; // The tooltip behaves unpredictably in Chromatic
|
||||
|
||||
export const WithSrOnlyElement: StoryFn = () => (
|
||||
<Modal
|
||||
@@ -330,12 +332,6 @@ ChromaticViewportTesting.parameters = {
|
||||
height: 400,
|
||||
},
|
||||
},
|
||||
desktop: {
|
||||
viewport: {
|
||||
width: 1280,
|
||||
height: 960,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,65 +1,91 @@
|
||||
import { Meta, StoryFn } from "@storybook/react";
|
||||
import React, { useState } from "react";
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import { fn } from "@storybook/test";
|
||||
import React from "react";
|
||||
import { ReadMore } from ".";
|
||||
import { VStack } from "../layout/stack";
|
||||
|
||||
const meta: Meta<typeof ReadMore> = {
|
||||
export default {
|
||||
title: "ds-react/ReadMore",
|
||||
component: ReadMore,
|
||||
};
|
||||
export default meta;
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof ReadMore>;
|
||||
|
||||
export const Default: StoryFn<{
|
||||
controlled: boolean;
|
||||
size: "medium" | "small";
|
||||
defaultOpen: boolean;
|
||||
}> = (props) => {
|
||||
const [state, setState] = useState(false);
|
||||
type Story = StoryObj<typeof ReadMore>;
|
||||
|
||||
return (
|
||||
<ReadMore
|
||||
open={props.controlled ? state : undefined}
|
||||
onClick={() => setState((x) => !x)}
|
||||
header="Grunnen til at vi spør om dette og i tillegg ber om vedlegg"
|
||||
size={props.size}
|
||||
onOpenChange={console.log}
|
||||
defaultOpen={props.defaultOpen}
|
||||
>
|
||||
<div style={{ maxWidth: 300 }}>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Mollitia,
|
||||
tempore corporis exercitationem minus dignissimos eius aspernatur fugiat
|
||||
iusto.
|
||||
</div>
|
||||
</ReadMore>
|
||||
);
|
||||
};
|
||||
Default.args = {
|
||||
controlled: false,
|
||||
size: "medium",
|
||||
defaultOpen: false,
|
||||
};
|
||||
Default.argTypes = {
|
||||
size: {
|
||||
options: ["medium", "small"],
|
||||
control: { type: "radio" },
|
||||
const Content = (
|
||||
<div style={{ maxWidth: 300 }}>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Mollitia, tempore
|
||||
corporis exercitationem minus dignissimos eius aspernatur fugiat iusto.
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
size: "medium",
|
||||
defaultOpen: false,
|
||||
onOpenChange: fn(),
|
||||
children: Content,
|
||||
header: "Grunnen til at vi spør om dette og i tillegg ber om vedlegg",
|
||||
},
|
||||
argTypes: {
|
||||
size: {
|
||||
options: ["medium", "small"],
|
||||
control: { type: "radio" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Small = () => (
|
||||
<ReadMore
|
||||
header="Grunnen til at vi spør om dette og i tillegg ber om vedlegg"
|
||||
size="small"
|
||||
>
|
||||
Command station, this is ST 321. Code Clearance Blue. We're starting
|
||||
our approach. Deactivate the security shield.
|
||||
</ReadMore>
|
||||
);
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
size: "small",
|
||||
},
|
||||
};
|
||||
|
||||
export const Open = () => (
|
||||
<ReadMore
|
||||
header="Grunnen til at vi spør om dette og i tillegg ber om vedlegg"
|
||||
defaultOpen
|
||||
>
|
||||
Command station, this is ST 321. Code Clearance Blue. We're starting
|
||||
our approach. Deactivate the security shield.
|
||||
</ReadMore>
|
||||
);
|
||||
export const DefaultOpen: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
defaultOpen: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Open: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
open: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Chromatic: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<VStack gap="4">
|
||||
<div>
|
||||
<h2>Default</h2>
|
||||
{/* @ts-expect-error Args are partial, leading to required prop mismatch */}
|
||||
<ReadMore {...Default.args} />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Small</h2>
|
||||
{/* @ts-expect-error Args are partial, leading to required prop mismatch */}
|
||||
<ReadMore {...Small.args} />
|
||||
</div>
|
||||
<div>
|
||||
<h2>DefaultOpen</h2>
|
||||
{/* @ts-expect-error Args are partial, leading to required prop mismatch */}
|
||||
<ReadMore {...DefaultOpen.args} />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Controlled open</h2>
|
||||
{/* @ts-expect-error Args are partial, leading to required prop mismatch */}
|
||||
<ReadMore {...Open.args} />
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
import * as RadixTabs from "@radix-ui/react-tabs";
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef, useContext } from "react";
|
||||
import { BodyShort } from "../typography";
|
||||
import { OverridableComponent } from "../util/types";
|
||||
import { TabsContext } from "./context";
|
||||
|
||||
export interface TabProps
|
||||
extends Omit<React.HTMLAttributes<HTMLButtonElement>, "children"> {
|
||||
/**
|
||||
* Tab label
|
||||
*/
|
||||
label?: React.ReactNode;
|
||||
/**
|
||||
* Tab Icon
|
||||
*/
|
||||
icon?: React.ReactNode;
|
||||
/**
|
||||
* Value for state-handling
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const Tab: OverridableComponent<TabProps, HTMLButtonElement> =
|
||||
forwardRef(
|
||||
(
|
||||
{ className, as: Component = "button", label, icon, value, ...rest },
|
||||
ref,
|
||||
) => {
|
||||
const context = useContext(TabsContext);
|
||||
|
||||
if (!label && !icon) {
|
||||
console.error("<Tabs.Tab/> needs label/icon");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<RadixTabs.Trigger value={value} asChild>
|
||||
<Component
|
||||
ref={ref}
|
||||
className={cl(
|
||||
"navds-tabs__tab",
|
||||
`navds-tabs__tab--${context?.size ?? "medium"}`,
|
||||
`navds-tabs__tab-icon--${context?.iconPosition}`,
|
||||
className,
|
||||
{
|
||||
"navds-tabs__tab--icon-only": icon && !label,
|
||||
},
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<BodyShort
|
||||
as="span"
|
||||
className="navds-tabs__tab-inner"
|
||||
size={context?.size}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</BodyShort>
|
||||
</Component>
|
||||
</RadixTabs.Trigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Tab;
|
||||
@@ -1,128 +0,0 @@
|
||||
import * as RadixTabs from "@radix-ui/react-tabs";
|
||||
import cl from "clsx";
|
||||
import React, {
|
||||
forwardRef,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@navikt/aksel-icons";
|
||||
import { debounce } from "../util";
|
||||
import { useMergeRefs } from "../util/hooks/useMergeRefs";
|
||||
import { TabsContext } from "./context";
|
||||
|
||||
export interface TabListProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* `<Tabs.Tab />` elements
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TabList = forwardRef<HTMLDivElement, TabListProps>(
|
||||
({ className, ...rest }, ref) => {
|
||||
const context = useContext(TabsContext);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const mergedRef = useMergeRefs(listRef, ref);
|
||||
|
||||
const [displayScroll, setDisplayScroll] = useState({
|
||||
start: false,
|
||||
end: false,
|
||||
});
|
||||
|
||||
const updateScrollButtonState = useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
if (!listRef?.current) return;
|
||||
const { scrollWidth, clientWidth } = listRef.current;
|
||||
const scrollLeft = listRef.current.scrollLeft;
|
||||
// use 1 for the potential rounding error with browser zooms.
|
||||
const showStartScroll = scrollLeft > 1;
|
||||
const showEndScroll = scrollLeft < scrollWidth - clientWidth - 1;
|
||||
|
||||
setDisplayScroll((oldDisplayScroll) =>
|
||||
showStartScroll === oldDisplayScroll.start &&
|
||||
showEndScroll === oldDisplayScroll.end
|
||||
? oldDisplayScroll
|
||||
: { start: showStartScroll, end: showEndScroll },
|
||||
);
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => updateScrollButtonState();
|
||||
const win = listRef.current?.ownerDocument ?? document ?? window;
|
||||
win.addEventListener("resize", handleResize);
|
||||
|
||||
let resizeObserver;
|
||||
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(listRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
win.removeEventListener("resize", handleResize);
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
};
|
||||
}, [updateScrollButtonState]);
|
||||
|
||||
useEffect(() => {
|
||||
updateScrollButtonState();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
updateScrollButtonState.clear();
|
||||
};
|
||||
}, [updateScrollButtonState]);
|
||||
|
||||
const ScrollButton = ({
|
||||
dir,
|
||||
hidden,
|
||||
}: {
|
||||
dir: 1 | -1;
|
||||
hidden: boolean;
|
||||
}) => (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className={cl("navds-tabs__scroll-button", {
|
||||
"navds-tabs__scroll-button--hidden": hidden,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (!listRef.current) return;
|
||||
listRef.current.scrollLeft += dir * 100;
|
||||
}}
|
||||
>
|
||||
{dir === -1 ? (
|
||||
<ChevronLeftIcon title="scroll tilbake" />
|
||||
) : (
|
||||
<ChevronRightIcon title="scroll neste" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const showSteppers = displayScroll.end || displayScroll.start;
|
||||
return (
|
||||
<div className="navds-tabs__tablist-wrapper">
|
||||
{showSteppers && (
|
||||
<ScrollButton dir={-1} hidden={!displayScroll.start} />
|
||||
)}
|
||||
<RadixTabs.TabsList
|
||||
{...rest}
|
||||
ref={mergedRef}
|
||||
onScroll={updateScrollButtonState}
|
||||
loop={context?.loop}
|
||||
className={cl("navds-tabs__tablist", className)}
|
||||
/>
|
||||
{showSteppers && <ScrollButton dir={1} hidden={!displayScroll.end} />}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default TabList;
|
||||
@@ -1,26 +0,0 @@
|
||||
import * as RadixTabs from "@radix-ui/react-tabs";
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
export interface TabPanelProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Tab panel content
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Value for state-handling
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
const TabPanel = forwardRef<HTMLDivElement, TabPanelProps>(
|
||||
({ className, ...rest }, ref) => (
|
||||
<RadixTabs.TabsContent
|
||||
{...rest}
|
||||
ref={ref}
|
||||
className={cl("navds-tabs__tabpanel", className)}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
export default TabPanel;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createContext } from "../util/create-context";
|
||||
import { createDescendantContext } from "../util/hooks/descendants/useDescendant";
|
||||
import { TabsProps } from "./Tabs.types";
|
||||
import { useTabs } from "./useTabs";
|
||||
|
||||
export const [
|
||||
TabsDescendantsProvider,
|
||||
useTabsDescendantsContext,
|
||||
useTabsDescendants,
|
||||
useTabsDescendant,
|
||||
] = createDescendantContext<HTMLButtonElement, { value: string }>();
|
||||
|
||||
type TabsProviderProps = ReturnType<typeof useTabs> &
|
||||
Pick<
|
||||
TabsProps,
|
||||
"selectionFollowsFocus" | "loop" | "size" | "iconPosition" | "fill"
|
||||
>;
|
||||
|
||||
/* State context */
|
||||
export const [TabsProvider, useTabsContext] = createContext<TabsProviderProps>({
|
||||
name: "TabsContext",
|
||||
hookName: "useTabsContext",
|
||||
providerName: "TabsProvider",
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Meta } from "@storybook/react";
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import React, { useState } from "react";
|
||||
import { DishwasherIcon, FreezerIcon, MugIcon } from "@navikt/aksel-icons";
|
||||
import { Tabs } from ".";
|
||||
import { VStack } from "../layout/stack";
|
||||
|
||||
export default {
|
||||
title: "ds-react/Tabs",
|
||||
@@ -20,41 +21,44 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof Tabs>;
|
||||
|
||||
const Panel = () => {
|
||||
return (
|
||||
<>
|
||||
<Tabs.Panel
|
||||
value="test1"
|
||||
className="panel"
|
||||
style={{ background: "var(--a-gray-50)", height: 100 }}
|
||||
>
|
||||
Innholdspanel for Skap-tab
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel
|
||||
value="test2"
|
||||
className="panel"
|
||||
style={{
|
||||
background: "var(--a-green-50)",
|
||||
height: 100,
|
||||
}}
|
||||
>
|
||||
Innholdspanel for Oppvaskmaskin-tab
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel
|
||||
value="test3"
|
||||
className="panel"
|
||||
style={{ background: "var(--a-red-50)", height: 100 }}
|
||||
>
|
||||
Innholdspanel for Fryser-tab
|
||||
</Tabs.Panel>
|
||||
<style>{`.panel[data-state="active"] { display:grid; place-content: center;}`}</style>
|
||||
</>
|
||||
);
|
||||
};
|
||||
type Story = StoryObj<typeof Tabs>;
|
||||
|
||||
export const Default = {
|
||||
const Panel = () => (
|
||||
<>
|
||||
<Tabs.Panel
|
||||
value="test1"
|
||||
className="panel"
|
||||
style={{ background: "var(--a-gray-50)", height: 100 }}
|
||||
>
|
||||
Innholdspanel for Skap-tab
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel
|
||||
value="test2"
|
||||
className="panel"
|
||||
style={{
|
||||
background: "var(--a-green-50)",
|
||||
height: 100,
|
||||
}}
|
||||
>
|
||||
Innholdspanel for Oppvaskmaskin-tab
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel
|
||||
value="test3"
|
||||
className="panel"
|
||||
style={{ background: "var(--a-red-50)", height: 100 }}
|
||||
>
|
||||
Innholdspanel for Fryser-tab
|
||||
</Tabs.Panel>
|
||||
<style>{`.panel[data-state="active"] { display:grid; place-content: center;}`}</style>
|
||||
</>
|
||||
);
|
||||
|
||||
export const Default: Story = {
|
||||
render: (props) => {
|
||||
return (
|
||||
<Tabs
|
||||
@@ -65,13 +69,17 @@ export const Default = {
|
||||
iconPosition={props?.iconPosition}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon />} label="Skap" />
|
||||
<Tabs.Tab value="test1" icon={<MugIcon aria-hidden />} label="Skap" />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
label="Oppvaskmaskin"
|
||||
icon={<DishwasherIcon />}
|
||||
icon={<DishwasherIcon aria-hidden />}
|
||||
/>
|
||||
<Tabs.Tab
|
||||
value="test3"
|
||||
icon={<FreezerIcon aria-hidden />}
|
||||
label="Fryser"
|
||||
/>
|
||||
<Tabs.Tab value="test3" icon={<FreezerIcon />} label="Fryser" />
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
@@ -84,108 +92,220 @@ export const Default = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Small = () => {
|
||||
return (
|
||||
<Tabs defaultValue="test2" size="small">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon />} label="Skap" />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
label="Oppvaskmaskin"
|
||||
icon={<DishwasherIcon />}
|
||||
/>
|
||||
<Tabs.Tab value="test3" icon={<FreezerIcon />} label="Fryser" />
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
export const Small = () => (
|
||||
<Tabs defaultValue="test2" size="small">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon aria-hidden />} label="Skap" />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
label="Oppvaskmaskin"
|
||||
icon={<DishwasherIcon aria-hidden />}
|
||||
/>
|
||||
<Tabs.Tab
|
||||
value="test3"
|
||||
icon={<FreezerIcon aria-hidden />}
|
||||
label="Fryser"
|
||||
/>
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
export const Controlled = () => {
|
||||
const [activeValue, setActiveValue] = useState("test1");
|
||||
return (
|
||||
<Tabs value={activeValue} onChange={setActiveValue}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon />} label="Skap" />
|
||||
<Tabs.Tab value="test1" icon={<MugIcon aria-hidden />} label="Skap" />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
label="Oppvaskmaskin"
|
||||
icon={<DishwasherIcon />}
|
||||
icon={<DishwasherIcon aria-hidden />}
|
||||
/>
|
||||
<Tabs.Tab
|
||||
value="test3"
|
||||
icon={<FreezerIcon aria-hidden />}
|
||||
label="Fryser"
|
||||
/>
|
||||
<Tabs.Tab value="test3" icon={<FreezerIcon />} label="Fryser" />
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export const IconPosition = () => {
|
||||
return (
|
||||
<div className="colgap">
|
||||
<Tabs defaultValue="test2" size="small">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon />} label="Skap" />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
label="Oppvaskmaskin"
|
||||
icon={<DishwasherIcon />}
|
||||
/>
|
||||
<Tabs.Tab value="test3" icon={<FreezerIcon />} label="Fryser" />
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
<Tabs defaultValue="test2" size="small" iconPosition="top">
|
||||
<Tabs.List style={{ margin: "0 auto" }}>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon />} label="Skap" />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
label="Oppvaskmaskin"
|
||||
icon={<DishwasherIcon />}
|
||||
/>
|
||||
<Tabs.Tab value="test3" icon={<FreezerIcon />} label="Fryser" />
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Icon = () => {
|
||||
return (
|
||||
<div className="colgap">
|
||||
<Tabs defaultValue="test2">
|
||||
<Tabs.List style={{ margin: "0 auto" }}>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon />} />
|
||||
<Tabs.Tab value="test2" icon={<DishwasherIcon />} />
|
||||
<Tabs.Tab value="test3" icon={<FreezerIcon />} />
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
<Tabs defaultValue="test2" size="small" iconPosition="top">
|
||||
<Tabs.List style={{ margin: "0 auto" }}>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon />} />
|
||||
<Tabs.Tab value="test2" icon={<DishwasherIcon />} />
|
||||
<Tabs.Tab value="test3" icon={<FreezerIcon />} />
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Overflow = () => {
|
||||
return (
|
||||
<Tabs defaultValue="test2" style={{ maxWidth: 300 }}>
|
||||
export const IconPosition = () => (
|
||||
<VStack gap="4">
|
||||
<Tabs defaultValue="test2" size="small">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon />} label="Skap" />
|
||||
<Tabs.Tab value="test1" icon={<MugIcon aria-hidden />} label="Skap" />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
label="Oppvaskmaskin"
|
||||
icon={<DishwasherIcon />}
|
||||
icon={<DishwasherIcon aria-hidden />}
|
||||
/>
|
||||
<Tabs.Tab
|
||||
value="test3"
|
||||
icon={<FreezerIcon aria-hidden />}
|
||||
label="Fryser"
|
||||
/>
|
||||
<Tabs.Tab value="test3" icon={<FreezerIcon />} label="Fryser" />
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
);
|
||||
<Tabs defaultValue="test2" size="small" iconPosition="top">
|
||||
<Tabs.List style={{ margin: "0 auto" }}>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon aria-hidden />} label="Skap" />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
label="Oppvaskmaskin"
|
||||
icon={<DishwasherIcon aria-hidden />}
|
||||
/>
|
||||
<Tabs.Tab
|
||||
value="test3"
|
||||
icon={<FreezerIcon aria-hidden />}
|
||||
label="Fryser"
|
||||
/>
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
export const Icon = () => (
|
||||
<VStack gap="4">
|
||||
<Tabs defaultValue="test2">
|
||||
<Tabs.List style={{ margin: "0 auto" }}>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon title="Skap" />} />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
icon={<DishwasherIcon title="Oppvaskmaskin" />}
|
||||
/>
|
||||
<Tabs.Tab value="test3" icon={<FreezerIcon title="Fryser" />} />
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
<Tabs defaultValue="test2" size="small" iconPosition="top">
|
||||
<Tabs.List style={{ margin: "0 auto" }}>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon title="Skap" />} />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
icon={<DishwasherIcon title="Oppvaskmaskin" />}
|
||||
/>
|
||||
<Tabs.Tab value="test3" icon={<FreezerIcon title="Fryser" />} />
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
export const Overflow = () => (
|
||||
<Tabs defaultValue="test2" style={{ maxWidth: 300 }}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon aria-hidden />} label="Skap" />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
label="Oppvaskmaskin"
|
||||
icon={<DishwasherIcon aria-hidden />}
|
||||
/>
|
||||
<Tabs.Tab
|
||||
value="test3"
|
||||
icon={<FreezerIcon aria-hidden />}
|
||||
label="Fryser"
|
||||
/>
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
export const Fill = () => (
|
||||
<Tabs defaultValue="test2" fill>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon aria-hidden />} label="Skap" />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
icon={<DishwasherIcon aria-hidden />}
|
||||
label="Oppvaskmaskin"
|
||||
/>
|
||||
<Tabs.Tab
|
||||
value="test3"
|
||||
icon={<FreezerIcon aria-hidden />}
|
||||
label="Fryser"
|
||||
/>
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
Fill.parameters = {
|
||||
layout: "fullscreen",
|
||||
};
|
||||
|
||||
export const CustomIds = () => (
|
||||
<Tabs defaultValue="test2">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab
|
||||
value="test1"
|
||||
label="Skap"
|
||||
id="custom-tabid-1"
|
||||
aria-controls="custom-tabpanelid-1"
|
||||
/>
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
label="Oppvaskmaskin"
|
||||
id="custom-tabid-2"
|
||||
aria-controls="custom-tabpanelid-2"
|
||||
/>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel
|
||||
value="test1"
|
||||
className="panel"
|
||||
id="custom-tabpanelid-1"
|
||||
aria-labelledby="custom-tabid-1"
|
||||
>
|
||||
Innholdspanel for Skap-tab
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel
|
||||
value="test2"
|
||||
className="panel"
|
||||
id="custom-tabpanelid-2"
|
||||
aria-labelledby="custom-tabid-2"
|
||||
>
|
||||
Innholdspanel for Oppvaskmaskin-tab
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
export const Chromatic = {
|
||||
render: () => (
|
||||
<VStack gap="6" align="center">
|
||||
<div>
|
||||
<h2>Small</h2>
|
||||
<Small />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Controlled</h2>
|
||||
<Controlled />
|
||||
</div>
|
||||
<div>
|
||||
<h2>IconPosition</h2>
|
||||
<IconPosition />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Icon</h2>
|
||||
<Icon />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Overflow</h2>
|
||||
<Overflow />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Fill</h2>
|
||||
<div style={{ minWidth: 600 }}>
|
||||
<Fill />
|
||||
</div>
|
||||
</div>
|
||||
</VStack>
|
||||
),
|
||||
parameters: {
|
||||
chromatic: { disable: false, delay: 300 },
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { Tabs } from "./Tabs";
|
||||
@@ -39,72 +40,133 @@ const TestTabs = ({
|
||||
describe("Tabs", () => {
|
||||
test("sets default value correctly", () => {
|
||||
render(<TestTabs defaultValue="tab2" />);
|
||||
const tab = screen.getByTestId("tab2");
|
||||
const panel = screen.getByTestId("tabpanel2");
|
||||
const tab2 = screen.getByTestId("tab2");
|
||||
const panel2 = screen.getByTestId("tabpanel2");
|
||||
|
||||
expect(tab).toHaveAttribute("aria-selected", "true");
|
||||
expect(panel).toHaveTextContent("Tabpanel 2");
|
||||
expect(tab2).toHaveAttribute("aria-selected", "true");
|
||||
expect(panel2).toHaveTextContent("Tabpanel 2");
|
||||
});
|
||||
|
||||
test("label-connection between tab and tabpanel is correct", async () => {
|
||||
render(<TestTabs defaultValue="tab2" />);
|
||||
const tab = screen.getByTestId("tab2");
|
||||
const panel = screen.getByTestId("tabpanel2");
|
||||
const tab2 = screen.getByTestId("tab2");
|
||||
const panel2 = screen.getByTestId("tabpanel2");
|
||||
|
||||
const controlsId = tab.getAttribute("aria-controls");
|
||||
const panelLabelledBy = panel.getAttribute("aria-labelledby");
|
||||
const controlsId = tab2.getAttribute("aria-controls");
|
||||
const panelLabelledBy = panel2.getAttribute("aria-labelledby");
|
||||
|
||||
expect(controlsId).toEqual(panel.id);
|
||||
expect(tab.id).toEqual(panelLabelledBy);
|
||||
expect(controlsId).toEqual(panel2.id);
|
||||
expect(tab2.id).toEqual(panelLabelledBy);
|
||||
});
|
||||
|
||||
test("sets correct attributes on active tab", () => {
|
||||
render(<TestTabs defaultValue="tab2" />);
|
||||
const tab = screen.getByTestId("tab2");
|
||||
const tab2 = screen.getByTestId("tab2");
|
||||
|
||||
expect(tab).toHaveAttribute("aria-selected", "true");
|
||||
expect(tab).toHaveAttribute("role", "tab");
|
||||
expect(tab).toHaveAttribute("aria-controls");
|
||||
expect(tab).toHaveAttribute("tabindex", "-1");
|
||||
expect(tab2).toHaveAttribute("aria-selected", "true");
|
||||
expect(tab2).toHaveAttribute("role", "tab");
|
||||
expect(tab2).toHaveAttribute("aria-controls");
|
||||
expect(tab2).toHaveAttribute("tabindex", "0");
|
||||
});
|
||||
|
||||
test("sets correct attributes on idle tab", () => {
|
||||
render(<TestTabs defaultValue="tab2" />);
|
||||
const tab = screen.getByTestId("tab1");
|
||||
const tab1 = screen.getByTestId("tab1");
|
||||
|
||||
expect(tab).toHaveAttribute("aria-selected", "false");
|
||||
expect(tab).toHaveAttribute("role", "tab");
|
||||
expect(tab).toHaveAttribute("aria-controls");
|
||||
expect(tab).toHaveAttribute("tabindex", "-1");
|
||||
expect(tab1).toHaveAttribute("aria-selected", "false");
|
||||
expect(tab1).toHaveAttribute("role", "tab");
|
||||
expect(tab1).toHaveAttribute("aria-controls");
|
||||
expect(tab1).toHaveAttribute("tabindex", "-1");
|
||||
});
|
||||
|
||||
test("sets correct attributes on active tabpanel", () => {
|
||||
render(<TestTabs defaultValue="tab2" />);
|
||||
const panel = screen.getByTestId("tabpanel2");
|
||||
const panel2 = screen.getByTestId("tabpanel2");
|
||||
|
||||
expect(panel).toHaveAttribute("aria-labelledby");
|
||||
expect(panel).toHaveAttribute("role", "tabpanel");
|
||||
expect(panel).toHaveAttribute("tabindex", "0");
|
||||
expect(panel).toHaveTextContent("Tabpanel 2");
|
||||
expect(panel).toHaveStyle({ display: "block" });
|
||||
expect(panel2).toHaveAttribute("aria-labelledby");
|
||||
expect(panel2).toHaveAttribute("role", "tabpanel");
|
||||
expect(panel2).toHaveAttribute("tabindex", "0");
|
||||
expect(panel2).toHaveTextContent("Tabpanel 2");
|
||||
expect(panel2).toHaveStyle({ display: "block" });
|
||||
});
|
||||
|
||||
test("sets correct attributes on idle tabpanel", () => {
|
||||
render(<TestTabs defaultValue="tab1" />);
|
||||
const panel = screen.getByTestId("tabpanel2");
|
||||
const panel2 = screen.getByTestId("tabpanel2");
|
||||
|
||||
expect(panel).toHaveAttribute("aria-labelledby");
|
||||
expect(panel).toHaveAttribute("role", "tabpanel");
|
||||
expect(panel).toHaveAttribute("tabindex", "0");
|
||||
expect(panel).toBeEmptyDOMElement();
|
||||
expect(panel).toHaveStyle({ display: "none" });
|
||||
expect(panel2).toHaveAttribute("aria-labelledby");
|
||||
expect(panel2).toHaveAttribute("role", "tabpanel");
|
||||
expect(panel2).toHaveAttribute("tabindex", "0");
|
||||
expect(panel2).toBeEmptyDOMElement();
|
||||
expect(panel2).toHaveStyle({ display: "none" });
|
||||
});
|
||||
|
||||
test("sets tabindex to 0 when focused", () => {
|
||||
render(<TestTabs defaultValue="tab2" />);
|
||||
const tab = screen.getByTestId("tab2");
|
||||
render(<TestTabs defaultValue="tab1" />);
|
||||
const tab2 = screen.getByTestId("tab2");
|
||||
|
||||
fireEvent.focus(tab);
|
||||
expect(tab).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.focus(tab2);
|
||||
expect(tab2).toHaveAttribute("tabindex", "0");
|
||||
});
|
||||
|
||||
test("rowing tabindex keydown moves focus", () => {
|
||||
render(<TestTabs defaultValue="tab1" />);
|
||||
const tab1 = screen.getByTestId("tab1");
|
||||
|
||||
expect(tab1).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.keyDown(tab1, { key: "ArrowRight" });
|
||||
|
||||
expect(tab1).toHaveAttribute("tabindex", "-1");
|
||||
expect(screen.getByTestId("tab2")).toHaveAttribute("tabindex", "0");
|
||||
expect(screen.getByTestId("tab2")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
test("selectionFollowsFocus moves active tabs", () => {
|
||||
render(<TestTabs defaultValue="tab1" selectionFollowsFocus />);
|
||||
const tab1 = screen.getByTestId("tab1");
|
||||
|
||||
expect(tab1).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.keyDown(tab1, { key: "ArrowRight" });
|
||||
|
||||
expect(screen.getByTestId("tab2")).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByTestId("tab2")).toHaveAttribute("tabindex", "0");
|
||||
});
|
||||
|
||||
test("tabbing from tabs moves focus to tabPanel", async () => {
|
||||
render(<TestTabs defaultValue="tab1" />);
|
||||
const tab1 = screen.getByTestId("tab1");
|
||||
|
||||
tab1.focus();
|
||||
await userEvent.tab();
|
||||
|
||||
expect(screen.getByTestId("tabpanel1")).toHaveFocus();
|
||||
});
|
||||
|
||||
test("shift+tab back to tablist should focus selected tab", async () => {
|
||||
render(<TestTabs defaultValue="tab1" />);
|
||||
const tab1 = screen.getByTestId("tab1");
|
||||
|
||||
/* Move focus to tab2 */
|
||||
fireEvent.keyDown(tab1, { key: "ArrowRight" });
|
||||
expect(screen.getByTestId("tab2")).toHaveFocus();
|
||||
|
||||
/* Move focus to tabPanel */
|
||||
// eslint-disable-next-line testing-library/no-unnecessary-act
|
||||
await act(async () => {
|
||||
/* Tablist handles tabbing with setTimeout, so we need to use act */
|
||||
await userEvent.tab();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("tabpanel1")).toHaveFocus();
|
||||
/* Move focus back to tablist, now tab1 should have focus */
|
||||
|
||||
// eslint-disable-next-line testing-library/no-unnecessary-act
|
||||
await act(async () => {
|
||||
await userEvent.tab({ shift: true });
|
||||
});
|
||||
expect(screen.getByTestId("tab1")).toHaveFocus();
|
||||
});
|
||||
});
|
||||
@@ -1,48 +1,15 @@
|
||||
import * as RadixTabs from "@radix-ui/react-tabs";
|
||||
import cl from "clsx";
|
||||
import React, { HTMLAttributes, forwardRef } from "react";
|
||||
import { OverridableComponent } from "../util/types";
|
||||
import Tab, { TabProps } from "./Tab";
|
||||
import TabList, { TabListProps } from "./TabList";
|
||||
import TabPanel, { TabPanelProps } from "./TabPanel";
|
||||
import { TabsContext } from "./context";
|
||||
|
||||
export interface TabsProps
|
||||
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "dir"> {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Changes padding and font-size.
|
||||
* @default "medium"
|
||||
*/
|
||||
size?: "medium" | "small";
|
||||
/**
|
||||
* onChange callback for selected Tab.
|
||||
*/
|
||||
onChange?: (value: string) => void;
|
||||
/**
|
||||
* Controlled selected value.
|
||||
*/
|
||||
value?: string;
|
||||
/**
|
||||
* If not controlled, a default-value needs to be set.
|
||||
*/
|
||||
defaultValue?: string;
|
||||
/**
|
||||
* Automatically activates tab on focus/navigation.
|
||||
* @default false
|
||||
*/
|
||||
selectionFollowsFocus?: boolean;
|
||||
/**
|
||||
* Loops back to start when navigating past last item.
|
||||
* @default false
|
||||
*/
|
||||
loop?: boolean;
|
||||
/**
|
||||
* Icon position in Tab.
|
||||
* @default "left"
|
||||
*/
|
||||
iconPosition?: "left" | "top";
|
||||
}
|
||||
import React, { forwardRef } from "react";
|
||||
import {
|
||||
TabsDescendantsProvider,
|
||||
TabsProvider,
|
||||
useTabsDescendants,
|
||||
} from "./Tabs.context";
|
||||
import { TabsProps } from "./Tabs.types";
|
||||
import Tab from "./parts/tab/Tab";
|
||||
import TabList from "./parts/tablist/TabList";
|
||||
import TabPanel from "./parts/tabpanel/TabPanel";
|
||||
import { useTabs } from "./useTabs";
|
||||
|
||||
interface TabsComponent
|
||||
extends React.ForwardRefExoticComponent<
|
||||
@@ -52,19 +19,15 @@ interface TabsComponent
|
||||
* @see 🏷️ {@link TabProps}
|
||||
* @see [🤖 OverridableComponent](https://aksel.nav.no/grunnleggende/kode/overridablecomponent) support
|
||||
*/
|
||||
Tab: OverridableComponent<TabProps, HTMLButtonElement>;
|
||||
Tab: typeof Tab;
|
||||
/**
|
||||
* @see 🏷️ {@link TabListProps}
|
||||
*/
|
||||
List: React.ForwardRefExoticComponent<
|
||||
TabListProps & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
List: typeof TabList;
|
||||
/**
|
||||
* @see 🏷️ {@link TabPanelProps}
|
||||
*/
|
||||
Panel: React.ForwardRefExoticComponent<
|
||||
TabPanelProps & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
Panel: typeof TabPanel;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,33 +61,48 @@ export const Tabs = forwardRef<HTMLDivElement, TabsProps>(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
onChange,
|
||||
size = "medium",
|
||||
defaultValue = "",
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
selectionFollowsFocus = false,
|
||||
loop = false,
|
||||
loop = true,
|
||||
iconPosition = "left",
|
||||
fill = false,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const descendants = useTabsDescendants();
|
||||
|
||||
const tabsContext = useTabs({ defaultValue, value, onChange, id });
|
||||
|
||||
/**
|
||||
* TabsProvider handles memoization of context values, so we can safely skip it here.
|
||||
*/
|
||||
const context = {
|
||||
...tabsContext,
|
||||
selectionFollowsFocus,
|
||||
loop,
|
||||
size,
|
||||
iconPosition,
|
||||
fill,
|
||||
};
|
||||
|
||||
return (
|
||||
<RadixTabs.Root
|
||||
{...rest}
|
||||
ref={ref}
|
||||
className={cl("navds-tabs", className, `navds-tabs--${size}`)}
|
||||
activationMode={selectionFollowsFocus ? "automatic" : "manual"}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
<TabsContext.Provider
|
||||
value={{
|
||||
size,
|
||||
loop,
|
||||
iconPosition,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TabsContext.Provider>
|
||||
</RadixTabs.Root>
|
||||
<TabsDescendantsProvider value={descendants}>
|
||||
<TabsProvider {...context}>
|
||||
<div
|
||||
ref={ref}
|
||||
{...rest}
|
||||
id={id}
|
||||
className={cl("navds-tabs", className, `navds-tabs--${size}`)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TabsProvider>
|
||||
</TabsDescendantsProvider>
|
||||
);
|
||||
},
|
||||
) as TabsComponent;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { HTMLAttributes } from "react";
|
||||
|
||||
export interface TabsProps
|
||||
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "dir"> {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Changes padding and font-size.
|
||||
* @default "medium"
|
||||
*/
|
||||
size?: "medium" | "small";
|
||||
/**
|
||||
* onChange callback for selected Tab.
|
||||
*/
|
||||
onChange?: (value: string) => void;
|
||||
/**
|
||||
* Controlled selected value.
|
||||
*/
|
||||
value?: string;
|
||||
/**
|
||||
* If not controlled, a default-value needs to be set.
|
||||
*/
|
||||
defaultValue?: string;
|
||||
/**
|
||||
* Automatically activates tab on focus/navigation.
|
||||
* @default false
|
||||
*/
|
||||
selectionFollowsFocus?: boolean;
|
||||
/**
|
||||
* Loops back to start when navigating past last item.
|
||||
* @default true
|
||||
*/
|
||||
loop?: boolean;
|
||||
/**
|
||||
* Icon position in Tab.
|
||||
* @default "left"
|
||||
*/
|
||||
iconPosition?: "left" | "top";
|
||||
/**
|
||||
* Stretches each tab to fill avaliable space in container.
|
||||
* @default false
|
||||
*/
|
||||
fill?: boolean;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
interface TabsContextProps {
|
||||
size: "medium" | "small";
|
||||
loop: boolean;
|
||||
iconPosition: "left" | "top";
|
||||
}
|
||||
|
||||
export const TabsContext = createContext<TabsContextProps | null>(null);
|
||||
@@ -1,5 +1,12 @@
|
||||
"use client";
|
||||
export { default as TabsTab, type TabProps } from "./Tab";
|
||||
export { default as TabsList, type TabListProps } from "./TabList";
|
||||
export { default as TabsPanel, type TabPanelProps } from "./TabPanel";
|
||||
export { default as Tabs, type TabsProps } from "./Tabs";
|
||||
export { default as Tabs } from "./Tabs";
|
||||
export { type TabsProps } from "./Tabs.types";
|
||||
export { default as TabsTab, type TabProps } from "./parts/tab/Tab";
|
||||
export {
|
||||
default as TabsList,
|
||||
type TabListProps,
|
||||
} from "./parts/tablist/TabList";
|
||||
export {
|
||||
default as TabsPanel,
|
||||
type TabPanelProps,
|
||||
} from "./parts/tabpanel/TabPanel";
|
||||
@@ -0,0 +1,93 @@
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef } from "react";
|
||||
import { BodyShort } from "../../../typography";
|
||||
import { OverridableComponent } from "../../../util";
|
||||
import { useTabsContext } from "../../Tabs.context";
|
||||
import { useTab } from "./useTab";
|
||||
|
||||
export interface TabProps
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
||||
/**
|
||||
* Tab label.
|
||||
*/
|
||||
label?: React.ReactNode;
|
||||
/**
|
||||
* Tab Icon.
|
||||
*/
|
||||
icon?: React.ReactNode;
|
||||
/**
|
||||
* Value for state-handling.
|
||||
*/
|
||||
value: string;
|
||||
/**
|
||||
* Overrides auto-generated id.
|
||||
*
|
||||
* **Warning**: Tab generates an id if not provided. If you need to override it,
|
||||
* make sure to also include the correct `aria-controls` id for the TabPanel it controls.
|
||||
*/
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const Tab: OverridableComponent<TabProps, HTMLButtonElement> =
|
||||
forwardRef(
|
||||
(
|
||||
{
|
||||
className,
|
||||
as: Component = "button",
|
||||
label,
|
||||
icon,
|
||||
value,
|
||||
onClick,
|
||||
onFocus,
|
||||
disabled,
|
||||
id,
|
||||
...rest
|
||||
},
|
||||
ref: React.ForwardedRef<HTMLButtonElement>,
|
||||
) => {
|
||||
const tabCtx = useTab({ value, onClick, onFocus, disabled }, ref);
|
||||
const ctx = useTabsContext();
|
||||
|
||||
if (!label && !icon) {
|
||||
console.error("<Tabs.Tab/> needs label and/or icon");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Component
|
||||
ref={tabCtx.ref}
|
||||
{...rest}
|
||||
className={cl(
|
||||
"navds-tabs__tab",
|
||||
`navds-tabs__tab--${ctx?.size ?? "medium"}`,
|
||||
`navds-tabs__tab-icon--${ctx?.iconPosition}`,
|
||||
className,
|
||||
{
|
||||
"navds-tabs__tab--icon-only": icon && !label,
|
||||
"navds-tabs__tab--fill": ctx.fill,
|
||||
},
|
||||
)}
|
||||
role="tab"
|
||||
type="button"
|
||||
aria-selected={tabCtx.isSelected}
|
||||
data-state={tabCtx.isSelected ? "active" : "inactive"}
|
||||
tabIndex={tabCtx.isFocused ? 0 : -1}
|
||||
aria-controls={rest["aria-controls"] ?? tabCtx.controlsId}
|
||||
id={id ?? tabCtx.id}
|
||||
onFocus={tabCtx.onFocus}
|
||||
onClick={tabCtx.onClick}
|
||||
>
|
||||
<BodyShort
|
||||
as="span"
|
||||
className="navds-tabs__tab-inner"
|
||||
size={ctx?.size}
|
||||
>
|
||||
<span aria-hidden={!!label}>{icon}</span>
|
||||
<span>{label}</span>
|
||||
</BodyShort>
|
||||
</Component>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Tab;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { composeEventHandlers } from "../../../util/composeEventHandlers";
|
||||
import { mergeRefs } from "../../../util/hooks/useMergeRefs";
|
||||
import { useTabsContext, useTabsDescendant } from "../../Tabs.context";
|
||||
|
||||
export interface UseTabProps {
|
||||
/**
|
||||
* If `true`, the `Tab` won't be toggleable
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
onClick?: React.MouseEventHandler;
|
||||
onFocus?: React.FocusEventHandler;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function useTab<P extends UseTabProps>(
|
||||
{ value, disabled = false, onFocus: _onFocus, onClick }: P,
|
||||
ref: React.ForwardedRef<HTMLButtonElement>,
|
||||
) {
|
||||
const {
|
||||
id,
|
||||
setSelectedValue,
|
||||
selectionFollowsFocus,
|
||||
focusedValue,
|
||||
setFocusedValue,
|
||||
selectedValue,
|
||||
makeTabId,
|
||||
makeTabPanelId,
|
||||
} = useTabsContext();
|
||||
|
||||
const { register } = useTabsDescendant({
|
||||
disabled,
|
||||
value,
|
||||
});
|
||||
|
||||
const isSelected = value === selectedValue;
|
||||
|
||||
const onFocus = () => {
|
||||
setFocusedValue(value);
|
||||
selectionFollowsFocus && setSelectedValue(value);
|
||||
};
|
||||
|
||||
return {
|
||||
ref: mergeRefs([register, ref]),
|
||||
isSelected,
|
||||
isFocused: focusedValue === value,
|
||||
id: makeTabId(id, value),
|
||||
controlsId: makeTabPanelId(id, value),
|
||||
onClick: composeEventHandlers(onClick, () => setSelectedValue(value)),
|
||||
onFocus: disabled ? undefined : composeEventHandlers(_onFocus, onFocus),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import cl from "clsx";
|
||||
import React from "react";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@navikt/aksel-icons";
|
||||
|
||||
interface ScrollButtonProps {
|
||||
hidden: boolean;
|
||||
onClick: () => void;
|
||||
dir: "left" | "right";
|
||||
}
|
||||
|
||||
function ScrollButton({ hidden, onClick, dir }: ScrollButtonProps) {
|
||||
return (
|
||||
<div
|
||||
className={cl("navds-tabs__scroll-button", {
|
||||
"navds-tabs__scroll-button--hidden": hidden,
|
||||
})}
|
||||
onClick={onClick}
|
||||
aria-hidden
|
||||
>
|
||||
{dir === "left" ? (
|
||||
<ChevronLeftIcon title="scroll tilbake" />
|
||||
) : (
|
||||
<ChevronRightIcon title="scroll neste" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScrollButton;
|
||||
@@ -0,0 +1,56 @@
|
||||
/* eslint-disable jsx-a11y/interactive-supports-focus */
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef, useRef } from "react";
|
||||
import { composeEventHandlers } from "../../../util/composeEventHandlers";
|
||||
import { useMergeRefs } from "../../../util/hooks/useMergeRefs";
|
||||
import ScrollButton from "./ScrollButtons";
|
||||
import { useScrollButtons } from "./useScrollButtons";
|
||||
import { useTabList } from "./useTabList";
|
||||
|
||||
export interface TabListProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* <Tabs.Tab /> elements.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TabList = forwardRef<HTMLDivElement, TabListProps>(
|
||||
({ className, onKeyDown, ...rest }, ref) => {
|
||||
const { onKeyDown: _onKeyDown } = useTabList();
|
||||
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const mergedRef = useMergeRefs(listRef, ref);
|
||||
|
||||
const scrollCtx = useScrollButtons(listRef);
|
||||
|
||||
return (
|
||||
<div className="navds-tabs__tablist-wrapper">
|
||||
{scrollCtx.show && (
|
||||
<ScrollButton
|
||||
dir="left"
|
||||
hidden={!scrollCtx.start}
|
||||
onClick={scrollCtx.scrollLeft}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={mergedRef}
|
||||
{...rest}
|
||||
onScroll={scrollCtx.update}
|
||||
className={cl("navds-tabs__tablist", className)}
|
||||
role="tablist"
|
||||
aria-orientation="horizontal"
|
||||
onKeyDown={composeEventHandlers(onKeyDown, _onKeyDown)}
|
||||
/>
|
||||
{scrollCtx.show && (
|
||||
<ScrollButton
|
||||
dir="right"
|
||||
hidden={!scrollCtx.end}
|
||||
onClick={scrollCtx.scrollRight}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default TabList;
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { debounce } from "../../../util";
|
||||
|
||||
export function useScrollButtons(listRef: React.RefObject<HTMLDivElement>) {
|
||||
const [displayScroll, setDisplayScroll] = useState({
|
||||
start: false,
|
||||
end: false,
|
||||
});
|
||||
|
||||
const updateScrollButtonState = useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
if (!listRef?.current) return;
|
||||
const { scrollWidth, clientWidth } = listRef.current;
|
||||
const scrollLeft = listRef.current.scrollLeft;
|
||||
// use 1 for the potential rounding error with browser zooms.
|
||||
const showStartScroll = scrollLeft > 1;
|
||||
const showEndScroll = scrollLeft < scrollWidth - clientWidth - 1;
|
||||
|
||||
setDisplayScroll((oldDisplayScroll) =>
|
||||
showStartScroll === oldDisplayScroll.start &&
|
||||
showEndScroll === oldDisplayScroll.end
|
||||
? oldDisplayScroll
|
||||
: { start: showStartScroll, end: showEndScroll },
|
||||
);
|
||||
}),
|
||||
[listRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => updateScrollButtonState();
|
||||
const win = listRef.current?.ownerDocument ?? document ?? window;
|
||||
win.addEventListener("resize", handleResize);
|
||||
|
||||
let resizeObserver;
|
||||
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(listRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
win.removeEventListener("resize", handleResize);
|
||||
resizeObserver && resizeObserver.disconnect();
|
||||
updateScrollButtonState.clear();
|
||||
};
|
||||
}, [listRef, updateScrollButtonState]);
|
||||
|
||||
useEffect(() => {
|
||||
updateScrollButtonState();
|
||||
});
|
||||
|
||||
return {
|
||||
update: updateScrollButtonState,
|
||||
start: displayScroll.start,
|
||||
end: displayScroll.end,
|
||||
show: displayScroll.end || displayScroll.start,
|
||||
scrollLeft: () => {
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollLeft -= 100;
|
||||
}
|
||||
},
|
||||
scrollRight: () => {
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollLeft += 100;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTabsContext, useTabsDescendantsContext } from "../../Tabs.context";
|
||||
|
||||
/**
|
||||
* TabList hook to manage multiple tab buttons,
|
||||
* and ensures only one tab is selected at a time.
|
||||
*/
|
||||
export function useTabList() {
|
||||
const { focusedValue, loop, selectedValue, setFocusedValue } =
|
||||
useTabsContext();
|
||||
|
||||
const descendants = useTabsDescendantsContext();
|
||||
|
||||
/**
|
||||
* Implements rowing-tabindex for horizontal tabs
|
||||
*/
|
||||
const onKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
/**
|
||||
* Tabs.Tab is registered with its prop 'value'.
|
||||
* We can then use it to find the current focuses descendant
|
||||
*/
|
||||
const idx = descendants
|
||||
.values()
|
||||
.findIndex((x) => x.value === focusedValue);
|
||||
|
||||
const nextTab = () => {
|
||||
const next = descendants.nextEnabled(idx, loop);
|
||||
next && next.node?.focus();
|
||||
};
|
||||
const prevTab = () => {
|
||||
const prev = descendants.prevEnabled(idx, loop);
|
||||
prev && prev.node?.focus();
|
||||
};
|
||||
const firstTab = () => {
|
||||
const first = descendants.firstEnabled();
|
||||
first && first.node?.focus();
|
||||
};
|
||||
const lastTab = () => {
|
||||
const last = descendants.lastEnabled();
|
||||
last && last.node?.focus();
|
||||
};
|
||||
|
||||
const keyMap: Record<string, React.KeyboardEventHandler> = {
|
||||
ArrowLeft: prevTab,
|
||||
ArrowRight: nextTab,
|
||||
Home: firstTab,
|
||||
End: lastTab,
|
||||
};
|
||||
|
||||
const action = keyMap[event.key];
|
||||
|
||||
if (action) {
|
||||
event.preventDefault();
|
||||
action(event);
|
||||
} else if (event.key === "Tab") {
|
||||
/**
|
||||
* Imperative focus during keydown is risky so we prevent React's batching updates
|
||||
* to avoid potential bugs. See: https://github.com/facebook/react/issues/20332
|
||||
*/
|
||||
selectedValue && setTimeout(() => setFocusedValue(selectedValue));
|
||||
}
|
||||
},
|
||||
[descendants, focusedValue, loop, selectedValue, setFocusedValue],
|
||||
);
|
||||
|
||||
return { onKeyDown };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef } from "react";
|
||||
import { useTabPanel } from "./useTabPanel";
|
||||
|
||||
export interface TabPanelProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Tab panel content.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Value for state-handling.
|
||||
*/
|
||||
value: string;
|
||||
/**
|
||||
* If true, will only render children when selected.
|
||||
* @default true
|
||||
*/
|
||||
lazy?: boolean;
|
||||
/**
|
||||
* Overrides auto-generated id.
|
||||
*
|
||||
* **Warning**: TabPanel generates an id if not provided. If you need to override it,
|
||||
* make sure to also include the correct `aria-labelledby` id for the Tab that labels it.
|
||||
*/
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const TabPanel = forwardRef<HTMLDivElement, TabPanelProps>(
|
||||
({ className, value, children, lazy = true, id, ...rest }, ref) => {
|
||||
const ctx = useTabPanel({ value });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
{...rest}
|
||||
className={cl("navds-tabs__tabpanel", className)}
|
||||
role="tabpanel"
|
||||
tabIndex={0}
|
||||
aria-labelledby={rest["aria-labelledby"] ?? ctx.labelledbyId}
|
||||
id={id ?? ctx.id}
|
||||
hidden={ctx.hidden}
|
||||
data-state={!ctx.hidden ? "active" : "inactive"}
|
||||
>
|
||||
{lazy && ctx.hidden ? null : children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default TabPanel;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useTabsContext } from "../../Tabs.context";
|
||||
|
||||
type TabPanelProps = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tabs hook for managing the visible/hidden state of Tabs.Panel
|
||||
*/
|
||||
export function useTabPanel({ value }: TabPanelProps) {
|
||||
const { id, selectedValue, makeTabId, makeTabPanelId } = useTabsContext();
|
||||
|
||||
return {
|
||||
labelledbyId: makeTabId(id, value),
|
||||
hidden: selectedValue !== value,
|
||||
id: makeTabPanelId(id, value),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useId } from "../util";
|
||||
import { useControllableState } from "../util/hooks/useControllableState";
|
||||
import { TabsProps } from "./Tabs.types";
|
||||
|
||||
export function useTabs({
|
||||
onChange,
|
||||
value,
|
||||
defaultValue = "",
|
||||
id,
|
||||
}: Pick<TabsProps, "onChange" | "value" | "defaultValue" | "id">) {
|
||||
const [focusedValue, setFocusedValue] = useState(defaultValue);
|
||||
|
||||
const [selectedValue, setSelectedValue] = useControllableState({
|
||||
defaultValue,
|
||||
value,
|
||||
onChange,
|
||||
});
|
||||
|
||||
/**
|
||||
* Sync focused `value` with controlled `selectedValue`
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (value != null) {
|
||||
setFocusedValue(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
/**
|
||||
* Scope ids for better tracking
|
||||
*/
|
||||
const uuid = useId();
|
||||
|
||||
return {
|
||||
id: `tabs-${id ?? uuid}`,
|
||||
selectedValue,
|
||||
setSelectedValue,
|
||||
focusedValue,
|
||||
setFocusedValue,
|
||||
makeTabId,
|
||||
makeTabPanelId,
|
||||
};
|
||||
}
|
||||
|
||||
function makeTabId(id: string, value: string) {
|
||||
return `${id}--tab-${value}`;
|
||||
}
|
||||
|
||||
function makeTabPanelId(id: string, value: string) {
|
||||
return `${id}--tabpanel-${value}`;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createContext as ReactCreateContext } from "react";
|
||||
import { createContext } from "../util/create-context";
|
||||
import { createDescendantContext } from "../util/hooks/descendants/useDescendant";
|
||||
import { ToggleGroupProps } from "./ToggleGroup.types";
|
||||
import { useToggleGroup } from "./useToggleGroup";
|
||||
|
||||
interface ToggleContextProps {
|
||||
size: "medium" | "small";
|
||||
}
|
||||
|
||||
export const ToggleGroupContext = ReactCreateContext<ToggleContextProps | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export const [
|
||||
ToggleGroupDescendantsProvider,
|
||||
useToggleGroupDescendantsContext,
|
||||
useToggleGroupDescendants,
|
||||
useToggleGroupDescendant,
|
||||
] = createDescendantContext<HTMLButtonElement, { value: string }>();
|
||||
|
||||
type ToggleGroupProviderProps = ReturnType<typeof useToggleGroup> &
|
||||
Pick<ToggleGroupProps, "size">;
|
||||
|
||||
/* State context */
|
||||
export const [ToggleGroupProvider, useToggleGroupContext] =
|
||||
createContext<ToggleGroupProviderProps>({
|
||||
name: "ToggleGroupContext",
|
||||
hookName: "useToggleGroupContext",
|
||||
providerName: "ToggleGroupProvider",
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
EnvelopeOpenIcon,
|
||||
InboxUpIcon,
|
||||
} from "@navikt/aksel-icons";
|
||||
import { VStack } from "../layout/stack";
|
||||
import ToggleGroup from "./ToggleGroup";
|
||||
|
||||
const meta: Meta<typeof ToggleGroup> = {
|
||||
@@ -22,7 +23,11 @@ const meta: Meta<typeof ToggleGroup> = {
|
||||
control: { type: "radio" },
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const Items = (icon?: boolean, both?: boolean) => (
|
||||
@@ -71,6 +76,7 @@ export const Default = (props) => {
|
||||
</ToggleGroup>
|
||||
);
|
||||
};
|
||||
|
||||
Default.args = {
|
||||
icon: true,
|
||||
text: true,
|
||||
@@ -81,7 +87,7 @@ export const Compositions = () => {
|
||||
const [activeValue, setActiveValue] = useState("ulest");
|
||||
|
||||
return (
|
||||
<div className="colgap">
|
||||
<VStack gap="6">
|
||||
<ToggleGroup value={activeValue} onChange={setActiveValue}>
|
||||
{Items()}
|
||||
</ToggleGroup>
|
||||
@@ -91,7 +97,10 @@ export const Compositions = () => {
|
||||
<ToggleGroup value={activeValue} onChange={setActiveValue}>
|
||||
{Items(true)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<ToggleGroup fill value={activeValue} onChange={setActiveValue}>
|
||||
{Items(true)}
|
||||
</ToggleGroup>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -99,7 +108,7 @@ export const Variants = () => {
|
||||
const [activeValue, setActiveValue] = useState("ulest");
|
||||
|
||||
return (
|
||||
<div className="colgap">
|
||||
<VStack gap="6">
|
||||
<ToggleGroup
|
||||
variant="action"
|
||||
value={activeValue}
|
||||
@@ -114,7 +123,7 @@ export const Variants = () => {
|
||||
>
|
||||
{Items(true, true)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -122,7 +131,7 @@ export const Small = () => {
|
||||
const [activeValue, setActiveValue] = useState("ulest");
|
||||
|
||||
return (
|
||||
<div className="colgap">
|
||||
<VStack gap="6">
|
||||
<ToggleGroup size="small" value={activeValue} onChange={setActiveValue}>
|
||||
{Items()}
|
||||
</ToggleGroup>
|
||||
@@ -132,6 +141,58 @@ export const Small = () => {
|
||||
<ToggleGroup size="small" value={activeValue} onChange={setActiveValue}>
|
||||
{Items(true)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export const Chromatic = {
|
||||
render: () => (
|
||||
<VStack gap="6">
|
||||
<div>
|
||||
<h2>Text</h2>
|
||||
<ToggleGroup value="ulest" onChange={console.log}>
|
||||
{Items()}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Icon</h2>
|
||||
<ToggleGroup value="ulest" onChange={console.log}>
|
||||
{Items(true)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Text + icon</h2>
|
||||
<ToggleGroup value="ulest" onChange={console.log}>
|
||||
{Items(true, true)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div style={{ minWidth: 600 }}>
|
||||
<h2>Fill</h2>
|
||||
<ToggleGroup value="ulest" onChange={console.log} fill>
|
||||
{Items(true, true)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Small</h2>
|
||||
<ToggleGroup value="ulest" onChange={console.log} size="small">
|
||||
{Items(true, true)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Small + fill</h2>
|
||||
<ToggleGroup value="ulest" onChange={console.log} size="small" fill>
|
||||
{Items(true, true)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Neutral</h2>
|
||||
<ToggleGroup value="ulest" onChange={console.log} variant="neutral">
|
||||
{Items(true, true)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</VStack>
|
||||
),
|
||||
parameters: {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { ToggleGroup } from "./ToggleGroup";
|
||||
@@ -20,36 +21,76 @@ const TestToggleGroup = ({ value, onChange, defaultValue }: any) => (
|
||||
describe("ToggleGroup", () => {
|
||||
test("sets default value correctly", () => {
|
||||
render(<TestToggleGroup defaultValue="toggle2" />);
|
||||
const toggle = screen.getByTestId("toggle2");
|
||||
const toggle2 = screen.getByTestId("toggle2");
|
||||
|
||||
expect(toggle).toHaveAttribute("aria-checked", "true");
|
||||
expect(toggle2).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
test("sets correct attributes on active toggle", () => {
|
||||
render(<TestToggleGroup defaultValue="toggle2" />);
|
||||
const toggle = screen.getByTestId("toggle2");
|
||||
const toggle2 = screen.getByTestId("toggle2");
|
||||
|
||||
expect(toggle).toHaveAttribute("aria-checked", "true");
|
||||
expect(toggle).toHaveAttribute("role", "radio");
|
||||
expect(toggle).toHaveAttribute("type", "button");
|
||||
expect(toggle).toHaveAttribute("tabindex", "-1");
|
||||
expect(toggle2).toHaveAttribute("aria-checked", "true");
|
||||
expect(toggle2).toHaveAttribute("role", "radio");
|
||||
expect(toggle2).toHaveAttribute("type", "button");
|
||||
expect(toggle2).toHaveAttribute("tabindex", "0");
|
||||
});
|
||||
|
||||
test("sets correct attributes on idle toggle", () => {
|
||||
render(<TestToggleGroup defaultValue="toggle1" />);
|
||||
const toggle = screen.getByTestId("toggle2");
|
||||
const toggle2 = screen.getByTestId("toggle2");
|
||||
|
||||
expect(toggle).toHaveAttribute("aria-checked", "false");
|
||||
expect(toggle).toHaveAttribute("role", "radio");
|
||||
expect(toggle).toHaveAttribute("type", "button");
|
||||
expect(toggle).toHaveAttribute("tabindex", "-1");
|
||||
expect(toggle2).toHaveAttribute("aria-checked", "false");
|
||||
expect(toggle2).toHaveAttribute("role", "radio");
|
||||
expect(toggle2).toHaveAttribute("type", "button");
|
||||
expect(toggle2).toHaveAttribute("tabindex", "-1");
|
||||
});
|
||||
|
||||
test("sets tabindex to 0 when focused", () => {
|
||||
render(<TestToggleGroup defaultValue="toggle2" />);
|
||||
const toggle = screen.getByTestId("toggle2");
|
||||
const toggle2 = screen.getByTestId("toggle2");
|
||||
|
||||
fireEvent.focus(toggle);
|
||||
expect(toggle).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.focus(toggle2);
|
||||
expect(toggle2).toHaveAttribute("tabindex", "0");
|
||||
});
|
||||
|
||||
test("roving tabindex keydown moves focus", () => {
|
||||
render(<TestToggleGroup defaultValue="toggle1" />);
|
||||
const toggle1 = screen.getByTestId("toggle1");
|
||||
|
||||
expect(toggle1).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.keyDown(toggle1, { key: "ArrowRight" });
|
||||
|
||||
expect(toggle1).toHaveAttribute("tabindex", "-1");
|
||||
expect(screen.getByTestId("toggle2")).toHaveAttribute("tabindex", "0");
|
||||
expect(screen.getByTestId("toggle2")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
test("Space selects focused toggle-item", async () => {
|
||||
render(<TestToggleGroup defaultValue="toggle1" />);
|
||||
const toggle1 = screen.getByTestId("toggle1");
|
||||
|
||||
expect(toggle1).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.keyDown(toggle1, { key: "ArrowRight" });
|
||||
|
||||
expect(toggle1).toHaveAttribute("tabindex", "-1");
|
||||
expect(screen.getByTestId("toggle2")).toHaveAttribute("tabindex", "0");
|
||||
expect(screen.getByTestId("toggle2")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
|
||||
// eslint-disable-next-line testing-library/no-unnecessary-act
|
||||
await act(async () => {
|
||||
await userEvent.keyboard(" ");
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("toggle2")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,55 +1,24 @@
|
||||
import * as RadixToggleGroup from "@radix-ui/react-toggle-group";
|
||||
import cl from "clsx";
|
||||
import React, { HTMLAttributes, forwardRef, useState } from "react";
|
||||
import React, { forwardRef } from "react";
|
||||
import { Label } from "../typography";
|
||||
import { useId } from "../util/hooks";
|
||||
import ToggleItem, { ToggleGroupItemProps } from "./ToggleItem";
|
||||
import { ToggleGroupContext } from "./context";
|
||||
|
||||
export interface ToggleGroupProps
|
||||
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "dir"> {
|
||||
/**
|
||||
* Toggles.Item elements
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Changes padding and font-size
|
||||
* @default "medium"
|
||||
*/
|
||||
size?: "medium" | "small";
|
||||
/**
|
||||
* Controlled selected value
|
||||
*/
|
||||
value?: string;
|
||||
/**
|
||||
* If not controlled, a default-value needs to be set
|
||||
*/
|
||||
defaultValue?: string;
|
||||
/**
|
||||
* Callback for selected toggle
|
||||
*/
|
||||
onChange: (value: string) => void;
|
||||
/**
|
||||
* Label describing ToggleGroup
|
||||
*/
|
||||
label?: React.ReactNode;
|
||||
/**
|
||||
* Changes design and interaction-visuals
|
||||
* @default "action"
|
||||
*/
|
||||
variant?: "action" | "neutral";
|
||||
}
|
||||
import { useId } from "../util";
|
||||
import {
|
||||
ToggleGroupDescendantsProvider,
|
||||
ToggleGroupProvider,
|
||||
useToggleGroupDescendants,
|
||||
} from "./ToggleGroup.context";
|
||||
import { ToggleGroupProps } from "./ToggleGroup.types";
|
||||
import ToggleItem from "./parts/ToggleItem";
|
||||
import { useToggleGroup } from "./useToggleGroup";
|
||||
|
||||
interface ToggleGroupComponent
|
||||
extends React.ForwardRefExoticComponent<
|
||||
ToggleGroupProps & React.RefAttributes<HTMLDivElement>
|
||||
> {
|
||||
/**
|
||||
* @see 🏷️ {@link ToggleGroupItemProps}
|
||||
* @see 🏷️ {@link ToggleItem}
|
||||
*/
|
||||
Item: React.ForwardRefExoticComponent<
|
||||
ToggleGroupItemProps & React.RefAttributes<HTMLButtonElement>
|
||||
>;
|
||||
Item: typeof ToggleItem;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,70 +46,74 @@ export const ToggleGroup = forwardRef<HTMLDivElement, ToggleGroupProps>(
|
||||
label,
|
||||
value,
|
||||
defaultValue,
|
||||
"aria-describedby": desc,
|
||||
"aria-describedby": userDescribedby,
|
||||
variant = "action",
|
||||
fill = false,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [groupValue, setGroupValue] = useState(defaultValue);
|
||||
const labelId = useId();
|
||||
const descendants = useToggleGroupDescendants();
|
||||
|
||||
const handleValueChange = (v: string) => {
|
||||
if (v !== "") {
|
||||
setGroupValue(v);
|
||||
onChange?.(v);
|
||||
}
|
||||
const toggleGroupContext = useToggleGroup({
|
||||
defaultValue,
|
||||
value,
|
||||
onChange,
|
||||
});
|
||||
|
||||
/**
|
||||
* ToggleGroupProvider handles memoization.
|
||||
*/
|
||||
const context = {
|
||||
...toggleGroupContext,
|
||||
size,
|
||||
};
|
||||
|
||||
if (!value && !defaultValue) {
|
||||
console.error("ToggleGroup without value/defaultvalue is not allowed");
|
||||
}
|
||||
const labelId = useId();
|
||||
|
||||
const describeBy = cl({
|
||||
[desc ?? ""]: !!desc,
|
||||
[labelId ?? ""]: !!label,
|
||||
});
|
||||
if (!value && !defaultValue) {
|
||||
console.error("ToggleGroup without value or defaultvalue is not allowed");
|
||||
}
|
||||
|
||||
if (!value && !defaultValue) {
|
||||
console.error("ToggleGroup needs either a value or defaultValue");
|
||||
}
|
||||
|
||||
return (
|
||||
<ToggleGroupContext.Provider
|
||||
value={{
|
||||
size,
|
||||
}}
|
||||
>
|
||||
<div className={cl("navds-toggle-group__wrapper", className)}>
|
||||
{label && (
|
||||
<Label
|
||||
size={size}
|
||||
className="navds-toggle-group__label"
|
||||
id={labelId}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
<RadixToggleGroup.Root
|
||||
{...rest}
|
||||
onValueChange={handleValueChange}
|
||||
value={value ?? groupValue}
|
||||
defaultValue={defaultValue}
|
||||
ref={ref}
|
||||
className={cl(
|
||||
"navds-toggle-group",
|
||||
`navds-toggle-group--${size}`,
|
||||
`navds-toggle-group--${variant}`,
|
||||
)}
|
||||
{...(describeBy && { "aria-describedby": describeBy })}
|
||||
role="radiogroup"
|
||||
type="single"
|
||||
<ToggleGroupDescendantsProvider value={descendants}>
|
||||
<ToggleGroupProvider {...context}>
|
||||
<div
|
||||
className={cl("navds-toggle-group__wrapper", className, {
|
||||
"navds-toggle-group__wrapper--fill": fill,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</RadixToggleGroup.Root>
|
||||
</div>
|
||||
</ToggleGroupContext.Provider>
|
||||
{label && (
|
||||
<Label
|
||||
size={size}
|
||||
className="navds-toggle-group__label"
|
||||
id={labelId}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
<div
|
||||
{...rest}
|
||||
ref={ref}
|
||||
className={cl(
|
||||
"navds-toggle-group",
|
||||
`navds-toggle-group--${size}`,
|
||||
`navds-toggle-group--${variant}`,
|
||||
)}
|
||||
aria-describedby={
|
||||
cl(userDescribedby, !!label && labelId) || undefined
|
||||
}
|
||||
role="radiogroup"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</ToggleGroupProvider>
|
||||
</ToggleGroupDescendantsProvider>
|
||||
);
|
||||
},
|
||||
) as ToggleGroupComponent;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { HTMLAttributes } from "react";
|
||||
|
||||
export interface ToggleGroupProps
|
||||
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "dir"> {
|
||||
/**
|
||||
* Toggles.Item elements.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Changes padding and font-size.
|
||||
* @default "medium"
|
||||
*/
|
||||
size?: "medium" | "small";
|
||||
/**
|
||||
* Controlled selected value.
|
||||
*/
|
||||
value?: string;
|
||||
/**
|
||||
* If not controlled, a default-value needs to be set.
|
||||
*/
|
||||
defaultValue?: string;
|
||||
/**
|
||||
* Callback for selected toggle.
|
||||
*/
|
||||
onChange: (value: string) => void;
|
||||
/**
|
||||
* Label describing ToggleGroup.
|
||||
*/
|
||||
label?: React.ReactNode;
|
||||
/**
|
||||
* Changes design and interaction-visuals.
|
||||
* @default "action"
|
||||
*/
|
||||
variant?: "action" | "neutral";
|
||||
/**
|
||||
* Stretch each button to fill avaliable space in container.
|
||||
* @default false
|
||||
*/
|
||||
fill?: boolean;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import * as RadixToggleGroup from "@radix-ui/react-toggle-group";
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef, useContext } from "react";
|
||||
import { BodyShort } from "../typography/BodyShort";
|
||||
import { ToggleGroupContext } from "./context";
|
||||
|
||||
export interface ToggleGroupItemProps
|
||||
extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
/**
|
||||
* Content
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Value for state-handling
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
const ToggleItem = forwardRef<HTMLButtonElement, ToggleGroupItemProps>(
|
||||
({ className, children, ...rest }, ref) => {
|
||||
const context = useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<RadixToggleGroup.Item
|
||||
{...rest}
|
||||
ref={ref}
|
||||
className={cl("navds-toggle-group__button", className)}
|
||||
>
|
||||
<BodyShort
|
||||
as="span"
|
||||
className="navds-toggle-group__button-inner"
|
||||
size={context?.size}
|
||||
>
|
||||
{children}
|
||||
</BodyShort>
|
||||
</RadixToggleGroup.Item>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default ToggleItem;
|
||||
@@ -1,9 +0,0 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
interface ToggleContextProps {
|
||||
size: "medium" | "small";
|
||||
}
|
||||
|
||||
export const ToggleGroupContext = createContext<ToggleContextProps | null>(
|
||||
null,
|
||||
);
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
export { default as ToggleGroup, type ToggleGroupProps } from "./ToggleGroup";
|
||||
export { default as ToggleGroup } from "./ToggleGroup";
|
||||
export { type ToggleGroupProps } from "./ToggleGroup.types";
|
||||
export {
|
||||
default as ToggleGroupItem,
|
||||
type ToggleGroupItemProps,
|
||||
} from "./ToggleItem";
|
||||
} from "./parts/ToggleItem";
|
||||
@@ -0,0 +1,55 @@
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef } from "react";
|
||||
import { BodyShort } from "../../typography/BodyShort";
|
||||
import { useToggleGroupContext } from "../ToggleGroup.context";
|
||||
import { useToggleItem } from "./useToggleItem";
|
||||
|
||||
export interface ToggleGroupItemProps
|
||||
extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
/**
|
||||
* Content.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Value for state-handling.
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
const ToggleItem = forwardRef<HTMLButtonElement, ToggleGroupItemProps>(
|
||||
(
|
||||
{ className, children, value, onClick, onFocus, onKeyDown, ...rest },
|
||||
forwardedRef,
|
||||
) => {
|
||||
const itemCtx = useToggleItem(
|
||||
{ value, onClick, onFocus, disabled: false, onKeyDown },
|
||||
forwardedRef,
|
||||
);
|
||||
const ctx = useToggleGroupContext();
|
||||
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
ref={itemCtx.ref}
|
||||
className={cl("navds-toggle-group__button", className)}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={itemCtx.isSelected}
|
||||
tabIndex={itemCtx.isFocused ? 0 : -1}
|
||||
onClick={itemCtx.onClick}
|
||||
onFocus={itemCtx.onFocus}
|
||||
onKeyDown={itemCtx.onKeyDown}
|
||||
>
|
||||
<BodyShort
|
||||
as="span"
|
||||
className="navds-toggle-group__button-inner"
|
||||
size={ctx?.size}
|
||||
>
|
||||
{children}
|
||||
</BodyShort>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default ToggleItem;
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCallback } from "react";
|
||||
import { composeEventHandlers } from "../../util/composeEventHandlers";
|
||||
import { mergeRefs } from "../../util/hooks/useMergeRefs";
|
||||
import {
|
||||
useToggleGroupContext,
|
||||
useToggleGroupDescendant,
|
||||
} from "../ToggleGroup.context";
|
||||
|
||||
export interface UseToggleItemProps {
|
||||
/**
|
||||
* If `true`, the `ToggleItem` won't be toggleable
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
onClick?: React.MouseEventHandler;
|
||||
onFocus?: React.FocusEventHandler;
|
||||
onKeyDown?: React.KeyboardEventHandler;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function useToggleItem<P extends UseToggleItemProps>(
|
||||
{
|
||||
value,
|
||||
disabled = false,
|
||||
onFocus: _onFocus,
|
||||
onClick,
|
||||
onKeyDown: _onKeyDown,
|
||||
}: P,
|
||||
ref: React.ForwardedRef<HTMLButtonElement>,
|
||||
) {
|
||||
const { setSelectedValue, setFocusedValue, selectedValue, focusedValue } =
|
||||
useToggleGroupContext();
|
||||
|
||||
const { register, descendants } = useToggleGroupDescendant({
|
||||
disabled,
|
||||
value,
|
||||
});
|
||||
|
||||
const isSelected = value === selectedValue;
|
||||
|
||||
const onFocus = () => setFocusedValue(value);
|
||||
|
||||
/**
|
||||
* Implements roving-tabindex for horizontal tabs
|
||||
*/
|
||||
const onKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
/**
|
||||
* ToggleGroup.Item is registered with its prop 'value'.
|
||||
* We can then use it to find the current focuses descendant
|
||||
*/
|
||||
const idx = descendants
|
||||
.values()
|
||||
.findIndex((x) => x.value === focusedValue);
|
||||
|
||||
const nextTab = () => {
|
||||
const next = descendants.nextEnabled(idx, false);
|
||||
next && next.node?.focus();
|
||||
};
|
||||
const prevTab = () => {
|
||||
const prev = descendants.prevEnabled(idx, false);
|
||||
prev && prev.node?.focus();
|
||||
};
|
||||
const firstTab = () => {
|
||||
const first = descendants.firstEnabled();
|
||||
first && first.node?.focus();
|
||||
};
|
||||
const lastTab = () => {
|
||||
const last = descendants.lastEnabled();
|
||||
last && last.node?.focus();
|
||||
};
|
||||
|
||||
const keyMap: Record<string, React.KeyboardEventHandler> = {
|
||||
ArrowLeft: prevTab,
|
||||
ArrowRight: nextTab,
|
||||
Home: firstTab,
|
||||
End: lastTab,
|
||||
};
|
||||
|
||||
const action = keyMap[event.key];
|
||||
|
||||
if (action) {
|
||||
event.preventDefault();
|
||||
action(event);
|
||||
} else if (event.key === "Tab") {
|
||||
/**
|
||||
* Imperative focus during keydown is risky so we prevent React's batching updates
|
||||
* to avoid potential bugs. See: https://github.com/facebook/react/issues/20332
|
||||
*/
|
||||
selectedValue && setTimeout(() => setFocusedValue(selectedValue));
|
||||
}
|
||||
},
|
||||
[descendants, focusedValue, selectedValue, setFocusedValue],
|
||||
);
|
||||
|
||||
return {
|
||||
ref: mergeRefs([register, ref]),
|
||||
isSelected,
|
||||
isFocused: focusedValue === value,
|
||||
onClick: composeEventHandlers(onClick, () => setSelectedValue(value)),
|
||||
onFocus: disabled ? undefined : composeEventHandlers(_onFocus, onFocus),
|
||||
onKeyDown: composeEventHandlers(_onKeyDown, onKeyDown),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useControllableState } from "../util/hooks/useControllableState";
|
||||
import { ToggleGroupProps } from "./ToggleGroup.types";
|
||||
|
||||
export function useToggleGroup({
|
||||
onChange,
|
||||
value,
|
||||
defaultValue = "",
|
||||
}: Pick<ToggleGroupProps, "onChange" | "value" | "defaultValue">) {
|
||||
const [focusedValue, setFocusedValue] = useState(defaultValue);
|
||||
|
||||
const [selectedValue, setSelectedValue] = useControllableState({
|
||||
defaultValue,
|
||||
value,
|
||||
onChange,
|
||||
});
|
||||
|
||||
/**
|
||||
* Sync focused `value` with controlled `selectedValue`
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (value != null) {
|
||||
setFocusedValue(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return {
|
||||
selectedValue,
|
||||
setSelectedValue,
|
||||
focusedValue,
|
||||
setFocusedValue,
|
||||
};
|
||||
}
|
||||
@@ -4,18 +4,17 @@ import React from "react";
|
||||
import { VStack } from "../../layout/stack";
|
||||
import BodyLong from "../BodyLong";
|
||||
|
||||
const meta = {
|
||||
const meta: Meta<typeof BodyLong> = {
|
||||
title: "ds-react/Typography/BodyLong",
|
||||
component: BodyLong,
|
||||
decorators: [(story) => <div style={{ maxWidth: "700px" }}>{story()}</div>],
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof BodyLong>;
|
||||
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
type Story = StoryObj<typeof BodyLong>;
|
||||
|
||||
const lorem =
|
||||
"Hvis du ikke bor sammen med begge foreldrene dine, kan du ha rett til barnebidrag fra en eller begge foreldre mens du fullfører videregående skole eller tilsvarende.";
|
||||
|
||||
@@ -4,18 +4,17 @@ import React from "react";
|
||||
import { VStack } from "../../layout/stack";
|
||||
import { BodyShort } from "../BodyShort";
|
||||
|
||||
const meta = {
|
||||
const meta: Meta<typeof BodyShort> = {
|
||||
title: "ds-react/Typography/BodyShort",
|
||||
component: BodyShort,
|
||||
decorators: [(story) => <div style={{ maxWidth: "250px" }}>{story()}</div>],
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof BodyShort>;
|
||||
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
type Story = StoryObj<typeof BodyShort>;
|
||||
|
||||
const lorem = "Du må gjøre en filtrering for å se brukere i listen.";
|
||||
|
||||
|
||||
@@ -4,18 +4,17 @@ import React from "react";
|
||||
import { VStack } from "../../layout/stack";
|
||||
import Detail from "../Detail";
|
||||
|
||||
const meta = {
|
||||
const meta: Meta<typeof Detail> = {
|
||||
title: "ds-react/Typography/Detail",
|
||||
component: Detail,
|
||||
decorators: [(story) => <div style={{ maxWidth: "200px" }}>{story()}</div>],
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof Detail>;
|
||||
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
type Story = StoryObj<typeof Detail>;
|
||||
|
||||
const lorem = "Du må gjøre en filtrering for å se brukere i listen.";
|
||||
|
||||
|
||||
@@ -4,17 +4,16 @@ import React from "react";
|
||||
import { VStack } from "../../layout/stack";
|
||||
import ErrorMessage from "../ErrorMessage";
|
||||
|
||||
const meta = {
|
||||
const meta: Meta<typeof ErrorMessage> = {
|
||||
title: "ds-react/Typography/ErrorMessage",
|
||||
component: ErrorMessage,
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof ErrorMessage>;
|
||||
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
type Story = StoryObj<typeof ErrorMessage>;
|
||||
|
||||
const lorem =
|
||||
"Du må fylle ut: Oppgi årsaken til at du har ventet mer enn 6 måneder med å søke om refusjon";
|
||||
|
||||
@@ -4,18 +4,17 @@ import { default as React } from "react";
|
||||
import { VStack } from "../../layout/stack";
|
||||
import Heading from "../Heading";
|
||||
|
||||
const meta = {
|
||||
const meta: Meta<typeof Heading> = {
|
||||
title: "ds-react/Typography/Heading",
|
||||
component: Heading,
|
||||
decorators: [(story) => <div style={{ maxWidth: "300px" }}>{story()}</div>],
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof Heading>;
|
||||
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
type Story = StoryObj<typeof Heading>;
|
||||
|
||||
const lorem = "Hva kan vi hjelpe deg med?";
|
||||
|
||||
|
||||
@@ -4,18 +4,17 @@ import React from "react";
|
||||
import { VStack } from "../../layout/stack";
|
||||
import Label from "../Label";
|
||||
|
||||
const meta = {
|
||||
const meta: Meta<typeof Label> = {
|
||||
title: "ds-react/Typography/Label",
|
||||
component: Label,
|
||||
decorators: [(story) => <div style={{ maxWidth: "400px" }}>{story()}</div>],
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof Label>;
|
||||
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
type Story = StoryObj<typeof Label>;
|
||||
|
||||
const lorem =
|
||||
"Oppgi årsaken til at du har ventet mer enn 6 måneder med å søke om refusjon";
|
||||
|
||||
@@ -143,7 +143,21 @@ export class DescendantsManager<
|
||||
};
|
||||
|
||||
private registerNode = (node: T | null, options?: DescendantOptions<K>) => {
|
||||
if (!node || this.descendants.has(node)) return;
|
||||
if (!node) return;
|
||||
|
||||
/**
|
||||
* While the node is registered, we have to make sure to sync options
|
||||
* This is useful when ex. a node is disabled after it has been registered
|
||||
*/
|
||||
const mapNode = this.descendants.get(node);
|
||||
if (mapNode) {
|
||||
this.descendants.set(node, {
|
||||
index: mapNode.index,
|
||||
node,
|
||||
...options,
|
||||
} as Descendant<T, K>);
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = Array.from(this.descendants.keys()).concat(node);
|
||||
const sorted = sortNodes(keys);
|
||||
|
||||
@@ -17,11 +17,6 @@ function useDescendants<
|
||||
K extends Record<string, any> = object,
|
||||
>() {
|
||||
const descendants = useRef(new DescendantsManager<T, K>()).current;
|
||||
useClientLayoutEffect(() => {
|
||||
return () => {
|
||||
descendants.destroy();
|
||||
};
|
||||
});
|
||||
|
||||
return descendants;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @navikt/ds-tailwind
|
||||
|
||||
## 6.6.0
|
||||
|
||||
## 6.5.0
|
||||
|
||||
## 6.4.1
|
||||
|
||||
## 6.4.0
|
||||
|
||||
## 6.3.6
|
||||
|
||||
## 6.3.5
|
||||
|
||||
## 6.3.4
|
||||
|
||||
## 6.3.3
|
||||
|
||||
## 6.3.2
|
||||
|
||||
Loaded 100 of 306 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user