mirror of
https://github.com/navikt/aksel.git
synced 2026-10-16 10:42:03 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09828a4223 | ||
|
|
e776c7c560 | ||
|
|
2dd266f0c4 | ||
|
|
daee394a71 | ||
|
|
8fd183cc90 | ||
|
|
0b1ac4f82c | ||
|
|
ec79e25060 | ||
|
|
18d20aaa8e | ||
|
|
72c8f3112a | ||
|
|
6e63069d6b | ||
|
|
825d87af43 | ||
|
|
9181d2ce75 | ||
|
|
22016ec90f | ||
|
|
f7d4c43f98 | ||
|
|
c67fd8bcda | ||
|
|
5efa09d00e | ||
|
|
3afc568a04 | ||
|
|
2fe2586ac6 | ||
|
|
420c5c30d8 | ||
|
|
6cf141a8c6 | ||
|
|
dcba0fffad | ||
|
|
da0a66fb62 | ||
|
|
3a52359169 | ||
|
|
1b8d553c05 |
No files matched your search
@@ -0,0 +1,29 @@
|
||||
# https://github.com/actions/stale
|
||||
name: Mark stale issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "31 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-issue-stale: 120
|
||||
days-before-pr-stale: -1
|
||||
days-before-close: 30
|
||||
any-of-labels: "Besvart"
|
||||
stale-issue-message: |-
|
||||
Hei! Det ser ut som det ikke har vært aktivitet her på en stund. Etter 30 dager vil saken bli lukket automatisk.
|
||||
|
||||
Om saken fortsatt er relevant eller du har oppdateringer, vil en kommentar hindre at den lukkes. Du kan alltids åpne den igjen hvis det skulle være nødvendig!
|
||||
stale-issue-label: "no-issue-activity"
|
||||
ascending: true
|
||||
operations-per-run: 200
|
||||
@@ -1,5 +1,7 @@
|
||||
# @navikt/aksel-icons
|
||||
|
||||
## 6.14.0
|
||||
|
||||
## 6.13.0
|
||||
|
||||
## 6.12.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel-icons",
|
||||
"version": "6.13.0",
|
||||
"version": "6.14.0",
|
||||
"author": "Aksel | NAV",
|
||||
"homepage": "https://aksel.nav.no/ikoner",
|
||||
"repository": {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# @navikt/aksel-stylelint
|
||||
|
||||
## 6.14.0
|
||||
|
||||
## 6.13.0
|
||||
|
||||
## 6.12.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel-stylelint",
|
||||
"version": "6.13.0",
|
||||
"version": "6.14.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.13.0",
|
||||
"@navikt/ds-tokens": "^6.13.0",
|
||||
"@navikt/ds-css": "^6.14.0",
|
||||
"@navikt/ds-tokens": "^6.14.0",
|
||||
"concurrently": "7.2.1",
|
||||
"postcss-selector-parser": "^6.0.13",
|
||||
"postcss-value-parser": "^4.2.0",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @navikt/aksel
|
||||
|
||||
## 6.14.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`8fd183cc9`](https://github.com/navikt/aksel/commit/8fd183cc909a1f27a0a9f496415d280c1207a479)]:
|
||||
- @navikt/ds-css@6.14.0
|
||||
|
||||
## 6.13.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel",
|
||||
"version": "6.13.0",
|
||||
"version": "6.14.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.13.0",
|
||||
"@navikt/ds-css": "6.14.0",
|
||||
"axios": "1.6.0",
|
||||
"chalk": "4.1.0",
|
||||
"clipboardy": "^2.3.0",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @navikt/ds-css
|
||||
|
||||
## 6.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Fonts: Added built-in support for cyrillic, cyrillic-ext and latin-ext ([#3044](https://github.com/navikt/aksel/pull/3044))
|
||||
|
||||
## 6.13.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
body,
|
||||
:host {
|
||||
font-family: var(--a-font-family, "Source Sans Pro", Arial, sans-serif);
|
||||
font-family: var(--a-font-family, "Source Sans 3", "Source Sans Pro", Arial, sans-serif);
|
||||
line-height: 1.333;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,129 @@
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: "Source Sans 3";
|
||||
font-style: italic;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/italic-cyrillic-ext.woff2) format("woff2");
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: "Source Sans 3";
|
||||
font-style: italic;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/italic-cyrillic.woff2) format("woff2");
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: "Source Sans 3";
|
||||
font-style: italic;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/italic-latin-ext.woff2) format("woff2");
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
|
||||
U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: "Source Sans 3";
|
||||
font-style: italic;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/italic-latin.woff2) format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
|
||||
U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: "Source Sans 3";
|
||||
font-style: normal;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/normal-cyrillic-ext.woff2) format("woff2");
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: "Source Sans 3";
|
||||
font-style: normal;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/normal-cyrillic.woff2) format("woff2");
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: "Source Sans 3";
|
||||
font-style: normal;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/normal-latin-ext.woff2) format("woff2");
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
|
||||
U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: "Source Sans 3";
|
||||
font-style: normal;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/normal-latin.woff2) format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
|
||||
U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* Legacy support */
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: "Source Sans Pro";
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url("https://cdn.nav.no/aksel/fonts/SourceSans3-italic.woff2") format("woff2");
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/italic-latin-ext.woff2) format("woff2");
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
|
||||
U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: "Source Sans Pro";
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url("https://cdn.nav.no/aksel/fonts/SourceSans3-italic-latin-semibold.woff2") format("woff2");
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/italic-latin.woff2) format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
|
||||
U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: "Source Sans Pro";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url("https://cdn.nav.no/aksel/fonts/SourceSans3-normal.woff2") format("woff2");
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/normal-latin-ext.woff2) format("woff2");
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
|
||||
U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: "Source Sans Pro";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-weight: 400 600;
|
||||
font-display: swap;
|
||||
src: url("https://cdn.nav.no/aksel/fonts/SourceSans3-normal.woff2") format("woff2");
|
||||
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/normal-latin.woff2) format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
|
||||
U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-css",
|
||||
"version": "6.13.0",
|
||||
"version": "6.14.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.13.0",
|
||||
"@navikt/ds-tokens": "^6.14.0",
|
||||
"cssnano": "6.0.0",
|
||||
"fast-glob": "3.2.11",
|
||||
"lodash": "4.17.21",
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
--__ac-hgrid-columns-xl: var(--__ac-hgrid-columns-lg);
|
||||
--__ac-hgrid-columns-2xl: var(--__ac-hgrid-columns-xl);
|
||||
--__ac-hgrid-columns: var(--__ac-hgrid-columns-xs);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: var(--__ac-hgrid-columns);
|
||||
}
|
||||
|
||||
.navds-hgrid-gap {
|
||||
--__ac-hgrid-gap-xs: initial;
|
||||
--__ac-hgrid-gap-sm: var(--__ac-hgrid-gap-xs);
|
||||
--__ac-hgrid-gap-md: var(--__ac-hgrid-gap-sm);
|
||||
@@ -13,17 +19,22 @@
|
||||
--__ac-hgrid-gap-xl: var(--__ac-hgrid-gap-lg);
|
||||
--__ac-hgrid-gap-2xl: var(--__ac-hgrid-gap-xl);
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-xs);
|
||||
|
||||
gap: var(--__ac-hgrid-gap);
|
||||
}
|
||||
|
||||
.navds-hgrid-align {
|
||||
--__ac-hgrid-align: initial;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: var(--__ac-hgrid-columns);
|
||||
gap: var(--__ac-hgrid-gap);
|
||||
align-items: var(--__ac-hgrid-align);
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.navds-hgrid {
|
||||
--__ac-hgrid-columns: var(--__ac-hgrid-columns-sm);
|
||||
}
|
||||
|
||||
.navds-hgrid-gap {
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-sm);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +42,9 @@
|
||||
@media (min-width: 768px) {
|
||||
.navds-hgrid {
|
||||
--__ac-hgrid-columns: var(--__ac-hgrid-columns-md);
|
||||
}
|
||||
|
||||
.navds-hgrid-gap {
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-md);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +52,9 @@
|
||||
@media (min-width: 1024px) {
|
||||
.navds-hgrid {
|
||||
--__ac-hgrid-columns: var(--__ac-hgrid-columns-lg);
|
||||
}
|
||||
|
||||
.navds-hgrid-gap {
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-lg);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +62,9 @@
|
||||
@media (min-width: 1280px) {
|
||||
.navds-hgrid {
|
||||
--__ac-hgrid-columns: var(--__ac-hgrid-columns-xl);
|
||||
}
|
||||
|
||||
.navds-hgrid-gap {
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-xl);
|
||||
}
|
||||
}
|
||||
@@ -52,6 +72,9 @@
|
||||
@media (min-width: 1440px) {
|
||||
.navds-hgrid {
|
||||
--__ac-hgrid-columns: var(--__ac-hgrid-columns-2xl);
|
||||
}
|
||||
|
||||
.navds-hgrid-gap {
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-2xl);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
.navds-stack {
|
||||
--__ac-stack-wrap: initial;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.navds-stack-gap {
|
||||
--__ac-stack-gap-xs: initial;
|
||||
--__ac-stack-gap-sm: var(--__ac-stack-gap-xs);
|
||||
--__ac-stack-gap-md: var(--__ac-stack-gap-sm);
|
||||
@@ -7,13 +10,11 @@
|
||||
--__ac-stack-gap-xl: var(--__ac-stack-gap-lg);
|
||||
--__ac-stack-gap-2xl: var(--__ac-stack-gap-xl);
|
||||
--__ac-stack-gap: var(--__ac-stack-gap-xs);
|
||||
--__ac-stack-justify-xs: initial;
|
||||
--__ac-stack-justify-sm: var(--__ac-stack-justify-xs);
|
||||
--__ac-stack-justify-md: var(--__ac-stack-justify-sm);
|
||||
--__ac-stack-justify-lg: var(--__ac-stack-justify-md);
|
||||
--__ac-stack-justify-xl: var(--__ac-stack-justify-lg);
|
||||
--__ac-stack-justify-2xl: var(--__ac-stack-justify-xl);
|
||||
--__ac-stack-justify: var(--__ac-stack-justify-xs);
|
||||
|
||||
gap: var(--__ac-stack-gap);
|
||||
}
|
||||
|
||||
.navds-stack-align {
|
||||
--__ac-stack-align-xs: initial;
|
||||
--__ac-stack-align-sm: var(--__ac-stack-align-xs);
|
||||
--__ac-stack-align-md: var(--__ac-stack-align-sm);
|
||||
@@ -21,6 +22,23 @@
|
||||
--__ac-stack-align-xl: var(--__ac-stack-align-lg);
|
||||
--__ac-stack-align-2xl: var(--__ac-stack-align-xl);
|
||||
--__ac-stack-align: var(--__ac-stack-align-xs);
|
||||
|
||||
align-items: var(--__ac-stack-align);
|
||||
}
|
||||
|
||||
.navds-stack-justify {
|
||||
--__ac-stack-justify-xs: initial;
|
||||
--__ac-stack-justify-sm: var(--__ac-stack-justify-xs);
|
||||
--__ac-stack-justify-md: var(--__ac-stack-justify-sm);
|
||||
--__ac-stack-justify-lg: var(--__ac-stack-justify-md);
|
||||
--__ac-stack-justify-xl: var(--__ac-stack-justify-lg);
|
||||
--__ac-stack-justify-2xl: var(--__ac-stack-justify-xl);
|
||||
--__ac-stack-justify: var(--__ac-stack-justify-xs);
|
||||
|
||||
justify-content: var(--__ac-stack-justify);
|
||||
}
|
||||
|
||||
.navds-stack-direction {
|
||||
--__ac-stack-direction-xs: initial;
|
||||
--__ac-stack-direction-sm: var(--__ac-stack-direction-xs);
|
||||
--__ac-stack-direction-md: var(--__ac-stack-direction-sm);
|
||||
@@ -29,11 +47,11 @@
|
||||
--__ac-stack-direction-2xl: var(--__ac-stack-direction-xl);
|
||||
--__ac-stack-direction: var(--__ac-stack-direction-xs);
|
||||
|
||||
gap: var(--__ac-stack-gap);
|
||||
display: flex;
|
||||
align-items: var(--__ac-stack-align);
|
||||
justify-content: var(--__ac-stack-justify);
|
||||
flex-flow: var(--__ac-stack-direction) var(--__ac-stack-wrap);
|
||||
flex-direction: var(--__ac-stack-direction);
|
||||
}
|
||||
|
||||
.navds-stack-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.navds-stack__spacer {
|
||||
@@ -48,46 +66,91 @@
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.navds-stack {
|
||||
.navds-stack-gap {
|
||||
--__ac-stack-gap: var(--__ac-stack-gap-sm);
|
||||
}
|
||||
|
||||
.navds-stack-align {
|
||||
--__ac-stack-align: var(--__ac-stack-align-sm);
|
||||
}
|
||||
|
||||
.navds-stack-justify {
|
||||
--__ac-stack-justify: var(--__ac-stack-justify-sm);
|
||||
}
|
||||
|
||||
.navds-stack-direction {
|
||||
--__ac-stack-direction: var(--__ac-stack-direction-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.navds-stack {
|
||||
.navds-stack-gap {
|
||||
--__ac-stack-gap: var(--__ac-stack-gap-md);
|
||||
}
|
||||
|
||||
.navds-stack-align {
|
||||
--__ac-stack-align: var(--__ac-stack-align-md);
|
||||
}
|
||||
|
||||
.navds-stack-justify {
|
||||
--__ac-stack-justify: var(--__ac-stack-justify-md);
|
||||
}
|
||||
|
||||
.navds-stack-direction {
|
||||
--__ac-stack-direction: var(--__ac-stack-direction-md);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.navds-stack {
|
||||
.navds-stack-gap {
|
||||
--__ac-stack-gap: var(--__ac-stack-gap-lg);
|
||||
}
|
||||
|
||||
.navds-stack-align {
|
||||
--__ac-stack-align: var(--__ac-stack-align-lg);
|
||||
}
|
||||
|
||||
.navds-stack-justify {
|
||||
--__ac-stack-justify: var(--__ac-stack-justify-lg);
|
||||
}
|
||||
|
||||
.navds-stack-direction {
|
||||
--__ac-stack-direction: var(--__ac-stack-direction-lg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.navds-stack {
|
||||
.navds-stack-gap {
|
||||
--__ac-stack-gap: var(--__ac-stack-gap-xl);
|
||||
}
|
||||
|
||||
.navds-stack-align {
|
||||
--__ac-stack-align: var(--__ac-stack-align-xl);
|
||||
}
|
||||
|
||||
.navds-stack-justify {
|
||||
--__ac-stack-justify: var(--__ac-stack-justify-xl);
|
||||
}
|
||||
|
||||
.navds-stack-direction {
|
||||
--__ac-stack-direction: var(--__ac-stack-direction-xl);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
.navds-stack {
|
||||
.navds-stack-gap {
|
||||
--__ac-stack-gap: var(--__ac-stack-gap-2xl);
|
||||
}
|
||||
|
||||
.navds-stack-align {
|
||||
--__ac-stack-align: var(--__ac-stack-align-2xl);
|
||||
}
|
||||
|
||||
.navds-stack-justify {
|
||||
--__ac-stack-justify: var(--__ac-stack-justify-2xl);
|
||||
}
|
||||
|
||||
.navds-stack-direction {
|
||||
--__ac-stack-direction: var(--__ac-stack-direction-2xl);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,29 @@
|
||||
# @navikt/ds-react
|
||||
|
||||
## 6.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Breaking change: Combobox.onChange now receive only value as argument, instead of ChangeEvent ([#2974](https://github.com/navikt/aksel/pull/2974))
|
||||
|
||||
- Improved search in Combobox - find hits anywhere in the label ([#2974](https://github.com/navikt/aksel/pull/2974))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Tabs, ToggleGroup: Rovingfocus now respects shift, alt, ctrl and meta-keys when navigating. ([#3041](https://github.com/navikt/aksel/pull/3041))
|
||||
|
||||
- Prevent NullPointer when adding a new/custom option in Combobox single-select ([#3054](https://github.com/navikt/aksel/pull/3054))
|
||||
|
||||
- Combobox: Entering an already selected option and pressing enter no longer removes it ([#3061](https://github.com/navikt/aksel/pull/3061))
|
||||
|
||||
- Combobox: Description is now connected to the input field via aria-describedby ([#3065](https://github.com/navikt/aksel/pull/3065))
|
||||
|
||||
- Prevent Escape in open Combobox from closing Modals ([#3053](https://github.com/navikt/aksel/pull/3053))
|
||||
|
||||
- Updated dependencies [[`8fd183cc9`](https://github.com/navikt/aksel/commit/8fd183cc909a1f27a0a9f496415d280c1207a479)]:
|
||||
- @navikt/ds-tokens@6.14.0
|
||||
- @navikt/aksel-icons@6.14.0
|
||||
|
||||
## 6.13.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-react",
|
||||
"version": "6.13.0",
|
||||
"version": "6.14.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",
|
||||
@@ -594,8 +594,8 @@
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "0.25.4",
|
||||
"@floating-ui/react-dom": "^2.0.9",
|
||||
"@navikt/aksel-icons": "^6.13.0",
|
||||
"@navikt/ds-tokens": "^6.13.0",
|
||||
"@navikt/aksel-icons": "^6.14.0",
|
||||
"@navikt/ds-tokens": "^6.14.0",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.0.0",
|
||||
"react-day-picker": "8.10.0"
|
||||
|
||||
@@ -61,28 +61,28 @@ export const Controls: Story = {
|
||||
variant: {
|
||||
control: {
|
||||
type: "radio",
|
||||
options: [
|
||||
"primary",
|
||||
"secondary",
|
||||
"tertiary",
|
||||
"danger",
|
||||
"primary-neutral",
|
||||
"secondary-neutral",
|
||||
"tertiary-neutral",
|
||||
],
|
||||
},
|
||||
options: [
|
||||
"primary",
|
||||
"secondary",
|
||||
"tertiary",
|
||||
"danger",
|
||||
"primary-neutral",
|
||||
"secondary-neutral",
|
||||
"tertiary-neutral",
|
||||
],
|
||||
},
|
||||
size: {
|
||||
control: {
|
||||
type: "radio",
|
||||
options: ["medium", "small", "xsmall"],
|
||||
},
|
||||
options: ["medium", "small", "xsmall"],
|
||||
},
|
||||
iconPosition: {
|
||||
control: {
|
||||
type: "radio",
|
||||
options: ["left", "right"],
|
||||
},
|
||||
options: ["left", "right"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,7 +10,10 @@ import { ComboboxProps } from "./types";
|
||||
|
||||
export const Combobox = forwardRef<
|
||||
HTMLInputElement,
|
||||
Omit<ComboboxProps, "onChange" | "options" | "size" | "onClear" | "value">
|
||||
Omit<
|
||||
ComboboxProps,
|
||||
"onChange" | "options" | "size" | "onClear" | "value" | "disabled"
|
||||
>
|
||||
>((props, ref) => {
|
||||
const { className, hideLabel = false, description, label, ...rest } = props;
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ const ComboboxProvider = forwardRef<HTMLInputElement, ComboboxProps>(
|
||||
allowNewValues = false,
|
||||
children,
|
||||
defaultValue,
|
||||
disabled,
|
||||
error,
|
||||
errorId,
|
||||
filteredOptions: externalFilteredOptions,
|
||||
@@ -60,6 +61,8 @@ const ComboboxProvider = forwardRef<HTMLInputElement, ComboboxProps>(
|
||||
<InputContextProvider
|
||||
value={{
|
||||
defaultValue,
|
||||
description: rest.description,
|
||||
disabled,
|
||||
error,
|
||||
errorId,
|
||||
id,
|
||||
|
||||
@@ -4,11 +4,18 @@ const normalizeText = (text: string): string =>
|
||||
typeof text === "string" ? text.toLocaleLowerCase().trim() : "";
|
||||
|
||||
const isPartOfText = (value: string, text: string) =>
|
||||
normalizeText(text).startsWith(normalizeText(value ?? ""));
|
||||
normalizeText(text).includes(normalizeText(value ?? ""));
|
||||
|
||||
const getMatchingValuesFromList = (value: string, list: ComboboxOption[]) =>
|
||||
list.filter((listItem) => isPartOfText(value, listItem.label));
|
||||
|
||||
const getFirstValueStartingWith = (text: string, list: ComboboxOption[]) => {
|
||||
const normalizedText = normalizeText(text);
|
||||
return list.find((listItem) =>
|
||||
normalizeText(listItem.label).startsWith(normalizedText),
|
||||
);
|
||||
};
|
||||
|
||||
const getFilteredOptionsId = (comboboxId: string) =>
|
||||
`${comboboxId}-filtered-options`;
|
||||
|
||||
@@ -37,4 +44,5 @@ export default {
|
||||
getIsLoadingId,
|
||||
getNoHitsId,
|
||||
getMaxSelectedOptionsId,
|
||||
getFirstValueStartingWith,
|
||||
};
|
||||
@@ -106,16 +106,19 @@ const FilteredOptionsProvider = ({
|
||||
}, [allowNewValues, customOptions, id, options, value]);
|
||||
|
||||
useClientLayoutEffect(() => {
|
||||
const autoCompleteCandidate =
|
||||
filteredOptionsUtils.getFirstValueStartingWith(
|
||||
searchTerm,
|
||||
filteredOptions,
|
||||
)?.label;
|
||||
if (
|
||||
shouldAutocomplete &&
|
||||
filteredOptionsUtils.normalizeText(searchTerm) !== "" &&
|
||||
(previousSearchTerm?.length || 0) < searchTerm.length &&
|
||||
filteredOptions.length > 0
|
||||
autoCompleteCandidate &&
|
||||
(previousSearchTerm?.length || 0) < searchTerm.length
|
||||
) {
|
||||
setValue(
|
||||
`${searchTerm}${filteredOptions[0].label.substring(searchTerm.length)}`,
|
||||
`${searchTerm}${autoCompleteCandidate.substring(searchTerm.length)}`,
|
||||
);
|
||||
setSearchTerm(searchTerm);
|
||||
}
|
||||
}, [
|
||||
filteredOptions,
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import React, {
|
||||
ChangeEvent,
|
||||
ChangeEventHandler,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { createContext } from "../../../util/create-context";
|
||||
import { useClientLayoutEffect } from "../../../util/hooks";
|
||||
import { FormFieldType, useFormField } from "../../useFormField";
|
||||
@@ -13,12 +6,12 @@ import { ComboboxProps } from "../types";
|
||||
|
||||
interface InputContextValue extends FormFieldType {
|
||||
clearInput: NonNullable<ComboboxProps["onClear"]>;
|
||||
error?: string;
|
||||
error?: ComboboxProps["error"];
|
||||
focusInput: () => void;
|
||||
inputRef: React.RefObject<HTMLInputElement>;
|
||||
value: string;
|
||||
setValue: (text: string) => void;
|
||||
onChange: ChangeEventHandler<HTMLInputElement>;
|
||||
onChange: (newValue: string) => void;
|
||||
searchTerm: string;
|
||||
setSearchTerm: React.Dispatch<React.SetStateAction<string>>;
|
||||
shouldAutocomplete?: boolean;
|
||||
@@ -31,7 +24,24 @@ const [InputContextProvider, useInputContext] =
|
||||
errorMessage: "useInputContext must be used within an InputContextProvider",
|
||||
});
|
||||
|
||||
const InputProvider = ({ children, value: props }) => {
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
value: {
|
||||
defaultValue: ComboboxProps["defaultValue"];
|
||||
description: ComboboxProps["description"];
|
||||
disabled: ComboboxProps["disabled"];
|
||||
error: ComboboxProps["error"];
|
||||
errorId: ComboboxProps["errorId"];
|
||||
id: ComboboxProps["id"];
|
||||
value: ComboboxProps["value"];
|
||||
onChange: ComboboxProps["onChange"];
|
||||
onClear: ComboboxProps["onClear"];
|
||||
shouldAutocomplete: ComboboxProps["shouldAutocomplete"];
|
||||
size: ComboboxProps["size"];
|
||||
};
|
||||
}
|
||||
|
||||
const InputProvider = ({ children, value: props }: Props) => {
|
||||
const {
|
||||
defaultValue = "",
|
||||
description,
|
||||
@@ -68,30 +78,22 @@ const InputProvider = ({ children, value: props }) => {
|
||||
const [searchTerm, setSearchTerm] = useState(value);
|
||||
|
||||
const onChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = event.currentTarget.value;
|
||||
(newValue: string) => {
|
||||
externalValue ?? setInternalValue(newValue);
|
||||
externalOnChange?.(event);
|
||||
setSearchTerm(newValue);
|
||||
externalOnChange?.(newValue);
|
||||
},
|
||||
[externalValue, externalOnChange],
|
||||
);
|
||||
|
||||
const setValue = useCallback(
|
||||
(text) => {
|
||||
setInternalValue(text);
|
||||
},
|
||||
[setInternalValue],
|
||||
);
|
||||
|
||||
const clearInput = useCallback(
|
||||
(event: React.PointerEvent | React.KeyboardEvent | React.MouseEvent) => {
|
||||
onClear?.(event);
|
||||
externalOnChange?.(null, "");
|
||||
setValue("");
|
||||
externalOnChange?.("");
|
||||
setInternalValue("");
|
||||
setSearchTerm("");
|
||||
},
|
||||
[externalOnChange, onClear, setValue],
|
||||
[externalOnChange, onClear, setInternalValue],
|
||||
);
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
@@ -111,7 +113,7 @@ const InputProvider = ({ children, value: props }) => {
|
||||
focusInput,
|
||||
inputRef,
|
||||
value,
|
||||
setValue,
|
||||
setValue: setInternalValue,
|
||||
onChange,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import cl from "clsx";
|
||||
import React, {
|
||||
ChangeEvent,
|
||||
InputHTMLAttributes,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { omit } from "../../../util";
|
||||
import { useMergeRefs } from "../../../util/hooks";
|
||||
import filteredOptionsUtil from "../FilteredOptions/filtered-options-util";
|
||||
import { useFilteredOptionsContext } from "../FilteredOptions/filteredOptionsContext";
|
||||
import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
|
||||
import { useInputContext } from "./Input.context";
|
||||
|
||||
interface InputProps
|
||||
extends Omit<InputHTMLAttributes<HTMLInputElement>, "value"> {
|
||||
extends Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "disabled"> {
|
||||
ref: React.Ref<HTMLInputElement>;
|
||||
inputClassName?: string;
|
||||
value?: string;
|
||||
@@ -20,7 +21,17 @@ interface InputProps
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ inputClassName, ...rest }, ref) => {
|
||||
const { clearInput, inputProps, onChange, size, value } = useInputContext();
|
||||
const internalRef = useRef<HTMLInputElement>(null);
|
||||
const mergedRefs = useMergeRefs(ref, internalRef);
|
||||
const {
|
||||
clearInput,
|
||||
inputProps,
|
||||
onChange,
|
||||
size,
|
||||
value,
|
||||
searchTerm,
|
||||
setValue,
|
||||
} = useInputContext();
|
||||
const {
|
||||
selectedOptions,
|
||||
removeSelectedOption,
|
||||
@@ -56,7 +67,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
if (!isMultiSelect && !isTextInSelectedOptions(currentOption.label)) {
|
||||
toggleIsListOpen(false);
|
||||
}
|
||||
} else if (shouldAutocomplete && isTextInSelectedOptions(value)) {
|
||||
} else if (isTextInSelectedOptions(value)) {
|
||||
event.preventDefault();
|
||||
// Trying to set the same value that is already set, so just clearing the input
|
||||
clearInput(event);
|
||||
@@ -67,13 +78,13 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
allowNewValues && isValueNew
|
||||
? { label: value, value }
|
||||
: filteredOptions[0];
|
||||
|
||||
if (!selectedValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
toggleOption(selectedValue, event);
|
||||
if (
|
||||
!isMultiSelect &&
|
||||
!isTextInSelectedOptions(
|
||||
filteredOptions[0].label || selectedValue.label,
|
||||
)
|
||||
) {
|
||||
if (!isMultiSelect && !isTextInSelectedOptions(selectedValue.label)) {
|
||||
toggleIsListOpen(false);
|
||||
}
|
||||
}
|
||||
@@ -96,10 +107,6 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
switch (e.key) {
|
||||
case "Escape":
|
||||
clearInput(e);
|
||||
toggleIsListOpen(false);
|
||||
break;
|
||||
case "Enter":
|
||||
case "Accept":
|
||||
onEnter(e);
|
||||
@@ -118,7 +125,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e) => {
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
setIsMouseLastUsedInputDevice(false);
|
||||
if (e.key === "Backspace") {
|
||||
if (value === "") {
|
||||
@@ -132,17 +139,34 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
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
|
||||
if (e.target.selectionStart === value?.length) {
|
||||
e.preventDefault();
|
||||
if (virtualFocus.activeElement === null || !isListOpen) {
|
||||
toggleIsListOpen(true);
|
||||
}
|
||||
virtualFocus.moveFocusDown();
|
||||
} else if (e.key === "Escape") {
|
||||
if (isListOpen || value) {
|
||||
e.preventDefault(); // Prevents closing an encasing Modal, as Combobox reacts on keyup.
|
||||
clearInput(e);
|
||||
toggleIsListOpen(false);
|
||||
}
|
||||
} else if (["ArrowLeft", "ArrowRight"].includes(e.key)) {
|
||||
/**
|
||||
* In case user has an active selection and 'completes' the selection with ArrowLeft or ArrowRight
|
||||
* we need to make sure to update the filter.
|
||||
*/
|
||||
if (value !== "" && value !== searchTerm) {
|
||||
onChange(value);
|
||||
}
|
||||
} else if (e.key === "ArrowDown") {
|
||||
// Reset the value to the search term to cancel autocomplete
|
||||
// if the user moves focus down to the FilteredOptions
|
||||
if (value !== searchTerm) {
|
||||
setValue(searchTerm);
|
||||
}
|
||||
if (virtualFocus.activeElement === null || !isListOpen) {
|
||||
toggleIsListOpen(true);
|
||||
}
|
||||
virtualFocus.moveFocusDown();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
if (value !== "" && value !== searchTerm) {
|
||||
onChange(value);
|
||||
}
|
||||
// Check that the FilteredOptions list is open and has virtual focus.
|
||||
// Otherwise ignore keystrokes, so it doesn't interfere with text editing
|
||||
if (isListOpen && activeDecendantId) {
|
||||
@@ -161,13 +185,17 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
isListOpen,
|
||||
activeDecendantId,
|
||||
setIsMouseLastUsedInputDevice,
|
||||
clearInput,
|
||||
toggleIsListOpen,
|
||||
onChange,
|
||||
virtualFocus,
|
||||
setValue,
|
||||
searchTerm,
|
||||
],
|
||||
);
|
||||
|
||||
const onChangeHandler = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = event.target.value;
|
||||
if (newValue && newValue !== "") {
|
||||
toggleIsListOpen(true);
|
||||
@@ -175,7 +203,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
toggleIsListOpen(false);
|
||||
}
|
||||
virtualFocus.moveFocusToTop();
|
||||
onChange(event);
|
||||
onChange(newValue);
|
||||
},
|
||||
[filteredOptions.length, virtualFocus, onChange, toggleIsListOpen],
|
||||
);
|
||||
@@ -184,10 +212,11 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
{...rest}
|
||||
{...omit(inputProps, ["aria-invalid"])}
|
||||
ref={ref}
|
||||
ref={mergedRefs}
|
||||
value={value}
|
||||
onBlur={() => virtualFocus.moveFocusToTop()}
|
||||
onChange={onChangeHandler}
|
||||
onClick={() => value !== searchTerm && onChange(value)}
|
||||
onInput={onChangeHandler}
|
||||
type="text"
|
||||
role="combobox"
|
||||
onKeyUp={handleKeyUp}
|
||||
|
||||
@@ -24,6 +24,7 @@ export const InputController = forwardRef<
|
||||
| "size"
|
||||
| "onClear"
|
||||
| "value"
|
||||
| "disabled"
|
||||
>
|
||||
>((props, ref) => {
|
||||
const {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { UNSAFE_Combobox } from "../index";
|
||||
const options = [
|
||||
"banana",
|
||||
"apple",
|
||||
"apple pie",
|
||||
"tangerine",
|
||||
"pear",
|
||||
"grape",
|
||||
@@ -71,86 +72,193 @@ describe("Render combobox", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("Should show loading icon when loading (used for async search)", async () => {
|
||||
render(<App options={[]} isListOpen isLoading />);
|
||||
describe("Combobox state-handling", () => {
|
||||
test("Should show loading icon when loading (used for async search)", async () => {
|
||||
render(<App options={[]} isListOpen isLoading />);
|
||||
|
||||
expect(await screen.findByText("Søker...")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
expect(await screen.findByText("Søker...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("Combobox state-handling", () => {
|
||||
test("Should not select previous focused element when closes", async () => {
|
||||
render(<App options={options} />);
|
||||
test("Should not select previous focused element when closes", async () => {
|
||||
render(<App options={options} />);
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(
|
||||
screen.getByRole("combobox", { name: "Hva er dine favorittfrukter?" }),
|
||||
await act(async () => {
|
||||
await userEvent.click(
|
||||
screen.getByRole("combobox", {
|
||||
name: "Hva er dine favorittfrukter?",
|
||||
}),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await userEvent.type(
|
||||
screen.getByRole("combobox", {
|
||||
name: "Hva er dine favorittfrukter?",
|
||||
}),
|
||||
"ban",
|
||||
);
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
await userEvent.keyboard("{ArrowUp}");
|
||||
await userEvent.keyboard("{Enter}");
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("button", { name: "banana slett" })).toBeNull();
|
||||
});
|
||||
|
||||
test("Should reset list when resetting input (ESC)", async () => {
|
||||
render(<App options={options} />);
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(
|
||||
screen.getByRole("combobox", {
|
||||
name: "Hva er dine favorittfrukter?",
|
||||
}),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await userEvent.type(
|
||||
screen.getByRole("combobox", {
|
||||
name: "Hva er dine favorittfrukter?",
|
||||
}),
|
||||
"apple",
|
||||
);
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
await userEvent.keyboard("{Escape}");
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByRole("option", { name: "banana" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("Should handle complex options with label and value", async () => {
|
||||
const onToggleSelected = vi.fn();
|
||||
render(
|
||||
<App
|
||||
options={[
|
||||
{ label: "Hjelpemidler [HJE]", value: "HJE" },
|
||||
{ label: "Oppfølging [OPP]", value: "OPP" },
|
||||
{ label: "Sykepenger [SYK]", value: "SYK" },
|
||||
{ label: "Sykemelding [SYM]", value: "SYM" },
|
||||
]}
|
||||
onToggleSelected={onToggleSelected}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await userEvent.type(
|
||||
screen.getByRole("combobox", { name: "Hva er dine favorittfrukter?" }),
|
||||
"ban",
|
||||
);
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
await userEvent.keyboard("{ArrowUp}");
|
||||
await userEvent.keyboard("{Enter}");
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("button", { name: "banana slett" })).toBeNull();
|
||||
});
|
||||
|
||||
test("Should reset list when resetting input (ESC)", async () => {
|
||||
render(<App options={options} />);
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(
|
||||
screen.getByRole("combobox", { name: "Hva er dine favorittfrukter?" }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await userEvent.type(
|
||||
screen.getByRole("combobox", { name: "Hva er dine favorittfrukter?" }),
|
||||
"apple",
|
||||
);
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
await userEvent.keyboard("{Escape}");
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByRole("option", { name: "banana" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("Should handle complex options with label and value", async () => {
|
||||
const onToggleSelected = vi.fn();
|
||||
render(
|
||||
<App
|
||||
options={[
|
||||
{ label: "Hjelpemidler [HJE]", value: "HJE" },
|
||||
{ label: "Oppfølging [OPP]", value: "OPP" },
|
||||
{ label: "Sykepenger [SYK]", value: "SYK" },
|
||||
{ label: "Sykemelding [SYM]", value: "SYM" },
|
||||
]}
|
||||
onToggleSelected={onToggleSelected}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
const bananaOption = screen.getByRole("option", {
|
||||
name: "Hjelpemidler [HJE]",
|
||||
selected: false,
|
||||
});
|
||||
await act(async () => {
|
||||
await userEvent.click(bananaOption);
|
||||
});
|
||||
expect(onToggleSelected).toHaveBeenCalledWith("HJE", true, false);
|
||||
expect(
|
||||
screen.getByRole("option", {
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
const bananaOption = screen.getByRole("option", {
|
||||
name: "Hjelpemidler [HJE]",
|
||||
selected: true,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
selected: false,
|
||||
});
|
||||
await act(async () => {
|
||||
await userEvent.click(bananaOption);
|
||||
});
|
||||
expect(onToggleSelected).toHaveBeenCalledWith("HJE", true, false);
|
||||
expect(
|
||||
screen.getByRole("option", {
|
||||
name: "Hjelpemidler [HJE]",
|
||||
selected: true,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should trigger onChange for every character typed or removed", async () => {
|
||||
const onChange = vi.fn();
|
||||
const onToggleSelected = vi.fn();
|
||||
render(
|
||||
<App
|
||||
options={["Apple", "Orange", "Banana", "Lemon"]}
|
||||
onChange={onChange}
|
||||
onToggleSelected={onToggleSelected}
|
||||
shouldAutocomplete
|
||||
/>,
|
||||
);
|
||||
const combobox = screen.getByRole("combobox");
|
||||
expect(combobox).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(combobox);
|
||||
await userEvent.type(combobox, "Lemon");
|
||||
});
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, "L");
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, "Le");
|
||||
expect(onChange).toHaveBeenNthCalledWith(3, "Lem");
|
||||
expect(onChange).toHaveBeenNthCalledWith(4, "Lemo");
|
||||
expect(onChange).toHaveBeenNthCalledWith(5, "Lemon");
|
||||
});
|
||||
|
||||
test("should trigger onChange while typing and on accepting autocomplete suggestions", async () => {
|
||||
const onChange = vi.fn();
|
||||
const onToggleSelected = vi.fn();
|
||||
render(
|
||||
<App
|
||||
options={[
|
||||
"Hjelpemidler [HJE]",
|
||||
"Oppfølging [OPP]",
|
||||
"Sykepenger [SYK]",
|
||||
"Sykemelding [SYM]",
|
||||
]}
|
||||
onChange={onChange}
|
||||
onToggleSelected={onToggleSelected}
|
||||
shouldAutocomplete
|
||||
/>,
|
||||
);
|
||||
const combobox = screen.getByRole("combobox");
|
||||
expect(combobox).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(combobox);
|
||||
await userEvent.type(combobox, "Syke");
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
await userEvent.keyboard("{Enter}");
|
||||
});
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, "S");
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, "Sy");
|
||||
expect(onChange).toHaveBeenNthCalledWith(3, "Syk");
|
||||
expect(onChange).toHaveBeenNthCalledWith(4, "Syke");
|
||||
expect(onChange).toHaveBeenNthCalledWith(5, "Sykepenger [SYK]");
|
||||
expect(onChange).toHaveBeenCalledWith("");
|
||||
expect(onToggleSelected).toHaveBeenCalledOnce();
|
||||
expect(onToggleSelected).toHaveBeenCalledWith(
|
||||
"Sykepenger [SYK]",
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("search", () => {
|
||||
test("should find matched anywhere in the label", async () => {
|
||||
render(<App options={options} />);
|
||||
|
||||
const combobox = screen.getByRole("combobox", {
|
||||
name: "Hva er dine favorittfrukter?",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(combobox);
|
||||
|
||||
await userEvent.type(combobox, "p");
|
||||
});
|
||||
|
||||
const searchHits = [
|
||||
"apple",
|
||||
"apple pie",
|
||||
"pear",
|
||||
"grape",
|
||||
"passion fruit",
|
||||
"pineapple",
|
||||
"grape fruit",
|
||||
];
|
||||
searchHits.forEach((label) => {
|
||||
expect(screen.getByRole("option", { name: label })).toBeInTheDocument();
|
||||
});
|
||||
screen.getAllByRole("option").forEach((option) => {
|
||||
expect(
|
||||
option.textContent && searchHits.includes(option.textContent),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,21 @@
|
||||
import { Meta, StoryFn, StoryObj } from "@storybook/react";
|
||||
import { expect, fn, userEvent, within } from "@storybook/test";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { Meta, StoryFn } from "@storybook/react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "../../button";
|
||||
import { Chips } from "../../chips";
|
||||
import { VStack } from "../../layout/stack";
|
||||
import { Modal } from "../../modal";
|
||||
import { TextField } from "../textfield";
|
||||
import { ComboboxProps, UNSAFE_Combobox } from "./index";
|
||||
import { UNSAFE_Combobox } from "./index";
|
||||
|
||||
export default {
|
||||
title: "ds-react/Combobox",
|
||||
component: UNSAFE_Combobox,
|
||||
decorators: [(story) => <div style={{ width: "300px" }}>{story()}</div>],
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof UNSAFE_Combobox>;
|
||||
|
||||
type StoryObject = StoryObj<typeof UNSAFE_Combobox>;
|
||||
type StoryFunction = StoryFn<typeof UNSAFE_Combobox>;
|
||||
|
||||
const options = [
|
||||
@@ -41,8 +45,15 @@ Default.args = {
|
||||
isLoading: false,
|
||||
isMultiSelect: false,
|
||||
allowNewValues: false,
|
||||
onChange: console.log,
|
||||
};
|
||||
Default.argTypes = {
|
||||
description: {
|
||||
control: { type: "text" },
|
||||
},
|
||||
disabled: {
|
||||
control: { type: "boolean" },
|
||||
},
|
||||
isListOpen: {
|
||||
control: { type: "boolean" },
|
||||
},
|
||||
@@ -56,31 +67,36 @@ Default.argTypes = {
|
||||
},
|
||||
};
|
||||
|
||||
export const MultiSelect: StoryFunction = (props) => {
|
||||
export const MultiSelect: StoryFn = () => {
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>([
|
||||
"pear",
|
||||
"kiwi",
|
||||
]);
|
||||
return (
|
||||
<UNSAFE_Combobox
|
||||
id="combobox-with-multiselect"
|
||||
label="Komboboks - velg flere"
|
||||
options={props.options}
|
||||
isMultiSelect={props.isMultiSelect}
|
||||
size={props.size}
|
||||
options={options}
|
||||
isMultiSelect={true}
|
||||
selectedOptions={selectedOptions}
|
||||
onToggleSelected={(option, isSelected) =>
|
||||
isSelected
|
||||
? setSelectedOptions([...selectedOptions, option])
|
||||
: setSelectedOptions(selectedOptions.filter((o) => o !== option))
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
MultiSelect.args = {
|
||||
options,
|
||||
isMultiSelect: true,
|
||||
size: "medium",
|
||||
};
|
||||
|
||||
export const MultiSelectWithComplexOptions: StoryFunction = (props) => {
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
|
||||
export const MultiSelectWithComplexOptions: StoryFn = () => {
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>([
|
||||
"HJE",
|
||||
"OPP",
|
||||
]);
|
||||
return (
|
||||
<>
|
||||
<UNSAFE_Combobox
|
||||
{...props}
|
||||
options={props.options.map((option) => ({
|
||||
options={complexOptions.map((option) => ({
|
||||
...option,
|
||||
label: `${option.label} [${option.value}]`,
|
||||
}))}
|
||||
@@ -93,74 +109,81 @@ export const MultiSelectWithComplexOptions: StoryFunction = (props) => {
|
||||
: setSelectedOptions(selectedOptions.filter((o) => o !== value))
|
||||
}
|
||||
selectedOptions={selectedOptions}
|
||||
isMultiSelect
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
MultiSelectWithComplexOptions.args = {
|
||||
options: [
|
||||
{ label: "Hjelpemidler", value: "HJE" },
|
||||
{ label: "Oppfølging", value: "OPP" },
|
||||
{ label: "Sykepenger", value: "SYK" },
|
||||
{ label: "Sykemelding", value: "SYM" },
|
||||
{ label: "Foreldre- og svangerskapspenger", value: "FOR" },
|
||||
{ label: "Arbeidsavklaringspenger", value: "AAP" },
|
||||
{ label: "Uføretrygd", value: "UFO" },
|
||||
{ label: "Pensjon", value: "PEN" },
|
||||
{ label: "Barnetrygd", value: "BAR" },
|
||||
{ label: "Kontantstøtte", value: "KON" },
|
||||
{ label: "Bostøtte", value: "BOS" },
|
||||
{ label: "Barnebidrag", value: "BBI" },
|
||||
{ label: "Bidragsforskudd", value: "BIF" },
|
||||
{ label: "Grunn- og hjelpestønad", value: "GRU" },
|
||||
],
|
||||
isMultiSelect: true,
|
||||
size: "medium",
|
||||
};
|
||||
const complexOptions = [
|
||||
{ label: "Hjelpemidler", value: "HJE" },
|
||||
{ label: "Oppfølging", value: "OPP" },
|
||||
{ label: "Sykepenger", value: "SYK" },
|
||||
{ label: "Sykemelding", value: "SYM" },
|
||||
{ label: "Foreldre- og svangerskapspenger", value: "FOR" },
|
||||
{ label: "Arbeidsavklaringspenger", value: "AAP" },
|
||||
{ label: "Uføretrygd", value: "UFO" },
|
||||
{ label: "Pensjon", value: "PEN" },
|
||||
{ label: "Barnetrygd", value: "BAR" },
|
||||
{ label: "Kontantstøtte", value: "KON" },
|
||||
{ label: "Bostøtte", value: "BOS" },
|
||||
{ label: "Barnebidrag", value: "BBI" },
|
||||
{ label: "Bidragsforskudd", value: "BIF" },
|
||||
{ label: "Grunn- og hjelpestønad", value: "GRU" },
|
||||
];
|
||||
|
||||
export const WithAddNewOptions: StoryFunction = (props) => {
|
||||
export const WithAddNewOptions: StoryFn = ({ open }: { open?: boolean }) => {
|
||||
const [value, setValue] = useState<string | undefined>("hello");
|
||||
const comboboxRef = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<UNSAFE_Combobox
|
||||
id="combobox-with-add-new-options"
|
||||
label="Komboboks med mulighet for å legge til nye verdier"
|
||||
options={props.options}
|
||||
allowNewValues={props.allowNewValues}
|
||||
shouldAutocomplete={props.shouldAutocomplete}
|
||||
options={options}
|
||||
allowNewValues={true}
|
||||
shouldAutocomplete={true}
|
||||
value={value}
|
||||
onChange={(newValue) => setValue(newValue)}
|
||||
isListOpen={open ?? (comboboxRef.current ? true : undefined)}
|
||||
ref={comboboxRef}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
WithAddNewOptions.args = {
|
||||
options,
|
||||
allowNewValues: true,
|
||||
shouldAutocomplete: true,
|
||||
};
|
||||
|
||||
export const MultiSelectWithAddNewOptions: StoryFunction = (props) => {
|
||||
export const MultiSelectWithAddNewOptions: StoryFn = ({
|
||||
open,
|
||||
}: {
|
||||
open?: boolean;
|
||||
}) => {
|
||||
const [value, setValue] = useState<string | undefined>("world");
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>(["hello"]);
|
||||
const comboboxRef = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<UNSAFE_Combobox
|
||||
id="combobox-with-multiselect-and-add-new-options"
|
||||
isMultiSelect={props.isMultiSelect}
|
||||
isMultiSelect={true}
|
||||
label="Multiselect komboboks med mulighet for å legge til nye verdier"
|
||||
options={props.options}
|
||||
allowNewValues={props.allowNewValues}
|
||||
options={options}
|
||||
allowNewValues={true}
|
||||
value={value}
|
||||
selectedOptions={selectedOptions}
|
||||
onChange={(newValue) => setValue(newValue)}
|
||||
onToggleSelected={(option, isSelected) =>
|
||||
isSelected
|
||||
? setSelectedOptions([...selectedOptions, option])
|
||||
: setSelectedOptions(selectedOptions.filter((o) => o !== option))
|
||||
}
|
||||
isListOpen={open ?? (comboboxRef.current ? true : undefined)}
|
||||
ref={comboboxRef}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
MultiSelectWithAddNewOptions.args = {
|
||||
allowNewValues: true,
|
||||
isMultiSelect: true,
|
||||
options,
|
||||
shouldAutocomplete: false,
|
||||
};
|
||||
|
||||
export const MultiSelectWithExternalChips: StoryFn<{
|
||||
controlled: boolean;
|
||||
options: ComboboxProps["options"];
|
||||
}> = (props) => {
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
|
||||
export const MultiSelectWithExternalChips: StoryFn = () => {
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>([
|
||||
"kiwi",
|
||||
"pear",
|
||||
]);
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const toggleSelected = (option: string) =>
|
||||
@@ -184,16 +207,12 @@ export const MultiSelectWithExternalChips: StoryFn<{
|
||||
)}
|
||||
<UNSAFE_Combobox
|
||||
id="combobox-with-external-chips"
|
||||
options={props.options}
|
||||
options={options}
|
||||
selectedOptions={selectedOptions}
|
||||
onToggleSelected={(option) => toggleSelected(option)}
|
||||
isMultiSelect
|
||||
value={props.controlled ? value : undefined}
|
||||
onChange={(event) =>
|
||||
props.controlled
|
||||
? setValue(event?.currentTarget.value || "")
|
||||
: undefined
|
||||
}
|
||||
value={value}
|
||||
onChange={(newValue) => setValue(newValue || "")}
|
||||
label="Komboboks"
|
||||
size="medium"
|
||||
shouldShowSelectedOptions={false}
|
||||
@@ -202,58 +221,40 @@ export const MultiSelectWithExternalChips: StoryFn<{
|
||||
);
|
||||
};
|
||||
|
||||
MultiSelectWithExternalChips.args = {
|
||||
controlled: false,
|
||||
options,
|
||||
};
|
||||
|
||||
export const Loading: StoryFunction = (props) => (
|
||||
export const Loading: StoryFn = () => (
|
||||
<UNSAFE_Combobox
|
||||
id="combobox-with-loading-indicator"
|
||||
label="Komboboks (laster)"
|
||||
options={[]}
|
||||
selectedOptions={[]}
|
||||
isListOpen={props.isListOpen}
|
||||
isLoading={props.isLoading}
|
||||
isListOpen={true}
|
||||
isLoading={true}
|
||||
/>
|
||||
);
|
||||
|
||||
Loading.args = {
|
||||
isLoading: true,
|
||||
isListOpen: true,
|
||||
};
|
||||
|
||||
export const ComboboxWithNoHits: StoryFunction = (props) => {
|
||||
const [value, setValue] = useState(props.value);
|
||||
export const ComboboxWithNoHits: StoryFn = () => {
|
||||
const [value, setValue] = useState<string | undefined>("Orange");
|
||||
return (
|
||||
<UNSAFE_Combobox
|
||||
id="combobox-with-no-hits"
|
||||
label="Komboboks (uten søketreff)"
|
||||
options={props.options}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event?.currentTarget.value)}
|
||||
onChange={(newValue) => setValue(newValue)}
|
||||
isListOpen={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
ComboboxWithNoHits.args = {
|
||||
options,
|
||||
value: "Orange",
|
||||
};
|
||||
|
||||
export const Controlled: StoryFn<{
|
||||
value: string;
|
||||
options: string[];
|
||||
initialSelectedOptions: string[];
|
||||
}> = (props) => {
|
||||
const [value, setValue] = useState(props.value);
|
||||
const [selectedOptions, setSelectedOptions] = useState(
|
||||
props.initialSelectedOptions,
|
||||
);
|
||||
export const Controlled: StoryFn = () => {
|
||||
const [value, setValue] = useState<string>("apple");
|
||||
const [selectedOptions, setSelectedOptions] = useState([
|
||||
"passion fruit",
|
||||
"grape fruit",
|
||||
]);
|
||||
const filteredOptions = useMemo(
|
||||
() => props.options.filter((option) => option.includes(value)),
|
||||
[props.options, value],
|
||||
() => options.filter((option) => option.includes(value)),
|
||||
[value],
|
||||
);
|
||||
|
||||
const onToggleSelected = (option: string, isSelected: boolean) => {
|
||||
@@ -277,8 +278,8 @@ export const Controlled: StoryFn<{
|
||||
id="combobox-controlled"
|
||||
filteredOptions={filteredOptions}
|
||||
isMultiSelect
|
||||
options={props.options}
|
||||
onChange={(event) => setValue(event?.target.value || "")}
|
||||
options={options}
|
||||
onChange={(newValue) => setValue(newValue || "")}
|
||||
onToggleSelected={onToggleSelected}
|
||||
selectedOptions={selectedOptions}
|
||||
value={value}
|
||||
@@ -287,47 +288,82 @@ export const Controlled: StoryFn<{
|
||||
);
|
||||
};
|
||||
|
||||
Controlled.args = {
|
||||
value: "apple",
|
||||
options,
|
||||
initialSelectedOptions: ["passion fruit", "grape fruit"],
|
||||
export const ComboboxSizes = () => {
|
||||
const [multiSelectedOptions, setMultiSelectedOptions] = useState<string[]>([
|
||||
"pear",
|
||||
"kiwi",
|
||||
]);
|
||||
const [singleSelectedOption, setSingleSelectedOption] = useState<string[]>([
|
||||
"apple",
|
||||
]);
|
||||
return (
|
||||
<>
|
||||
<UNSAFE_Combobox
|
||||
label="Hva er dine favorittfrukter?"
|
||||
description="Medium single-select"
|
||||
options={options}
|
||||
selectedOptions={singleSelectedOption}
|
||||
onToggleSelected={(option, isSelected) =>
|
||||
isSelected
|
||||
? setSingleSelectedOption([option])
|
||||
: setSingleSelectedOption(
|
||||
singleSelectedOption.filter((o) => o !== option),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<br />
|
||||
<UNSAFE_Combobox
|
||||
label="Hva er dine favorittfrukter?"
|
||||
description="Small single-select"
|
||||
options={options}
|
||||
size="small"
|
||||
selectedOptions={singleSelectedOption}
|
||||
onToggleSelected={(option, isSelected) =>
|
||||
isSelected
|
||||
? setSingleSelectedOption([option])
|
||||
: setSingleSelectedOption(
|
||||
singleSelectedOption.filter((o) => o !== option),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<br />
|
||||
<UNSAFE_Combobox
|
||||
label="Hva er dine favorittfrukter?"
|
||||
description="Medium multiselect"
|
||||
options={options}
|
||||
isMultiSelect
|
||||
allowNewValues
|
||||
selectedOptions={multiSelectedOptions}
|
||||
onToggleSelected={(option, isSelected) =>
|
||||
isSelected
|
||||
? setMultiSelectedOptions([...multiSelectedOptions, option])
|
||||
: setMultiSelectedOptions(
|
||||
multiSelectedOptions.filter((o) => o !== option),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<br />
|
||||
<UNSAFE_Combobox
|
||||
label="Hva er dine favorittfrukter?"
|
||||
description="Small multiselect"
|
||||
options={options}
|
||||
isMultiSelect
|
||||
size="small"
|
||||
allowNewValues
|
||||
selectedOptions={multiSelectedOptions}
|
||||
onToggleSelected={(option, isSelected) =>
|
||||
isSelected
|
||||
? setMultiSelectedOptions([...multiSelectedOptions, option])
|
||||
: setMultiSelectedOptions(
|
||||
multiSelectedOptions.filter((o) => o !== option),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ComboboxSizes = () => (
|
||||
<>
|
||||
<UNSAFE_Combobox
|
||||
label="Hva er dine favorittfrukter?"
|
||||
description="Medium single-select"
|
||||
options={options}
|
||||
/>
|
||||
<br />
|
||||
<UNSAFE_Combobox
|
||||
label="Hva er dine favorittfrukter?"
|
||||
description="Small single-select"
|
||||
options={options}
|
||||
size="small"
|
||||
/>
|
||||
<br />
|
||||
<UNSAFE_Combobox
|
||||
label="Hva er dine favorittfrukter?"
|
||||
description="Medium multiselect"
|
||||
options={options}
|
||||
isMultiSelect
|
||||
allowNewValues
|
||||
/>
|
||||
<br />
|
||||
<UNSAFE_Combobox
|
||||
label="Hva er dine favorittfrukter?"
|
||||
description="Small multiselect"
|
||||
options={options}
|
||||
isMultiSelect
|
||||
size="small"
|
||||
allowNewValues
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
export const MaxSelectedOptions: StoryFunction = () => {
|
||||
export const MaxSelectedOptions: StoryFn = ({ open }: { open?: boolean }) => {
|
||||
const [value, setValue] = useState<string | undefined>("");
|
||||
const [selectedOptions, setSelectedOptions] = useState([
|
||||
options[0],
|
||||
@@ -348,15 +384,15 @@ export const MaxSelectedOptions: StoryFunction = () => {
|
||||
}
|
||||
isMultiSelect
|
||||
allowNewValues
|
||||
isListOpen={comboboxRef.current ? undefined : true}
|
||||
isListOpen={open ?? (comboboxRef.current ? undefined : true)}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event?.target.value)}
|
||||
onChange={(newValue) => setValue(newValue)}
|
||||
ref={comboboxRef}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithError: StoryFunction = (props) => {
|
||||
export const WithError: StoryFn = () => {
|
||||
const [hasSelectedValue, setHasSelectedValue] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
return (
|
||||
@@ -364,7 +400,7 @@ export const WithError: StoryFunction = (props) => {
|
||||
filteredOptions={isLoading ? [] : undefined}
|
||||
options={options}
|
||||
label="Hva er dine favorittfrukter?"
|
||||
error={!hasSelectedValue && props.error}
|
||||
error={!hasSelectedValue && "Du må velge en favorittfrukt."}
|
||||
isLoading={isLoading}
|
||||
onChange={() => {
|
||||
setIsLoading(true);
|
||||
@@ -374,350 +410,87 @@ export const WithError: StoryFunction = (props) => {
|
||||
/>
|
||||
);
|
||||
};
|
||||
WithError.args = {
|
||||
error: "Du må velge en favorittfrukt.",
|
||||
|
||||
export const InModal: StoryFn = () => {
|
||||
const modalRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
modalRef.current?.showModal();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => modalRef.current?.showModal()}>Åpne modal</Button>
|
||||
<Modal
|
||||
ref={modalRef}
|
||||
header={{ heading: "Overskrift" }}
|
||||
width="medium"
|
||||
style={{ height: "auto" }}
|
||||
>
|
||||
<Modal.Body style={{ height: "100% " }}>
|
||||
<p>
|
||||
Modalen skal ikke lukke seg om man trykker Escape mens virtuelt
|
||||
fokus er i Combobox sin nedtrekksliste eller om inputfeltet
|
||||
inneholder tekst.
|
||||
</p>
|
||||
<UNSAFE_Combobox
|
||||
options={options}
|
||||
label="Hva er dine favorittfrukter?"
|
||||
/>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const CancelInputTest: StoryObject = {
|
||||
render: () => {
|
||||
return (
|
||||
<UNSAFE_Combobox options={options} label="Hva er dine favorittfrukter?" />
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
|
||||
|
||||
userEvent.click(input);
|
||||
await userEvent.type(input, "apple", { delay: 200 });
|
||||
await sleep(1000);
|
||||
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(1000);
|
||||
userEvent.keyboard("{Escape}");
|
||||
await sleep(1000);
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(500);
|
||||
const banana = canvas.getByText("banana");
|
||||
userEvent.click(banana);
|
||||
},
|
||||
export const Disabled: StoryFn = () => {
|
||||
return (
|
||||
<UNSAFE_Combobox
|
||||
options={options}
|
||||
label="Hva er dine favorittfrukter?"
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const RemoveSelectedMultiSelectTest: StoryObject = {
|
||||
render: () => {
|
||||
return (
|
||||
<UNSAFE_Combobox
|
||||
options={options}
|
||||
label="Hva er dine favorittfrukter?"
|
||||
isMultiSelect
|
||||
/>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
|
||||
|
||||
userEvent.click(input);
|
||||
await userEvent.type(input, "apple", { delay: 200 });
|
||||
await sleep(250);
|
||||
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{Enter}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{Escape}");
|
||||
await sleep(250);
|
||||
|
||||
userEvent.click(input);
|
||||
await userEvent.type(input, "banana", { delay: 200 });
|
||||
await sleep(250);
|
||||
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{Enter}");
|
||||
await sleep(250);
|
||||
|
||||
const appleSlett = canvas.getByLabelText("apple slett");
|
||||
appleSlett.focus();
|
||||
await sleep(250);
|
||||
userEvent.click(appleSlett);
|
||||
await sleep(250);
|
||||
const appleOption = canvas.getByRole("option", {
|
||||
name: "apple",
|
||||
selected: false,
|
||||
});
|
||||
expect(appleOption).toBeVisible();
|
||||
userEvent.keyboard("{Escape}");
|
||||
await sleep(250);
|
||||
expect(appleOption).not.toBeVisible();
|
||||
|
||||
const bananaSlett = canvas.getByLabelText("banana slett");
|
||||
expect(bananaSlett).toBeInTheDocument();
|
||||
const appleSlettAgain = canvas.queryByLabelText("apple slett");
|
||||
expect(appleSlettAgain).not.toBeInTheDocument();
|
||||
},
|
||||
export const Chromatic: StoryFn = () => {
|
||||
const H2 = (props: { children: string; style?: React.CSSProperties }) => (
|
||||
<h2 style={{ marginBottom: "-0.25rem", ...props.style }}>
|
||||
{props.children}
|
||||
</h2>
|
||||
);
|
||||
return (
|
||||
<VStack gap="2">
|
||||
<H2>MultiSelect</H2>
|
||||
<MultiSelect />
|
||||
<H2>ComboboxWithNoHits</H2>
|
||||
<ComboboxWithNoHits />
|
||||
<H2 style={{ marginTop: "5rem" }}>MultiSelectWithComplexOptions</H2>
|
||||
<MultiSelectWithComplexOptions />
|
||||
<H2>WithAddNewOptions</H2>
|
||||
<WithAddNewOptions open />
|
||||
<H2 style={{ marginTop: "5rem" }}>MultiSelectWithAddNewOptions</H2>
|
||||
<MultiSelectWithAddNewOptions open />
|
||||
<H2 style={{ marginTop: "5rem" }}>MultiSelectWithExternalChips</H2>
|
||||
<MultiSelectWithExternalChips />
|
||||
<H2>Loading</H2>
|
||||
<Loading />
|
||||
<H2 style={{ marginTop: "5rem" }}>Controlled Input Value</H2>
|
||||
<Controlled />
|
||||
<H2>ComboboxSizes</H2>
|
||||
<ComboboxSizes />
|
||||
<H2>MaxSelectedOptions</H2>
|
||||
<MaxSelectedOptions open />
|
||||
<H2 style={{ marginTop: "20rem" }}>WithError</H2>
|
||||
<WithError />
|
||||
<H2>Disabled</H2>
|
||||
<Disabled />
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export const AllowNewValuesTest: StoryObject = {
|
||||
render: () => {
|
||||
return (
|
||||
<UNSAFE_Combobox
|
||||
options={options}
|
||||
label="Hva er dine favorittfrukter?"
|
||||
allowNewValues
|
||||
/>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
|
||||
|
||||
userEvent.click(input);
|
||||
await userEvent.type(input, "aaa", { delay: 200 });
|
||||
await sleep(250);
|
||||
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{Enter}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{Escape}");
|
||||
await sleep(250);
|
||||
|
||||
const invalidSelect = canvas.queryByLabelText("aaa slett");
|
||||
expect(invalidSelect).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const DisallowNewValuesTest: StoryObject = {
|
||||
render: () => {
|
||||
return (
|
||||
<UNSAFE_Combobox options={options} label="Hva er dine favorittfrukter?" />
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
|
||||
|
||||
userEvent.click(input);
|
||||
await userEvent.type(input, "aaa", { delay: 200 });
|
||||
await sleep(250);
|
||||
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{Enter}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{Escape}");
|
||||
await sleep(250);
|
||||
|
||||
const invalidSelect = canvas.queryByLabelText("aaa slett");
|
||||
expect(invalidSelect).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const TestThatCallbacksOnlyFireWhenExpected: StoryObj<{
|
||||
onChange: ReturnType<typeof fn>;
|
||||
onClear: ReturnType<typeof fn>;
|
||||
onToggleSelected: ReturnType<typeof fn>;
|
||||
}> = {
|
||||
args: {
|
||||
onChange: fn(),
|
||||
onClear: fn(),
|
||||
onToggleSelected: fn(),
|
||||
},
|
||||
render: (props) => {
|
||||
return (
|
||||
<UNSAFE_Combobox
|
||||
options={options}
|
||||
label="Hva er dine favorittfrukter?"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
args.onToggleSelected.mockClear();
|
||||
args.onClear.mockClear();
|
||||
args.onChange.mockClear();
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
|
||||
const searchWord = "tangerine";
|
||||
|
||||
userEvent.click(input);
|
||||
await userEvent.type(input, searchWord, { delay: 200 });
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{Enter}");
|
||||
await sleep(250);
|
||||
expect(args.onClear.mock.calls).toHaveLength(1);
|
||||
expect(args.onToggleSelected.mock.calls).toHaveLength(1);
|
||||
expect(args.onChange.mock.calls).toHaveLength(searchWord.length + 1);
|
||||
},
|
||||
};
|
||||
|
||||
export const TestCasingWhenAutoCompleting = {
|
||||
args: {
|
||||
onChange: fn(),
|
||||
onClear: fn(),
|
||||
onToggleSelected: fn(),
|
||||
},
|
||||
render: (props) => {
|
||||
return (
|
||||
<UNSAFE_Combobox
|
||||
options={["Camel Case", "lowercase", "UPPERCASE"]}
|
||||
label="Liker du best store eller små bokstaver?"
|
||||
shouldAutocomplete
|
||||
allowNewValues
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const input = canvas.getByRole<HTMLInputElement>("combobox");
|
||||
|
||||
// With exisiting option
|
||||
userEvent.click(input);
|
||||
await userEvent.type(input, "cAmEl CaSe", { delay: 250 });
|
||||
await sleep(250);
|
||||
expect(input.value).toBe("cAmEl CaSe");
|
||||
await userEvent.type(input, "{Enter}");
|
||||
await sleep(250);
|
||||
const chips = canvas.getAllByRole("list")[0];
|
||||
const selectedUpperCaseChip = within(chips).getAllByRole("listitem")[0];
|
||||
expect(selectedUpperCaseChip).toHaveTextContent("Camel Case"); // A weird issue is preventing the accessible name from being used in the test, even if it works in VoiceOver
|
||||
|
||||
// With custom option
|
||||
userEvent.click(input);
|
||||
await userEvent.type(input, "cAmEl{Backspace}", { delay: 250 });
|
||||
await sleep(250);
|
||||
expect(input.value).toBe("cAmEl");
|
||||
await userEvent.type(input, "{Enter}");
|
||||
await sleep(250);
|
||||
const selectedNewValueChip = within(chips).getAllByRole("listitem")[0];
|
||||
expect(selectedNewValueChip).toHaveTextContent("cAmEl"); // A weird issue is preventing the accessible name from being used in the test, even if it works in VoiceOver
|
||||
},
|
||||
};
|
||||
|
||||
export const TestHoverAndFocusSwitching: StoryObject = {
|
||||
render: () => {
|
||||
return (
|
||||
<UNSAFE_Combobox options={options} label="Hva er dine favorittfrukter?" />
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await sleep(500);
|
||||
|
||||
const getInput = () =>
|
||||
canvas.getByRole("combobox", {
|
||||
name: "Hva er dine favorittfrukter?",
|
||||
});
|
||||
|
||||
userEvent.click(getInput());
|
||||
expect(getInput().getAttribute("aria-expanded")).toEqual("false");
|
||||
expect(getInput().getAttribute("aria-activedescendant")).toBeNull();
|
||||
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(250);
|
||||
const bananaOption = canvas.getByRole("option", { name: "banana" });
|
||||
expect(getInput().getAttribute("aria-activedescendant")).toBe(
|
||||
bananaOption.getAttribute("id"),
|
||||
);
|
||||
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(250);
|
||||
const appleOption = canvas.getByRole("option", { name: "apple" });
|
||||
expect(getInput().getAttribute("aria-activedescendant")).toBe(
|
||||
appleOption.getAttribute("id"),
|
||||
);
|
||||
|
||||
userEvent.hover(bananaOption);
|
||||
await sleep(250);
|
||||
expect(getInput().getAttribute("aria-activedescendant")).toBe(
|
||||
bananaOption.getAttribute("id"),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
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();
|
||||
Chromatic.parameters = {
|
||||
chromatic: {
|
||||
disable: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,404 @@
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import { expect, fn, userEvent, within } from "@storybook/test";
|
||||
import React from "react";
|
||||
import { UNSAFE_Combobox } from "./index";
|
||||
|
||||
export default {
|
||||
title: "ds-react/Combobox/Tests",
|
||||
component: UNSAFE_Combobox,
|
||||
decorators: [(story) => <div style={{ width: "300px" }}>{story()}</div>],
|
||||
parameters: {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
} satisfies Meta<typeof UNSAFE_Combobox>;
|
||||
|
||||
type StoryObject = StoryObj<typeof UNSAFE_Combobox>;
|
||||
|
||||
const options = [
|
||||
"banana",
|
||||
"apple",
|
||||
"apple pie",
|
||||
"tangerine",
|
||||
"pear",
|
||||
"grape",
|
||||
"kiwi",
|
||||
"mango",
|
||||
"passion fruit",
|
||||
"pineapple",
|
||||
"strawberry",
|
||||
"watermelon",
|
||||
"grape fruit",
|
||||
];
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const CancelInput: StoryObject = {
|
||||
render: () => {
|
||||
return (
|
||||
<UNSAFE_Combobox options={options} label="Hva er dine favorittfrukter?" />
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
|
||||
|
||||
userEvent.click(input);
|
||||
await userEvent.type(input, "apple", { delay: 200 });
|
||||
await sleep(1000);
|
||||
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(1000);
|
||||
userEvent.keyboard("{Escape}");
|
||||
await sleep(1000);
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(500);
|
||||
const banana = canvas.getByText("banana");
|
||||
userEvent.click(banana);
|
||||
},
|
||||
};
|
||||
|
||||
export const RemoveSelectedMultiSelect: StoryObject = {
|
||||
render: () => {
|
||||
return (
|
||||
<UNSAFE_Combobox
|
||||
options={options}
|
||||
label="Hva er dine favorittfrukter?"
|
||||
isMultiSelect
|
||||
/>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
|
||||
|
||||
userEvent.click(input);
|
||||
await userEvent.type(input, "apple", { delay: 200 });
|
||||
await sleep(250);
|
||||
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{Enter}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{Escape}");
|
||||
await sleep(250);
|
||||
|
||||
userEvent.click(input);
|
||||
await userEvent.type(input, "banana", { delay: 200 });
|
||||
await sleep(250);
|
||||
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{Enter}");
|
||||
await sleep(250);
|
||||
|
||||
const appleSlett = canvas.getByLabelText("apple slett");
|
||||
appleSlett.focus();
|
||||
await sleep(250);
|
||||
userEvent.click(appleSlett);
|
||||
await sleep(250);
|
||||
const appleOption = canvas.getByRole("option", {
|
||||
name: "apple",
|
||||
selected: false,
|
||||
});
|
||||
expect(appleOption).toBeVisible();
|
||||
userEvent.keyboard("{Escape}");
|
||||
await sleep(250);
|
||||
expect(appleOption).not.toBeVisible();
|
||||
|
||||
const bananaSlett = canvas.getByLabelText("banana slett");
|
||||
expect(bananaSlett).toBeInTheDocument();
|
||||
const appleSlettAgain = canvas.queryByLabelText("apple slett");
|
||||
expect(appleSlettAgain).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const AllowNewValuesMultiSelect: StoryObject = {
|
||||
render: () => {
|
||||
return (
|
||||
<UNSAFE_Combobox
|
||||
isMultiSelect={true}
|
||||
options={options}
|
||||
label="Hva er dine favorittfrukter?"
|
||||
allowNewValues
|
||||
/>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
|
||||
|
||||
userEvent.click(input);
|
||||
await userEvent.type(input, "aaa", { delay: 200 });
|
||||
await sleep(250);
|
||||
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{Enter}");
|
||||
await sleep(250);
|
||||
|
||||
const invalidSelect = canvas.queryByLabelText("aaa slett");
|
||||
expect(invalidSelect).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const AllowNewValuesSingleSelect: StoryObject = {
|
||||
render: () => {
|
||||
return (
|
||||
<UNSAFE_Combobox
|
||||
options={options}
|
||||
label="Hva er dine favorittfrukter?"
|
||||
allowNewValues
|
||||
/>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
|
||||
|
||||
userEvent.click(input);
|
||||
await userEvent.type(input, "aaa", { delay: 200 });
|
||||
await sleep(250);
|
||||
expect(
|
||||
canvas.getByRole("option", { name: "Legg til “aaa”" }),
|
||||
).toBeVisible();
|
||||
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{Enter}");
|
||||
await sleep(250);
|
||||
|
||||
const invalidSelect = canvas.queryByLabelText("aaa slett");
|
||||
expect(invalidSelect).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const DisallowNewValues: StoryObject = {
|
||||
render: () => {
|
||||
return (
|
||||
<UNSAFE_Combobox options={options} label="Hva er dine favorittfrukter?" />
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
|
||||
|
||||
userEvent.click(input);
|
||||
await userEvent.type(input, "aaa", { delay: 200 });
|
||||
await sleep(250);
|
||||
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{Enter}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{Escape}");
|
||||
await sleep(250);
|
||||
|
||||
const invalidSelect = canvas.queryByLabelText("aaa slett");
|
||||
expect(invalidSelect).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const CallbacksOnlyFireWhenExpected: StoryObj<{
|
||||
onChange: ReturnType<typeof fn>;
|
||||
onClear: ReturnType<typeof fn>;
|
||||
onToggleSelected: ReturnType<typeof fn>;
|
||||
}> = {
|
||||
args: {
|
||||
onChange: fn(),
|
||||
onClear: fn(),
|
||||
onToggleSelected: fn(),
|
||||
},
|
||||
render: (props) => {
|
||||
return (
|
||||
<UNSAFE_Combobox
|
||||
options={options}
|
||||
label="Hva er dine favorittfrukter?"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
args.onToggleSelected.mockClear();
|
||||
args.onClear.mockClear();
|
||||
args.onChange.mockClear();
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
|
||||
const searchWord = "tangerine";
|
||||
|
||||
userEvent.click(input);
|
||||
await userEvent.type(input, searchWord, { delay: 200 });
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{Enter}");
|
||||
await sleep(250);
|
||||
expect(args.onClear.mock.calls).toHaveLength(1);
|
||||
expect(args.onToggleSelected.mock.calls).toHaveLength(1);
|
||||
expect(args.onChange.mock.calls).toHaveLength(searchWord.length + 1);
|
||||
},
|
||||
};
|
||||
|
||||
export const CorrectCasingWhenAutoCompleting = {
|
||||
args: {
|
||||
onChange: fn(),
|
||||
onClear: fn(),
|
||||
onToggleSelected: fn(),
|
||||
},
|
||||
render: (props) => {
|
||||
return (
|
||||
<UNSAFE_Combobox
|
||||
options={["Camel Case", "lowercase", "UPPERCASE"]}
|
||||
label="Liker du best store eller små bokstaver?"
|
||||
shouldAutocomplete
|
||||
allowNewValues
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const input = canvas.getByRole<HTMLInputElement>("combobox");
|
||||
|
||||
// With exisiting option
|
||||
userEvent.click(input);
|
||||
await userEvent.type(input, "cAmEl CaSe", { delay: 250 });
|
||||
await sleep(250);
|
||||
expect(input.value).toBe("cAmEl CaSe");
|
||||
await userEvent.type(input, "{Enter}");
|
||||
await sleep(250);
|
||||
const chips = canvas.getAllByRole("list")[0];
|
||||
const selectedUpperCaseChip = within(chips).getAllByRole("listitem")[0];
|
||||
expect(selectedUpperCaseChip).toHaveTextContent("Camel Case"); // A weird issue is preventing the accessible name from being used in the test, even if it works in VoiceOver
|
||||
|
||||
// With custom option
|
||||
userEvent.click(input);
|
||||
await userEvent.type(input, "cAmEl{Backspace}", { delay: 250 });
|
||||
await sleep(250);
|
||||
expect(input.value).toBe("cAmEl");
|
||||
await userEvent.type(input, "{Enter}");
|
||||
await sleep(250);
|
||||
const selectedNewValueChip = within(chips).getAllByRole("listitem")[0];
|
||||
expect(selectedNewValueChip).toHaveTextContent("cAmEl"); // A weird issue is preventing the accessible name from being used in the test, even if it works in VoiceOver
|
||||
},
|
||||
};
|
||||
|
||||
export const HoverAndFocusSwitching: StoryObject = {
|
||||
render: () => {
|
||||
return (
|
||||
<UNSAFE_Combobox options={options} label="Hva er dine favorittfrukter?" />
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await sleep(500);
|
||||
|
||||
const getInput = () =>
|
||||
canvas.getByRole("combobox", {
|
||||
name: "Hva er dine favorittfrukter?",
|
||||
});
|
||||
|
||||
userEvent.click(getInput());
|
||||
expect(getInput().getAttribute("aria-expanded")).toEqual("false");
|
||||
expect(getInput().getAttribute("aria-activedescendant")).toBeNull();
|
||||
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(250);
|
||||
const bananaOption = canvas.getByRole("option", { name: "banana" });
|
||||
expect(getInput().getAttribute("aria-activedescendant")).toBe(
|
||||
bananaOption.getAttribute("id"),
|
||||
);
|
||||
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(250);
|
||||
const appleOption = canvas.getByRole("option", { name: "apple" });
|
||||
expect(getInput().getAttribute("aria-activedescendant")).toBe(
|
||||
appleOption.getAttribute("id"),
|
||||
);
|
||||
|
||||
userEvent.hover(bananaOption);
|
||||
await sleep(250);
|
||||
expect(getInput().getAttribute("aria-activedescendant")).toBe(
|
||||
bananaOption.getAttribute("id"),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const EnterNotSubmittingForm: 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();
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { ChangeEvent, InputHTMLAttributes } from "react";
|
||||
import React, { InputHTMLAttributes } from "react";
|
||||
import { FormFieldProps } from "../useFormField";
|
||||
|
||||
/**
|
||||
@@ -29,7 +29,10 @@ export type MaxSelected = {
|
||||
|
||||
export interface ComboboxProps
|
||||
extends FormFieldProps,
|
||||
Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "onChange" | "value"> {
|
||||
Omit<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
"size" | "onChange" | "value" | "defaultValue"
|
||||
> {
|
||||
/**
|
||||
* Combobox label.
|
||||
*/
|
||||
@@ -89,12 +92,9 @@ export interface ComboboxProps
|
||||
/**
|
||||
* Callback function triggered whenever the value of the input field is triggered.
|
||||
*
|
||||
* @param event
|
||||
* @param value The value after change
|
||||
*/
|
||||
onChange?: (
|
||||
event: ChangeEvent<HTMLInputElement> | null,
|
||||
value?: string,
|
||||
) => void;
|
||||
onChange?: (value: string) => void;
|
||||
/**
|
||||
* Callback function triggered whenever the input field is cleared.
|
||||
*
|
||||
@@ -156,4 +156,8 @@ export interface ComboboxProps
|
||||
* This converts the input to a controlled input, so you have to use onChange to update the value.
|
||||
*/
|
||||
value?: string;
|
||||
/**
|
||||
* Initial value of the input field. Only works when the input is uncontrolled.
|
||||
*/
|
||||
defaultValue?: string;
|
||||
}
|
||||
@@ -10,8 +10,8 @@ export default {
|
||||
size: {
|
||||
control: {
|
||||
type: "radio",
|
||||
options: ["medium", "small"],
|
||||
},
|
||||
options: ["medium", "small"],
|
||||
},
|
||||
error: {
|
||||
type: "string",
|
||||
@@ -20,7 +20,7 @@ export default {
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} as Meta;
|
||||
} satisfies Meta<typeof ConfirmationPanel>;
|
||||
|
||||
type Story = StoryObj<typeof ConfirmationPanel>;
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ export default {
|
||||
size: {
|
||||
control: {
|
||||
type: "radio",
|
||||
options: ["medium", "small"],
|
||||
},
|
||||
options: ["medium", "small"],
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
|
||||
@@ -11,8 +11,8 @@ export default {
|
||||
size: {
|
||||
control: {
|
||||
type: "radio",
|
||||
options: ["medium", "small"],
|
||||
},
|
||||
options: ["medium", "small"],
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import { Meta, StoryFn, StoryObj } from "@storybook/react";
|
||||
import { fireEvent, within } from "@storybook/test";
|
||||
import React from "react";
|
||||
import { ImageIcon } from "@navikt/aksel-icons";
|
||||
@@ -48,8 +48,8 @@ export const Default: StoryObj<typeof FileUpload.Dropzone> = {
|
||||
parameters: { chromatic: { disable: true } },
|
||||
};
|
||||
|
||||
export const States: StoryObj = {
|
||||
render: () => (
|
||||
export const States: StoryFn = () => {
|
||||
return (
|
||||
<div>
|
||||
<h2>Disabled</h2>
|
||||
<FileUpload.Dropzone
|
||||
@@ -78,7 +78,7 @@ export const States: StoryObj = {
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
};
|
||||
States.play = ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
@@ -86,8 +86,8 @@ States.play = ({ canvasElement }) => {
|
||||
fireEvent.dragEnter(button);
|
||||
};
|
||||
|
||||
export const Translation: StoryObj = {
|
||||
render: () => (
|
||||
export const Translation: StoryFn = () => {
|
||||
return (
|
||||
<div>
|
||||
<h2>Single file</h2>
|
||||
<FileUpload.Dropzone
|
||||
@@ -119,5 +119,5 @@ export const Translation: StoryObj = {
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import { Meta, StoryFn } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { FileItem, UNSAFE_FileUpload as FileUpload } from ".";
|
||||
import { VStack } from "../../layout/stack";
|
||||
@@ -28,8 +28,8 @@ const filePptx = new File(["abc"], "file.pptx");
|
||||
const fileWebp = new File(["abc"], "file.webp");
|
||||
const fileDocx = new File(["abc"], "file.docx");
|
||||
|
||||
export const Icons: StoryObj<typeof FileUpload.Item> = {
|
||||
render: () => (
|
||||
export const Icons: StoryFn = () => {
|
||||
return (
|
||||
<FileUpload>
|
||||
<VStack gap="5" as="ul">
|
||||
<FileUpload.Item file={fileTxt} as="li" />
|
||||
@@ -44,11 +44,11 @@ export const Icons: StoryObj<typeof FileUpload.Item> = {
|
||||
<FileUpload.Item file={filePptx} as="li" />
|
||||
</VStack>
|
||||
</FileUpload>
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const States: StoryObj<typeof FileUpload.Item> = {
|
||||
render: () => (
|
||||
export const States: StoryFn = () => {
|
||||
return (
|
||||
<div>
|
||||
<h2>Error</h2>
|
||||
<FileUpload.Item file={fileTxt} error="Plain error" />
|
||||
@@ -116,11 +116,11 @@ export const States: StoryObj<typeof FileUpload.Item> = {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const Download: StoryObj = {
|
||||
render: () => (
|
||||
export const Download: StoryFn = () => {
|
||||
return (
|
||||
<VStack gap="5">
|
||||
<FileUpload.Item
|
||||
file={{
|
||||
@@ -144,11 +144,11 @@ export const Download: StoryObj = {
|
||||
/>
|
||||
<FileUpload.Item file={fileTxt} />
|
||||
</VStack>
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const Description: StoryObj = {
|
||||
render: () => (
|
||||
export const Description: StoryFn = () => {
|
||||
return (
|
||||
<VStack gap="5">
|
||||
<FileUpload.Item file={fileTxt} description="Mottat 11.11.11" />
|
||||
<FileUpload.Item
|
||||
@@ -156,5 +156,5 @@ export const Description: StoryObj = {
|
||||
description="Lorem ipsum dolor, sit amet consectetur adipisicing elit. Enim officiis nisi beatae quia non iste nihil accusantium nobis amet, officia eius, repellendus a cupiditate, commodi eos! Quis illum repudiandae exercitationem."
|
||||
/>
|
||||
</VStack>
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, StoryFn, StoryObj } from "@storybook/react";
|
||||
import { Meta, StoryFn } from "@storybook/react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { UploadIcon } from "@navikt/aksel-icons";
|
||||
import { UNSAFE_FileUpload as FileUpload, FileUploadItemProps } from ".";
|
||||
@@ -11,6 +11,16 @@ import {
|
||||
FileRejected,
|
||||
FileRejectionReason,
|
||||
} from "./FileUpload.types";
|
||||
import {
|
||||
States as DropzoneStates,
|
||||
Translation as DropzoneTranslation,
|
||||
} from "./file-upload-dropzone.stories";
|
||||
import {
|
||||
Description as ItemDescription,
|
||||
Download as ItemDownload,
|
||||
Icons as ItemIcons,
|
||||
States as ItemStates,
|
||||
} from "./file-upload-item.stories";
|
||||
|
||||
const meta: Meta<typeof FileUpload.Dropzone> = {
|
||||
title: "ds-react/FileUpload",
|
||||
@@ -180,7 +190,7 @@ export const Single: StoryFn = () => {
|
||||
};
|
||||
Single.parameters = { chromatic: { disable: true } };
|
||||
|
||||
export const Translation = () => (
|
||||
export const Translation: StoryFn = () => (
|
||||
<FileUpload
|
||||
translations={{
|
||||
dropzone: {
|
||||
@@ -230,19 +240,45 @@ export const Translation = () => (
|
||||
</FileUpload>
|
||||
);
|
||||
|
||||
export const TriggerWithButton: StoryObj<typeof FileUpload.Trigger> = {
|
||||
render: (props) => {
|
||||
return (
|
||||
<FileUpload.Trigger {...props} onSelect={console.log}>
|
||||
<Button variant="secondary" icon={<UploadIcon aria-hidden />}>
|
||||
Last opp filer
|
||||
</Button>
|
||||
</FileUpload.Trigger>
|
||||
);
|
||||
},
|
||||
args: {
|
||||
multiple: true,
|
||||
accept: "",
|
||||
maxSizeInBytes: 0,
|
||||
},
|
||||
export const TriggerWithButton: StoryFn = (props) => (
|
||||
<FileUpload.Trigger {...props} onSelect={console.log}>
|
||||
<Button variant="secondary" icon={<UploadIcon aria-hidden />}>
|
||||
Last opp filer
|
||||
</Button>
|
||||
</FileUpload.Trigger>
|
||||
);
|
||||
|
||||
TriggerWithButton.args = {
|
||||
multiple: true,
|
||||
accept: "",
|
||||
maxSizeInBytes: 0,
|
||||
};
|
||||
|
||||
export const Chromatic: StoryFn = () => (
|
||||
<div>
|
||||
<h2>Default</h2>
|
||||
<Default />
|
||||
<h2>Single</h2>
|
||||
<Single />
|
||||
<h2>Translation</h2>
|
||||
<Translation />
|
||||
<h2>TriggerWithButton</h2>
|
||||
<TriggerWithButton />
|
||||
<h2>DropzoneStates</h2>
|
||||
<DropzoneStates />
|
||||
<h2>DropzoneTranslation</h2>
|
||||
<DropzoneTranslation />
|
||||
<h2>ItemDescription</h2>
|
||||
<ItemDescription />
|
||||
<h2>ItemDownload</h2>
|
||||
<ItemDownload />
|
||||
<h2>ItemIcons</h2>
|
||||
<ItemIcons />
|
||||
<h2>ItemStates</h2>
|
||||
<ItemStates />
|
||||
</div>
|
||||
);
|
||||
|
||||
Chromatic.parameters = {
|
||||
chromatic: { disable: false },
|
||||
};
|
||||
@@ -316,50 +316,6 @@ export const Empty: StoryFn<typeof 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>
|
||||
@@ -386,3 +342,51 @@ export const CommaSeparated: StoryFn<typeof FormSummary> = () => (
|
||||
</FormSummary.Answers>
|
||||
</FormSummary>
|
||||
);
|
||||
|
||||
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>
|
||||
<h2>Comma Separated</h2>
|
||||
<CommaSeparated>{null}</CommaSeparated>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
chromatic: {
|
||||
disable: false,
|
||||
modes: {
|
||||
default: {
|
||||
viewport: {
|
||||
width: 620,
|
||||
},
|
||||
},
|
||||
sm: {
|
||||
viewport: {
|
||||
width: 479,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -14,14 +14,14 @@ export default {
|
||||
size: {
|
||||
control: {
|
||||
type: "radio",
|
||||
options: ["medium", "small"],
|
||||
},
|
||||
options: ["medium", "small"],
|
||||
},
|
||||
variant: {
|
||||
control: {
|
||||
type: "radio",
|
||||
options: ["primary", "secondary", "simple"],
|
||||
},
|
||||
options: ["primary", "secondary", "simple"],
|
||||
},
|
||||
error: {
|
||||
control: {
|
||||
@@ -32,7 +32,7 @@ export default {
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} as Meta;
|
||||
} satisfies Meta<typeof Search>;
|
||||
|
||||
type Story = StoryObj<typeof Search>;
|
||||
|
||||
|
||||
@@ -10,14 +10,14 @@ export default {
|
||||
size: {
|
||||
control: {
|
||||
type: "radio",
|
||||
options: ["medium", "small"],
|
||||
},
|
||||
options: ["medium", "small"],
|
||||
},
|
||||
position: {
|
||||
control: {
|
||||
type: "radio",
|
||||
options: ["right", "left"],
|
||||
},
|
||||
options: ["right", "left"],
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
|
||||
@@ -9,8 +9,8 @@ export default {
|
||||
size: {
|
||||
control: {
|
||||
type: "radio",
|
||||
options: ["medium", "small"],
|
||||
},
|
||||
options: ["medium", "small"],
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
@@ -28,7 +28,7 @@ export default {
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} as Meta;
|
||||
} satisfies Meta<typeof TextField>;
|
||||
|
||||
type Story = StoryObj<typeof TextField>;
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { Meta } from "@storybook/react";
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { InformationIcon } from "@navikt/aksel-icons";
|
||||
import { VStack } from "../layout/stack";
|
||||
import { BodyLong } from "../typography";
|
||||
import GuidePanel from "./GuidePanel";
|
||||
|
||||
type Story = StoryObj<typeof GuidePanel>;
|
||||
|
||||
export default {
|
||||
title: "ds-react/GuidePanel",
|
||||
component: GuidePanel,
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof GuidePanel>;
|
||||
|
||||
const panelText = `Sit sint eu dolore reprehenderit exercitation labore aute anim sit
|
||||
@@ -15,29 +20,30 @@ adipisicing proident. Tempor ipsum ea cupidatat qui esse do veniam
|
||||
cupidatat. Excepteur irure reprehenderit esse tempor nisi duis qui ea
|
||||
enim id.`;
|
||||
|
||||
export const Default = {
|
||||
render: (props: { poster: boolean; colorOverride: boolean }) => {
|
||||
const style = props.colorOverride
|
||||
? {
|
||||
"--ac-guide-panel-illustration-bg": "var(--a-purple-200)",
|
||||
"--ac-guide-panel-border": "var(--a-purple-400)",
|
||||
}
|
||||
: {};
|
||||
export const Default: StoryObj<{ poster?: boolean; colorOverride?: boolean }> =
|
||||
{
|
||||
render: (props) => {
|
||||
const style = props.colorOverride
|
||||
? {
|
||||
"--ac-guide-panel-illustration-bg": "var(--a-purple-200)",
|
||||
"--ac-guide-panel-border": "var(--a-purple-400)",
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<GuidePanel style={style} poster={props?.poster}>
|
||||
{panelText}
|
||||
</GuidePanel>
|
||||
);
|
||||
},
|
||||
return (
|
||||
<GuidePanel style={style} poster={props?.poster}>
|
||||
{panelText}
|
||||
</GuidePanel>
|
||||
);
|
||||
},
|
||||
|
||||
args: {
|
||||
poster: false,
|
||||
colorOverride: false,
|
||||
},
|
||||
};
|
||||
args: {
|
||||
poster: false,
|
||||
colorOverride: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const PosterVariants = {
|
||||
export const PosterVariants: Story = {
|
||||
render: () => (
|
||||
<VStack gap="6" align="start">
|
||||
<GuidePanel>
|
||||
@@ -58,33 +64,69 @@ export const PosterVariants = {
|
||||
},
|
||||
};
|
||||
|
||||
export const ColorOverride = () => (
|
||||
<GuidePanel
|
||||
style={{
|
||||
"--ac-guide-panel-illustration-bg": "var(--a-purple-200)",
|
||||
"--ac-guide-panel-border": "var(--a-purple-400)",
|
||||
}}
|
||||
>
|
||||
{panelText}
|
||||
</GuidePanel>
|
||||
);
|
||||
export const ColorOverride: Story = {
|
||||
render: () => (
|
||||
<GuidePanel
|
||||
style={{
|
||||
"--ac-guide-panel-illustration-bg": "var(--a-purple-200)",
|
||||
"--ac-guide-panel-border": "var(--a-purple-400)",
|
||||
}}
|
||||
>
|
||||
{panelText}
|
||||
</GuidePanel>
|
||||
),
|
||||
};
|
||||
|
||||
export const Content = () => (
|
||||
<GuidePanel>
|
||||
<BodyLong spacing>
|
||||
Duis et ex ad magna nostrud ut officia nulla cillum commodo sint irure
|
||||
elit nulla. Ad proident nulla ex sunt exercitation sunt Lorem non laboris
|
||||
ea ex cillum nulla consequat. Enim pariatur eiusmod quis est fugiat
|
||||
officia nostrud dolore occaecat nisi.
|
||||
</BodyLong>
|
||||
<BodyLong>
|
||||
Do esse magna nulla amet excepteur. Tempor laboris ipsum magna velit
|
||||
dolore nulla id ex mollit. Deserunt ut esse laboris pariatur tempor
|
||||
laborum veniam enim. Nisi deserunt officia minim enim.
|
||||
</BodyLong>
|
||||
</GuidePanel>
|
||||
);
|
||||
export const Content: Story = {
|
||||
render: () => (
|
||||
<GuidePanel>
|
||||
<BodyLong spacing>
|
||||
Duis et ex ad magna nostrud ut officia nulla cillum commodo sint irure
|
||||
elit nulla. Ad proident nulla ex sunt exercitation sunt Lorem non
|
||||
laboris ea ex cillum nulla consequat. Enim pariatur eiusmod quis est
|
||||
fugiat officia nostrud dolore occaecat nisi.
|
||||
</BodyLong>
|
||||
<BodyLong>
|
||||
Do esse magna nulla amet excepteur. Tempor laboris ipsum magna velit
|
||||
dolore nulla id ex mollit. Deserunt ut esse laboris pariatur tempor
|
||||
laborum veniam enim. Nisi deserunt officia minim enim.
|
||||
</BodyLong>
|
||||
</GuidePanel>
|
||||
),
|
||||
};
|
||||
|
||||
export const CustomIllustration = () => (
|
||||
<GuidePanel illustration={<InformationIcon />}>{panelText}</GuidePanel>
|
||||
);
|
||||
export const CustomIllustration: Story = {
|
||||
render: () => (
|
||||
<GuidePanel illustration={<InformationIcon />}>{panelText}</GuidePanel>
|
||||
),
|
||||
};
|
||||
|
||||
export const Chromatic: Story = {
|
||||
render: (props, context) => (
|
||||
<div>
|
||||
<div>
|
||||
<h2>Default</h2>
|
||||
{Default.render?.(Default.args || {}, context)}
|
||||
</div>
|
||||
<div>
|
||||
<h2>PosterVariants</h2>
|
||||
{PosterVariants.render?.(props, context)}
|
||||
</div>
|
||||
<div>
|
||||
<h2>ColorOverride</h2>
|
||||
{ColorOverride.render?.(props, context)}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Content</h2>
|
||||
{Content.render?.(props, context)}
|
||||
</div>
|
||||
<div>
|
||||
<h2>CustomIllustration</h2>
|
||||
{CustomIllustration.render?.(props, context)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, StoryFn, StoryObj } from "@storybook/react";
|
||||
import { Meta, StoryFn } from "@storybook/react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { BodyLong, Heading } from "../typography";
|
||||
import HelpText from "./HelpText";
|
||||
@@ -7,41 +7,42 @@ const meta: Meta<typeof HelpText> = {
|
||||
title: "ds-react/HelpText",
|
||||
component: HelpText,
|
||||
parameters: {
|
||||
chromatic: { delay: 300 },
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
|
||||
export const Default: StoryObj<typeof HelpText> = {
|
||||
render: (props) => (
|
||||
export const Default: StoryFn<typeof HelpText> = (props) => {
|
||||
return (
|
||||
<HelpText {...props}>Id ullamco excepteur elit fugiat labore.</HelpText>
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
args: {
|
||||
title: "Show tooltip",
|
||||
Default.args = {
|
||||
title: "Show tooltip",
|
||||
};
|
||||
|
||||
Default.argTypes = {
|
||||
placement: {
|
||||
control: { type: "radio" },
|
||||
options: [
|
||||
"top",
|
||||
"bottom",
|
||||
"right",
|
||||
"left",
|
||||
"top-start",
|
||||
"top-end",
|
||||
"bottom-start",
|
||||
"bottom-end",
|
||||
"right-start",
|
||||
"right-end",
|
||||
"left-start",
|
||||
"left-end",
|
||||
],
|
||||
},
|
||||
argTypes: {
|
||||
placement: {
|
||||
control: { type: "radio" },
|
||||
options: [
|
||||
"top",
|
||||
"bottom",
|
||||
"right",
|
||||
"left",
|
||||
"top-start",
|
||||
"top-end",
|
||||
"bottom-start",
|
||||
"bottom-end",
|
||||
"right-start",
|
||||
"right-end",
|
||||
"left-start",
|
||||
"left-end",
|
||||
],
|
||||
},
|
||||
strategy: {
|
||||
control: { type: "radio" },
|
||||
options: ["fixed", "absolute"],
|
||||
},
|
||||
strategy: {
|
||||
control: { type: "radio" },
|
||||
options: ["fixed", "absolute"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -89,3 +90,20 @@ export const WrapperClassName: StoryFn = () => (
|
||||
</BodyLong>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Chromatic = () => {
|
||||
return (
|
||||
<div>
|
||||
<h2>Default</h2>
|
||||
<Default>{null}</Default>
|
||||
<h2>Open</h2>
|
||||
<Open />
|
||||
<h2>WrapperClassName</h2>
|
||||
<WrapperClassName />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Chromatic.parameters = {
|
||||
chromatic: { disable: false, delay: 300 },
|
||||
};
|
||||
@@ -14,18 +14,10 @@ import InternalHeader from "./InternalHeader";
|
||||
export default {
|
||||
title: "ds-react/InternalHeader",
|
||||
component: InternalHeader,
|
||||
argTypes: {
|
||||
user: {
|
||||
control: {
|
||||
type: "radio",
|
||||
options: ["simple", "with description", "with dropdown"],
|
||||
},
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} as Meta;
|
||||
} satisfies Meta<typeof InternalHeader>;
|
||||
|
||||
type Story = StoryObj<typeof InternalHeader>;
|
||||
|
||||
@@ -126,6 +118,13 @@ export const Default = {
|
||||
args: {
|
||||
titleAsHeading: false,
|
||||
systemMenu: false,
|
||||
user: "simple",
|
||||
},
|
||||
argTypes: {
|
||||
user: {
|
||||
control: { type: "radio" },
|
||||
options: ["simple", "with description", "with dropdown"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ import { Bleed } from "./Bleed";
|
||||
export default {
|
||||
title: "ds-react/Primitives/Bleed",
|
||||
component: Bleed,
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof Bleed>;
|
||||
|
||||
export const Default = {
|
||||
@@ -393,3 +396,25 @@ export const ReflectivePadding = {
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
export const Chromatic = {
|
||||
render: () => (
|
||||
<VStack gap="4">
|
||||
<h2>Default</h2>
|
||||
<Default.render />
|
||||
<h2>AsChild</h2>
|
||||
<AsChild.render />
|
||||
<h2>Breakpoints</h2>
|
||||
<Breakpoints.render />
|
||||
<h2>Px</h2>
|
||||
<Px.render />
|
||||
<h2>Full</h2>
|
||||
<Full.render />
|
||||
<h2>ReflectivePadding</h2>
|
||||
<ReflectivePadding.render />
|
||||
</VStack>
|
||||
),
|
||||
parameters: {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
};
|
||||
@@ -92,7 +92,10 @@ export const HGrid: OverridableComponent<HGridProps, HTMLDivElement> =
|
||||
<Comp
|
||||
{...omit(rest, PRIMITIVE_PROPS)}
|
||||
ref={ref}
|
||||
className={cl("navds-hgrid", className)}
|
||||
className={cl("navds-hgrid", className, {
|
||||
"navds-hgrid-gap": gap,
|
||||
"navds-hgrid-align": align,
|
||||
})}
|
||||
style={styles}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -23,7 +23,7 @@ export default {
|
||||
control: { type: "radio" },
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
} satisfies Meta<typeof HGrid>;
|
||||
|
||||
type Story = StoryObj<typeof HGrid>;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useEffect } from "react";
|
||||
import bgColors from "@navikt/ds-tokens/src/colors-bg.json";
|
||||
import { Box } from "../box";
|
||||
import { HGrid } from "../grid";
|
||||
import { VStack } from "../stack";
|
||||
import Page from "./Page";
|
||||
import { widths } from "./parts/PageBlock";
|
||||
|
||||
@@ -11,6 +12,7 @@ const meta: Meta = {
|
||||
component: Page,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -204,12 +206,6 @@ WithDecorator.decorators = [
|
||||
},
|
||||
];
|
||||
|
||||
WithDecorator.parameters = {
|
||||
chromatic: {
|
||||
disable: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const OutsideBackground = () => {
|
||||
return (
|
||||
<Page
|
||||
@@ -269,3 +265,28 @@ function Footer({ width = "lg", gutters = false }: any) {
|
||||
</Page.Block>
|
||||
);
|
||||
}
|
||||
|
||||
export const Chromatic = () => {
|
||||
return (
|
||||
<VStack gap="2">
|
||||
<h2>Default</h2>
|
||||
<Default />
|
||||
<h2>BelowFold</h2>
|
||||
<BelowFold />
|
||||
<h2>Background</h2>
|
||||
<Background />
|
||||
<h2>ContentBlockPadding</h2>
|
||||
<ContentBlockPadding />
|
||||
<h2>Gutters</h2>
|
||||
<Gutters />
|
||||
<h2>OutsideBackground</h2>
|
||||
<OutsideBackground />
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
Chromatic.parameters = {
|
||||
chromatic: {
|
||||
disable: false,
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Meta } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { Hide } from ".";
|
||||
import { Tag } from "../../tag";
|
||||
@@ -7,9 +7,16 @@ import { VStack } from "../stack";
|
||||
export default {
|
||||
title: "ds-react/Primitives/Hide",
|
||||
component: Hide,
|
||||
parameters: {
|
||||
chromatic: {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Hide>;
|
||||
|
||||
export const Default = {
|
||||
type Story = StoryObj<typeof Hide>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<VStack gap="12">
|
||||
<VStack gap="2" align="center">
|
||||
@@ -44,7 +51,7 @@ export const Default = {
|
||||
),
|
||||
};
|
||||
|
||||
export const AsChild = {
|
||||
export const AsChild: Story = {
|
||||
render: () => (
|
||||
<VStack gap="12">
|
||||
<VStack gap="2" align="center">
|
||||
@@ -78,3 +85,19 @@ export const AsChild = {
|
||||
</VStack>
|
||||
),
|
||||
};
|
||||
|
||||
export const Chromatic: Story = {
|
||||
render: (props, context) => (
|
||||
<VStack gap="4">
|
||||
<h2>Default</h2>
|
||||
{Default.render?.(props, context)}
|
||||
<h2>AsChild</h2>
|
||||
{AsChild.render?.(props, context)}
|
||||
</VStack>
|
||||
),
|
||||
parameters: {
|
||||
chromatic: {
|
||||
disable: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Meta } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { Show } from ".";
|
||||
import { Tag } from "../../tag";
|
||||
@@ -7,9 +7,16 @@ import { VStack } from "../stack";
|
||||
export default {
|
||||
title: "ds-react/Primitives/Show",
|
||||
component: Show,
|
||||
parameters: {
|
||||
chromatic: {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Show>;
|
||||
|
||||
export const Default = {
|
||||
type Story = StoryObj<typeof Show>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<VStack gap="12">
|
||||
<VStack gap="2" align="center">
|
||||
@@ -44,7 +51,7 @@ export const Default = {
|
||||
),
|
||||
};
|
||||
|
||||
export const AsChild = {
|
||||
export const AsChild: Story = {
|
||||
render: () => (
|
||||
<VStack gap="12">
|
||||
<VStack gap="2" align="center">
|
||||
@@ -78,3 +85,19 @@ export const AsChild = {
|
||||
</VStack>
|
||||
),
|
||||
};
|
||||
|
||||
export const Chromatic: Story = {
|
||||
render: (props, context) => (
|
||||
<VStack gap="4">
|
||||
<h2>Default</h2>
|
||||
{Default.render?.(props, context)}
|
||||
<h2>AsChild</h2>
|
||||
{AsChild.render?.(props, context)}
|
||||
</VStack>
|
||||
),
|
||||
parameters: {
|
||||
chromatic: {
|
||||
disable: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -73,7 +73,7 @@ export const Stack: OverridableComponent<StackProps, HTMLDivElement> =
|
||||
children,
|
||||
className,
|
||||
as: Component = "div",
|
||||
align = "stretch",
|
||||
align,
|
||||
justify,
|
||||
wrap = true,
|
||||
gap,
|
||||
@@ -86,7 +86,6 @@ export const Stack: OverridableComponent<StackProps, HTMLDivElement> =
|
||||
) => {
|
||||
const style: React.CSSProperties = {
|
||||
..._style,
|
||||
"--__ac-stack-wrap": wrap ? "wrap" : "nowrap",
|
||||
...getResponsiveProps(`stack`, "gap", "spacing", gap),
|
||||
...getResponsiveValue(`stack`, "direction", direction),
|
||||
...getResponsiveValue(`stack`, "align", align),
|
||||
@@ -104,6 +103,11 @@ export const Stack: OverridableComponent<StackProps, HTMLDivElement> =
|
||||
className={cl("navds-stack", className, {
|
||||
"navds-vstack": direction === "column",
|
||||
"navds-hstack": direction === "row",
|
||||
"navds-stack-gap": gap,
|
||||
"navds-stack-align": align,
|
||||
"navds-stack-justify": justify,
|
||||
"navds-stack-direction": direction,
|
||||
"navds-stack-wrap": wrap,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Meta } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { HStack, Spacer, Stack, VStack } from ".";
|
||||
import { Box } from "../box";
|
||||
@@ -6,9 +6,14 @@ import { Box } from "../box";
|
||||
export default {
|
||||
title: "ds-react/Primitives/Stack",
|
||||
component: HStack,
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof HStack>;
|
||||
|
||||
export const Horizontal = {
|
||||
type Story = StoryObj<typeof Stack>;
|
||||
|
||||
export const Horizontal: Story = {
|
||||
render: () => (
|
||||
<HStack gap="4">
|
||||
<Placeholders count={4} />
|
||||
@@ -16,7 +21,7 @@ export const Horizontal = {
|
||||
),
|
||||
};
|
||||
|
||||
export const Spacing = {
|
||||
export const Spacing: Story = {
|
||||
render: () => (
|
||||
<div style={{ height: "80vh", display: "flex" }}>
|
||||
<VStack gap="8">
|
||||
@@ -41,7 +46,7 @@ export const Spacing = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Vertical = {
|
||||
export const Vertical: Story = {
|
||||
render: () => (
|
||||
<VStack gap="4">
|
||||
<Placeholders count={4} />
|
||||
@@ -49,7 +54,7 @@ export const Vertical = {
|
||||
),
|
||||
};
|
||||
|
||||
export const VerticalDemo = {
|
||||
export const VerticalDemo: Story = {
|
||||
render: () => (
|
||||
<VStack gap="2">
|
||||
<VStack>
|
||||
@@ -63,7 +68,7 @@ export const VerticalDemo = {
|
||||
),
|
||||
};
|
||||
|
||||
export const VerticalAlign = {
|
||||
export const VerticalAlign: Story = {
|
||||
render: () => (
|
||||
<VStack gap="4">
|
||||
<VStack align="start">
|
||||
@@ -82,7 +87,7 @@ export const VerticalAlign = {
|
||||
},
|
||||
};
|
||||
|
||||
export const OverrideComponent = {
|
||||
export const OverrideComponent: Story = {
|
||||
render: () => (
|
||||
<VStack gap="4" as="form" onSubmit={(e) => e.preventDefault()}>
|
||||
<Placeholders count={4} />
|
||||
@@ -90,7 +95,7 @@ export const OverrideComponent = {
|
||||
),
|
||||
};
|
||||
|
||||
export const Responsive = {
|
||||
export const Responsive: Story = {
|
||||
render: () => (
|
||||
<VStack gap={{ xs: "1", sm: "3", md: "6", lg: "10", xl: "16" }}>
|
||||
<Placeholders count={4} />
|
||||
@@ -98,7 +103,7 @@ export const Responsive = {
|
||||
),
|
||||
};
|
||||
|
||||
export const Nested = {
|
||||
export const Nested: Story = {
|
||||
render: () => (
|
||||
<VStack gap="16">
|
||||
<Placeholders count={2}>
|
||||
@@ -110,7 +115,7 @@ export const Nested = {
|
||||
),
|
||||
};
|
||||
|
||||
export const DividerDemo = {
|
||||
export const DividerDemo: Story = {
|
||||
render: () => (
|
||||
<div style={{ height: "80vh", width: "40rem" }}>
|
||||
<VStack gap={{ xs: "2", md: "6", lg: "12" }}>
|
||||
@@ -134,7 +139,7 @@ export const DividerDemo = {
|
||||
),
|
||||
};
|
||||
|
||||
export const ResponsiveDirection = {
|
||||
export const ResponsiveDirection: Story = {
|
||||
render: () => (
|
||||
<Box
|
||||
background="surface-alt-3-subtle"
|
||||
@@ -181,3 +186,31 @@ function Placeholders({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const Chromatic: Story = {
|
||||
render: (props, context) => (
|
||||
<VStack gap="4">
|
||||
<h2>Spacing</h2>
|
||||
{Spacing.render?.(props, context)}
|
||||
<h2>Vertical</h2>
|
||||
{Vertical.render?.(props, context)}
|
||||
<h2>VerticalDemo</h2>
|
||||
{VerticalDemo.render?.(props, context)}
|
||||
<h2>VerticalAlign</h2>
|
||||
{VerticalAlign.render?.(props, context)}
|
||||
<h2>OverrideComponent</h2>
|
||||
{OverrideComponent.render?.(props, context)}
|
||||
<h2>Responsive</h2>
|
||||
{Responsive.render?.(props, context)}
|
||||
<h2>Nested</h2>
|
||||
{Nested.render?.(props, context)}
|
||||
<h2>DividerDemo</h2>
|
||||
{DividerDemo.render?.(props, context)}
|
||||
<h2>ResponsiveDirection</h2>
|
||||
{ResponsiveDirection.render?.(props, context)}
|
||||
</VStack>
|
||||
),
|
||||
parameters: {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
};
|
||||
@@ -12,7 +12,7 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
} satisfies Meta<typeof LinkPanel>;
|
||||
|
||||
export const Default = {
|
||||
render: (props: any) => {
|
||||
|
||||
@@ -16,7 +16,7 @@ export default {
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} as Meta;
|
||||
} satisfies Meta<typeof List>;
|
||||
|
||||
type Story = StoryObj<typeof List>;
|
||||
|
||||
|
||||
@@ -10,28 +10,28 @@ export default {
|
||||
size: {
|
||||
control: {
|
||||
type: "radio",
|
||||
options: [
|
||||
"3xlarge",
|
||||
"2xlarge",
|
||||
"xlarge",
|
||||
"large",
|
||||
"medium",
|
||||
"small",
|
||||
"xsmall",
|
||||
],
|
||||
},
|
||||
options: [
|
||||
"3xlarge",
|
||||
"2xlarge",
|
||||
"xlarge",
|
||||
"large",
|
||||
"medium",
|
||||
"small",
|
||||
"xsmall",
|
||||
],
|
||||
},
|
||||
variant: {
|
||||
control: {
|
||||
type: "radio",
|
||||
options: ["neutral", "interaction", "inverted"],
|
||||
},
|
||||
options: ["neutral", "interaction", "inverted"],
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
} as Meta;
|
||||
} satisfies Meta<typeof Loader>;
|
||||
|
||||
type Story = StoryObj<typeof Loader>;
|
||||
|
||||
|
||||
@@ -48,9 +48,12 @@ export function useTabList() {
|
||||
End: lastTab,
|
||||
};
|
||||
|
||||
const hasModifiers =
|
||||
event.shiftKey || event.ctrlKey || event.altKey || event.metaKey;
|
||||
|
||||
const action = keyMap[event.key];
|
||||
|
||||
if (action) {
|
||||
if (action && !hasModifiers) {
|
||||
event.preventDefault();
|
||||
action(event);
|
||||
} else if (event.key === "Tab") {
|
||||
|
||||
@@ -8,7 +8,7 @@ export default {
|
||||
title: "ds-react/Timeline",
|
||||
component: Timeline,
|
||||
argTypes: {},
|
||||
} as Meta;
|
||||
} satisfies Meta<typeof Timeline>;
|
||||
|
||||
const DummyLabel = () => {
|
||||
return (
|
||||
|
||||
@@ -77,9 +77,12 @@ export function useToggleItem<P extends UseToggleItemProps>(
|
||||
End: lastTab,
|
||||
};
|
||||
|
||||
const hasModifiers =
|
||||
event.shiftKey || event.ctrlKey || event.altKey || event.metaKey;
|
||||
|
||||
const action = keyMap[event.key];
|
||||
|
||||
if (action) {
|
||||
if (action && !hasModifiers) {
|
||||
event.preventDefault();
|
||||
action(event);
|
||||
} else if (event.key === "Tab") {
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from "react";
|
||||
import { Alert } from "../alert";
|
||||
import { Button } from "../button";
|
||||
import { Search } from "../form/search";
|
||||
import { VStack } from "../layout/stack";
|
||||
import Tooltip from "./Tooltip";
|
||||
|
||||
export default {
|
||||
@@ -25,11 +26,11 @@ export default {
|
||||
placement: {
|
||||
control: {
|
||||
type: "radio",
|
||||
options: ["top", "right", "bottom", "left"],
|
||||
},
|
||||
options: ["top", "right", "bottom", "left"],
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
} satisfies Meta<typeof Tooltip>;
|
||||
|
||||
export const Demo = () => (
|
||||
<div>
|
||||
@@ -45,28 +46,25 @@ export const Demo = () => (
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Default = {
|
||||
render: (props) => {
|
||||
return (
|
||||
<Tooltip
|
||||
content="Tooltip example"
|
||||
open={props?.open}
|
||||
keys={props?.keys ? ["Cmd", "K"] : undefined}
|
||||
placement={props?.placement}
|
||||
arrow={props?.arrow}
|
||||
delay={props?.delay}
|
||||
offset={props?.offset}
|
||||
>
|
||||
<Button aria-describedby="test123">Tooltip</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
|
||||
args: {
|
||||
keys: false,
|
||||
arrow: true,
|
||||
delay: 150,
|
||||
},
|
||||
export const Default = (props) => {
|
||||
return (
|
||||
<Tooltip
|
||||
content="Tooltip example"
|
||||
open={props?.open}
|
||||
keys={props?.keys ? ["Cmd", "K"] : undefined}
|
||||
placement={props?.placement}
|
||||
arrow={props?.arrow}
|
||||
delay={props?.delay}
|
||||
offset={props?.offset}
|
||||
>
|
||||
<Button aria-describedby="test123">Tooltip</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
Default.args = {
|
||||
keys: false,
|
||||
arrow: true,
|
||||
delay: 150,
|
||||
};
|
||||
|
||||
export const Placement = () => {
|
||||
@@ -91,11 +89,34 @@ export const Placement = () => {
|
||||
export const Keys = () => {
|
||||
return (
|
||||
<Tooltip
|
||||
content="Tooltip example Laboris reprehenderit sit sunt nisi velit mollit esse excepteur. Consectetur ullamco quis laboris enim nulla amet proident proident deserunt laborum. Aliqua adipisicing ipsum nisi ipsum nisi pariatur tempor amet aute labore laboris eiusmod adipisicing eu. Et cillum ipsum voluptate ea irure aliquip laboris mollit in. Voluptate tempor do voluptate reprehenderit ea dolor velit ullamco et magna enim ut sit. Pariatur culpa nulla consectetur voluptate id Lorem incididunt magna aliqua sunt ut Lorem. Laborum est quis aute enim et fugiat aute."
|
||||
content="Tooltip example Laboris reprehenderit sit sunt nisi velit mollit esse excepteur. "
|
||||
open={true}
|
||||
keys={["CMD", "K"]}
|
||||
keys={["CMD", "I"]}
|
||||
>
|
||||
<div>Element</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const Chromatic = () => {
|
||||
return (
|
||||
<VStack gap="24">
|
||||
<div>
|
||||
<h2>Default</h2>
|
||||
<Default />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Placement</h2>
|
||||
<Placement />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Keys</h2>
|
||||
<Keys />
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
Chromatic.parameters = {
|
||||
chromatic: { disable: false },
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
|
||||
const fontWeights = [100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const;
|
||||
|
||||
interface FontProps {
|
||||
weight: (typeof fontWeights)[number];
|
||||
family: "Source Sans Pro" | "Source Sans 3";
|
||||
cyrillic?: boolean;
|
||||
samisk?: boolean;
|
||||
showDefault?: boolean;
|
||||
mixed?: boolean;
|
||||
italic?: boolean;
|
||||
}
|
||||
|
||||
const lorem =
|
||||
"lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.";
|
||||
|
||||
const cyrillicAlphabet =
|
||||
"А Б В Г Д Е Ё Ж З И Й К Л М Н О П Р С Т У Ф Х Ц Ч Ш Щ Ъ Ы Ь Э Ю Я а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я";
|
||||
|
||||
const samisk = "á c č đ h i ŋ o š ŧ u z ž";
|
||||
|
||||
function seededRandom(seed: number) {
|
||||
return function () {
|
||||
seed = (seed * 9301 + 49297) % 233280;
|
||||
return seed / 233280;
|
||||
};
|
||||
}
|
||||
|
||||
const shuffle = () => {
|
||||
const array = (lorem + cyrillicAlphabet + samisk).split("");
|
||||
const random = seededRandom(99);
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
return array.join("");
|
||||
};
|
||||
|
||||
const FontComponent = ({ showDefault = true, ...props }: FontProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontWeight: props.weight,
|
||||
fontFamily: props.family,
|
||||
fontStyle: props.italic ? "italic" : undefined,
|
||||
}}
|
||||
>
|
||||
{showDefault && (
|
||||
<p>
|
||||
lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
|
||||
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
|
||||
minim veniam, quis nostrud exercitation ullamco laboris nisi ut
|
||||
aliquip ex ea commodo consequat.
|
||||
</p>
|
||||
)}
|
||||
{props?.cyrillic && (
|
||||
<>
|
||||
<h3>cyrillic</h3>
|
||||
<p>{cyrillicAlphabet}</p>
|
||||
</>
|
||||
)}
|
||||
{props?.samisk && (
|
||||
<>
|
||||
<h3>samisk</h3>
|
||||
<p>{samisk}</p>
|
||||
</>
|
||||
)}
|
||||
{props?.mixed && (
|
||||
<>
|
||||
<h3>mixed</h3>
|
||||
<p>{shuffle()}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const meta: Meta<typeof FontComponent> = {
|
||||
title: "ds-react/Typography/Font",
|
||||
component: FontComponent,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof FontComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
weight: 400,
|
||||
family: "Source Sans 3",
|
||||
cyrillic: true,
|
||||
samisk: true,
|
||||
mixed: true,
|
||||
italic: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Italic: Story = {
|
||||
args: {
|
||||
weight: 400,
|
||||
family: "Source Sans 3",
|
||||
cyrillic: true,
|
||||
samisk: true,
|
||||
mixed: true,
|
||||
italic: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Cyrillic: Story = {
|
||||
args: {
|
||||
weight: 400,
|
||||
family: "Source Sans 3",
|
||||
cyrillic: true,
|
||||
showDefault: false,
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
};
|
||||
|
||||
export const Samisk: Story = {
|
||||
args: {
|
||||
weight: 400,
|
||||
family: "Source Sans 3",
|
||||
samisk: true,
|
||||
showDefault: false,
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
};
|
||||
|
||||
export const Mixed: Story = {
|
||||
args: {
|
||||
weight: 400,
|
||||
family: "Source Sans 3",
|
||||
showDefault: false,
|
||||
mixed: true,
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
};
|
||||
|
||||
export const FontWeight: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<>
|
||||
{fontWeights.map((weight) => (
|
||||
<>
|
||||
<h2>{weight}</h2>
|
||||
<FontComponent
|
||||
key={weight}
|
||||
weight={weight}
|
||||
family="Source Sans 3"
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const FontFamily: Story = {
|
||||
render: (args) => {
|
||||
return (
|
||||
<>
|
||||
<h2>Source Sans Pro</h2>
|
||||
<FontComponent weight={args.weight} family="Source Sans Pro" mixed />
|
||||
<h2>Source Sans 3</h2>
|
||||
<FontComponent weight={args.weight} family="Source Sans 3" mixed />
|
||||
</>
|
||||
);
|
||||
},
|
||||
args: {
|
||||
weight: 400,
|
||||
},
|
||||
};
|
||||
@@ -126,9 +126,11 @@ function Input() {
|
||||
inputMode="numeric"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
descendants.next(index, false)?.node.focus();
|
||||
}
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
descendants.prev(index, false)?.node.focus();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { createContext } from "../create-context";
|
||||
import { createDescendantContext } from "../hooks/descendants/useDescendant";
|
||||
import { SlottedDivElementRef } from "./SlottedDivElement";
|
||||
|
||||
export const [
|
||||
VirtualFocusDescendantsProvider,
|
||||
useVirtualFocusDescendantsContext,
|
||||
useVirtualFocusDescendantInitializer,
|
||||
useVirtualFocusDescendant,
|
||||
] = createDescendantContext<
|
||||
SlottedDivElementRef,
|
||||
{
|
||||
handleOnSelect: () => void;
|
||||
handleOnActive: () => void;
|
||||
}
|
||||
>();
|
||||
|
||||
export const [
|
||||
VirtualFocusInternalContextProvider,
|
||||
useVirtualFocusInternalContext,
|
||||
] = createContext<{
|
||||
virtualFocusIdx: number;
|
||||
setVirtualFocusIdx: Dispatch<SetStateAction<number>>;
|
||||
loop: boolean;
|
||||
uniqueId: string;
|
||||
}>();
|
||||
@@ -0,0 +1,17 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { Slot } from "../../slot/Slot";
|
||||
|
||||
interface SlottedDivProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const SlottedDivElement = forwardRef<HTMLDivElement, SlottedDivProps>(
|
||||
({ asChild, ...rest }, forwardedRef) => {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
return <Comp {...rest} ref={forwardedRef} />;
|
||||
},
|
||||
);
|
||||
|
||||
type SlottedDivElementRef = React.ElementRef<typeof SlottedDivElement>;
|
||||
|
||||
export { SlottedDivElement, type SlottedDivElementRef, type SlottedDivProps };
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useState } from "react";
|
||||
import { useId } from "../../util/hooks";
|
||||
import {
|
||||
VirtualFocusDescendantsProvider,
|
||||
VirtualFocusInternalContextProvider,
|
||||
useVirtualFocusDescendantInitializer,
|
||||
} from "./Context";
|
||||
import { VirtualFocusAnchor } from "./parts/VirtualFocusAnchor";
|
||||
import { VirtualFocusContent } from "./parts/VirtualFocusContent";
|
||||
import { VirtualFocusItem } from "./parts/VirtualFocusItem";
|
||||
|
||||
type VirtualFocusProps = {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Whether to cause focus to loop around when it hits the first or last element
|
||||
* @default false
|
||||
**/
|
||||
loop?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A component that manages a virtual focus using the 'up' and 'down'
|
||||
* arrow keys as well as selection with 'Return'.
|
||||
*
|
||||
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/virtualfocus)
|
||||
* @see 🏷️ {@link AccordionProps}
|
||||
*
|
||||
* @example
|
||||
* ```jsx
|
||||
* <VirtualFocus>
|
||||
* <VirtualFocus.Anchor
|
||||
* role="combobox"
|
||||
* onSelect={() => {
|
||||
* console.log("you selected the anchor");
|
||||
* }}
|
||||
* onActive={() => {
|
||||
* console.log("the anchor is now virtually focused");
|
||||
* }}
|
||||
* >
|
||||
* <input type="text" />
|
||||
* </VirtualFocus.Anchor>
|
||||
* <VirtualFocus.Content>
|
||||
* <VirtualFocus.Item
|
||||
* onSelect={() => {
|
||||
* console.log("you selected the item");
|
||||
* }}
|
||||
* onActive={() => {
|
||||
* console.log("the item is now virtually focused");
|
||||
* }}
|
||||
* >
|
||||
* <p>item 1</p>
|
||||
* </VirtualFocus.Item>
|
||||
* <VirtualFocus.Item
|
||||
* onSelect={() => {
|
||||
* console.log("you selected the item");
|
||||
* }}
|
||||
* onActive={() => {
|
||||
* console.log("the item is now virtually focused");
|
||||
* }}
|
||||
* >
|
||||
* <p>item 2</p>
|
||||
* </VirtualFocus.Item>
|
||||
* </VirtualFocus.Content>
|
||||
* </VirtualFocus>
|
||||
* ```
|
||||
*/
|
||||
export const VirtualFocus = ({ children, loop = false }: VirtualFocusProps) => {
|
||||
const descendants = useVirtualFocusDescendantInitializer();
|
||||
const [virtualFocusIdx, setVirtualFocusIdx] = useState(0);
|
||||
|
||||
return (
|
||||
<VirtualFocusInternalContextProvider
|
||||
virtualFocusIdx={virtualFocusIdx}
|
||||
setVirtualFocusIdx={setVirtualFocusIdx}
|
||||
loop={loop}
|
||||
uniqueId={useId()}
|
||||
>
|
||||
<VirtualFocusDescendantsProvider value={descendants}>
|
||||
{children}
|
||||
</VirtualFocusDescendantsProvider>
|
||||
</VirtualFocusInternalContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
VirtualFocus.Anchor = VirtualFocusAnchor;
|
||||
VirtualFocus.Item = VirtualFocusItem;
|
||||
VirtualFocus.Content = VirtualFocusContent;
|
||||
|
||||
export default VirtualFocus;
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { Slot } from "../../../slot/Slot";
|
||||
import { composeEventHandlers } from "../../composeEventHandlers";
|
||||
import { useMergeRefs } from "../../hooks";
|
||||
import {
|
||||
useVirtualFocusDescendant,
|
||||
useVirtualFocusInternalContext,
|
||||
} from "../Context";
|
||||
|
||||
export interface VirtualFocusAnchorProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, "id"> {
|
||||
/**
|
||||
* The role of the container. This is a limited subset of roles that
|
||||
* require manual focus management.
|
||||
*
|
||||
* Children that are to get focus inside this container element shall be
|
||||
* pointed to by `aria-activedescendant`.
|
||||
**/
|
||||
role:
|
||||
| "combobox"
|
||||
| "grid"
|
||||
| "listbox"
|
||||
| "menu"
|
||||
| "menubar"
|
||||
| "radiogroup"
|
||||
| "tree"
|
||||
| "treegrid"
|
||||
| "tablist";
|
||||
/**
|
||||
* The function that is run when the focused element
|
||||
* is to be selected (eg. do an actual search, change route... etc)
|
||||
*/
|
||||
onSelect: () => void;
|
||||
/**
|
||||
* The function that is run when the element gets
|
||||
* virtual focus set to it.
|
||||
*/
|
||||
onActive: () => void;
|
||||
children: React.ReactElement;
|
||||
/**
|
||||
* Set this to `0` if you want the Anchor itself
|
||||
* to be focusable. Since this Anchor is hoisted & merged with
|
||||
* its first child, you most likely want to keep this as `0`.
|
||||
* @default 0
|
||||
*/
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Must have a single child that is an input element.
|
||||
*/
|
||||
export const VirtualFocusAnchor = forwardRef<
|
||||
HTMLInputElement,
|
||||
VirtualFocusAnchorProps
|
||||
>(({ onSelect, onActive, children, ...rest }, ref) => {
|
||||
const { virtualFocusIdx, setVirtualFocusIdx, loop, uniqueId } =
|
||||
useVirtualFocusInternalContext();
|
||||
|
||||
const { register, descendants, index } = useVirtualFocusDescendant({
|
||||
handleOnActive: () => {
|
||||
setVirtualFocusIdx(0);
|
||||
onActive();
|
||||
},
|
||||
handleOnSelect: onSelect,
|
||||
});
|
||||
|
||||
const mergedRefs = useMergeRefs(ref, register);
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={mergedRefs}
|
||||
tabIndex={0}
|
||||
{...rest}
|
||||
id={`virtualfocus-${uniqueId}-${index}`}
|
||||
aria-owns={`virtualfocus-${uniqueId}-content`}
|
||||
aria-controls={`virtualfocus-${uniqueId}-content`}
|
||||
aria-activedescendant={`virtualfocus-${uniqueId}-${virtualFocusIdx}`}
|
||||
onKeyDown={composeEventHandlers(rest.onKeyDown, (event) => {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
const to_focus_descendant = descendants.next(virtualFocusIdx, loop);
|
||||
if (to_focus_descendant) {
|
||||
to_focus_descendant.handleOnActive();
|
||||
}
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
const to_focus_descendant = descendants.prev(virtualFocusIdx, loop);
|
||||
if (to_focus_descendant) {
|
||||
to_focus_descendant.handleOnActive();
|
||||
}
|
||||
} else if (event.key === "Enter") {
|
||||
const curr = descendants.item(virtualFocusIdx);
|
||||
if (curr?.handleOnSelect) {
|
||||
curr.handleOnSelect();
|
||||
}
|
||||
}
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</Slot>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { useVirtualFocusInternalContext } from "../Context";
|
||||
|
||||
export interface VirtualFocusContentProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, "id"> {}
|
||||
|
||||
export const VirtualFocusContent = forwardRef<
|
||||
HTMLDivElement,
|
||||
VirtualFocusContentProps
|
||||
>(({ children, ...rest }, ref) => {
|
||||
const { uniqueId } = useVirtualFocusInternalContext();
|
||||
return (
|
||||
<div ref={ref} {...rest} id={`virtualfocus-${uniqueId}-content`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { Slot } from "../../../slot/Slot";
|
||||
import { composeEventHandlers } from "../../composeEventHandlers";
|
||||
import { useMergeRefs } from "../../hooks";
|
||||
import {
|
||||
useVirtualFocusDescendant,
|
||||
useVirtualFocusInternalContext,
|
||||
} from "../Context";
|
||||
|
||||
export interface VirtualFocusItemProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, "id" | "tabIndex"> {
|
||||
/**
|
||||
* The function that is run when the element is focused
|
||||
* (virtually, not actual focus, eg. set a border around an item)
|
||||
*/
|
||||
onActive: () => void;
|
||||
/**
|
||||
* The function that is run when the focused element
|
||||
* is to be selected (eg. do an actual search, change route... etc)
|
||||
*/
|
||||
onSelect: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains an item you want to be iterable via virtual focus.
|
||||
*/
|
||||
export const VirtualFocusItem = forwardRef<HTMLElement, VirtualFocusItemProps>(
|
||||
({ children, onActive, onSelect, ...rest }, ref) => {
|
||||
const { virtualFocusIdx, setVirtualFocusIdx, uniqueId } =
|
||||
useVirtualFocusInternalContext();
|
||||
const { register, index } = useVirtualFocusDescendant({
|
||||
handleOnActive: () => {
|
||||
setVirtualFocusIdx(index);
|
||||
onActive();
|
||||
},
|
||||
handleOnSelect: onSelect,
|
||||
});
|
||||
|
||||
const mergedRefs = useMergeRefs(ref, register);
|
||||
return (
|
||||
<Slot
|
||||
ref={mergedRefs}
|
||||
{...rest}
|
||||
id={`virtualfocus-${uniqueId}-${index}`}
|
||||
data-aksel-virtualfocus={virtualFocusIdx === index}
|
||||
tabIndex={-1}
|
||||
onClick={composeEventHandlers(rest.onClick, () => {
|
||||
onSelect();
|
||||
})}
|
||||
onMouseMove={composeEventHandlers(rest.onMouseMove, () => {
|
||||
setVirtualFocusIdx(index);
|
||||
onActive();
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</Slot>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,381 @@
|
||||
import { Meta } from "@storybook/react";
|
||||
import React, {
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
forwardRef,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
CloudIcon,
|
||||
HangerIcon,
|
||||
HourglassIcon,
|
||||
HouseIcon,
|
||||
PuzzlePieceIcon,
|
||||
RulerIcon,
|
||||
} from "@navikt/aksel-icons";
|
||||
import { Button } from "../../button";
|
||||
import { Search } from "../../form/search";
|
||||
import { VStack } from "../../layout/stack";
|
||||
import { Popover } from "../../popover";
|
||||
import { VirtualFocus } from "./VirtualFocus";
|
||||
|
||||
export default {
|
||||
title: "Utilities/VirtualFocus",
|
||||
component: VirtualFocus,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<VStack gap="10">
|
||||
<Button>above</Button>
|
||||
<Story />
|
||||
<Button>below</Button>
|
||||
</VStack>
|
||||
),
|
||||
],
|
||||
parameters: {
|
||||
chromatic: {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof VirtualFocus>;
|
||||
|
||||
const MyAnchor = forwardRef<
|
||||
HTMLInputElement,
|
||||
{
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
setValue: Dispatch<SetStateAction<string>>;
|
||||
}
|
||||
>(({ children, value, setValue }, ref) => {
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<VirtualFocus.Anchor
|
||||
role="combobox"
|
||||
ref={ref}
|
||||
onSelect={() => {
|
||||
console.log(`onSelect(): ${value}`);
|
||||
}}
|
||||
onActive={() => {
|
||||
console.log(`onActive(): Anchor`);
|
||||
}}
|
||||
>
|
||||
<Search
|
||||
label="Søk på nav sine sider"
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
onChange={(current_input) => {
|
||||
setValue(current_input);
|
||||
}}
|
||||
ref={searchRef}
|
||||
/>
|
||||
</VirtualFocus.Anchor>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
const MyItem = ({
|
||||
children,
|
||||
icon,
|
||||
setValue,
|
||||
}: {
|
||||
children: string;
|
||||
icon: ReactNode;
|
||||
setValue: Dispatch<SetStateAction<string>>;
|
||||
}) => {
|
||||
return (
|
||||
<VirtualFocus.Item
|
||||
onActive={() => {
|
||||
setValue(children);
|
||||
console.log(`onActive(): ${children}`);
|
||||
}}
|
||||
onSelect={() => {
|
||||
console.log(`onSelect(): ${children}`);
|
||||
}}
|
||||
role="button"
|
||||
>
|
||||
<span>
|
||||
{icon}
|
||||
<span>{children}</span>
|
||||
</span>
|
||||
</VirtualFocus.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const StoryStyle = () => (
|
||||
<style>{`
|
||||
[data-aksel-virtualfocus="true"] {
|
||||
outline: solid 3px var(--a-border-focus);
|
||||
border-radius: var(--a-border-radius-medium);
|
||||
}
|
||||
`}</style>
|
||||
);
|
||||
|
||||
export const Default = () => {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
return (
|
||||
<>
|
||||
<StoryStyle />
|
||||
<VirtualFocus>
|
||||
<MyAnchor setValue={setValue} value={value}>
|
||||
<VirtualFocus.Content>
|
||||
<VStack>
|
||||
<MyItem setValue={setValue} icon={<CloudIcon aria-hidden />}>
|
||||
{"cloud "}
|
||||
</MyItem>
|
||||
<MyItem setValue={setValue} icon={<HangerIcon aria-hidden />}>
|
||||
{"hanger "}
|
||||
</MyItem>
|
||||
<MyItem setValue={setValue} icon={<HourglassIcon aria-hidden />}>
|
||||
{"hourglass "}
|
||||
</MyItem>
|
||||
<MyItem setValue={setValue} icon={<HouseIcon aria-hidden />}>
|
||||
{"house "}
|
||||
</MyItem>
|
||||
<MyItem setValue={setValue} icon={<RulerIcon aria-hidden />}>
|
||||
{"ruler "}
|
||||
</MyItem>
|
||||
<MyItem
|
||||
setValue={setValue}
|
||||
icon={<PuzzlePieceIcon aria-hidden />}
|
||||
>
|
||||
{"puzzle piece "}
|
||||
</MyItem>
|
||||
</VStack>
|
||||
</VirtualFocus.Content>
|
||||
</MyAnchor>
|
||||
</VirtualFocus>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Loop = () => {
|
||||
const [value, setValue] = useState("");
|
||||
return (
|
||||
<>
|
||||
<StoryStyle />
|
||||
<VirtualFocus loop>
|
||||
<MyAnchor setValue={setValue} value={value}>
|
||||
<VirtualFocus.Content>
|
||||
<VStack>
|
||||
<MyItem setValue={setValue} icon={<CloudIcon aria-hidden />}>
|
||||
{"cloud "}
|
||||
</MyItem>
|
||||
<MyItem setValue={setValue} icon={<HangerIcon aria-hidden />}>
|
||||
{"hanger "}
|
||||
</MyItem>
|
||||
<MyItem setValue={setValue} icon={<HourglassIcon aria-hidden />}>
|
||||
{"hourglass "}
|
||||
</MyItem>
|
||||
<MyItem setValue={setValue} icon={<HouseIcon aria-hidden />}>
|
||||
{"house "}
|
||||
</MyItem>
|
||||
<MyItem setValue={setValue} icon={<RulerIcon aria-hidden />}>
|
||||
{"ruler "}
|
||||
</MyItem>
|
||||
<MyItem
|
||||
setValue={setValue}
|
||||
icon={<PuzzlePieceIcon aria-hidden />}
|
||||
>
|
||||
{"puzzle piece "}
|
||||
</MyItem>
|
||||
</VStack>
|
||||
</VirtualFocus.Content>
|
||||
</MyAnchor>
|
||||
</VirtualFocus>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Multiple = () => {
|
||||
const [value, setValue] = useState("");
|
||||
const [value2, setValue2] = useState("");
|
||||
const [value3, setValue3] = useState("");
|
||||
|
||||
return (
|
||||
<>
|
||||
<StoryStyle />
|
||||
<VirtualFocus>
|
||||
<MyAnchor setValue={setValue} value={value}>
|
||||
<VirtualFocus.Content>
|
||||
<VStack>
|
||||
<MyItem setValue={setValue} icon={<CloudIcon aria-hidden />}>
|
||||
{"cloud "}
|
||||
</MyItem>
|
||||
<MyItem setValue={setValue} icon={<HangerIcon aria-hidden />}>
|
||||
{"hanger "}
|
||||
</MyItem>
|
||||
</VStack>
|
||||
</VirtualFocus.Content>
|
||||
</MyAnchor>
|
||||
</VirtualFocus>
|
||||
<VirtualFocus>
|
||||
<MyAnchor setValue={setValue2} value={value2}>
|
||||
<VirtualFocus.Content>
|
||||
<VStack>
|
||||
<MyItem setValue={setValue2} icon={<CloudIcon aria-hidden />}>
|
||||
{"cloud "}
|
||||
</MyItem>
|
||||
<MyItem setValue={setValue2} icon={<HangerIcon aria-hidden />}>
|
||||
{"hanger "}
|
||||
</MyItem>
|
||||
</VStack>
|
||||
</VirtualFocus.Content>
|
||||
</MyAnchor>
|
||||
</VirtualFocus>
|
||||
<VirtualFocus>
|
||||
<MyAnchor setValue={setValue3} value={value3}>
|
||||
<VirtualFocus.Content>
|
||||
<VStack>
|
||||
<MyItem setValue={setValue3} icon={<CloudIcon aria-hidden />}>
|
||||
{"cloud "}
|
||||
</MyItem>
|
||||
<MyItem setValue={setValue3} icon={<HangerIcon aria-hidden />}>
|
||||
{"hanger "}
|
||||
</MyItem>
|
||||
</VStack>
|
||||
</VirtualFocus.Content>
|
||||
</MyAnchor>
|
||||
</VirtualFocus>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MyDropDownSearchVirtualfocus = () => {
|
||||
const anchorRef = useRef<HTMLInputElement>(null);
|
||||
const [openState, setOpenState] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const items = [
|
||||
{
|
||||
icon: <CloudIcon aria-hidden />,
|
||||
text: "cloud ",
|
||||
},
|
||||
{
|
||||
icon: <HangerIcon aria-hidden />,
|
||||
text: "hanger ",
|
||||
},
|
||||
{
|
||||
icon: <HourglassIcon aria-hidden />,
|
||||
text: "hourglass ",
|
||||
},
|
||||
{
|
||||
icon: <HouseIcon aria-hidden />,
|
||||
text: "house ",
|
||||
},
|
||||
{
|
||||
icon: <RulerIcon aria-hidden />,
|
||||
text: "ruler ",
|
||||
},
|
||||
{
|
||||
icon: <PuzzlePieceIcon aria-hidden />,
|
||||
text: "puzzle piece ",
|
||||
},
|
||||
];
|
||||
|
||||
const filteredItems = items;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
.dropdown-search-virtualfocus {
|
||||
width: 100%;
|
||||
}
|
||||
[data-aksel-virtualfocus="true"] {
|
||||
outline: solid 3px var(--a-border-focus);
|
||||
border-radius: var(--a-border-radius-medium);
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<MyAnchor setValue={setValue} value={value} ref={anchorRef}>
|
||||
<Popover
|
||||
className="dropdown-search-virtualfocus"
|
||||
anchorEl={anchorRef.current}
|
||||
open={openState || (!!value.length && !!filteredItems.length)}
|
||||
onClose={() => {
|
||||
setOpenState(false);
|
||||
}}
|
||||
arrow={false}
|
||||
offset={0}
|
||||
placement="bottom-start"
|
||||
>
|
||||
<VirtualFocus.Content>
|
||||
<Popover.Content>
|
||||
<VStack>
|
||||
{filteredItems.map((i) => {
|
||||
return (
|
||||
<MyItem setValue={setValue} key={i.text} icon={i.icon}>
|
||||
{i.text}
|
||||
</MyItem>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
</Popover.Content>
|
||||
</VirtualFocus.Content>
|
||||
</Popover>
|
||||
</MyAnchor>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithPopover = () => {
|
||||
return (
|
||||
<div>
|
||||
<VirtualFocus>
|
||||
<MyDropDownSearchVirtualfocus />
|
||||
</VirtualFocus>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A simple story without much interactivity (very much incomplete
|
||||
* but shows a good overview of the components & their relationship)
|
||||
* This is essentially 1:1 with the JSDoc example for the component.
|
||||
**/
|
||||
export const Simple = () => {
|
||||
return (
|
||||
<>
|
||||
<StoryStyle />
|
||||
<VirtualFocus>
|
||||
<VirtualFocus.Anchor
|
||||
role="combobox"
|
||||
onSelect={() => {
|
||||
console.log("you selected the anchor");
|
||||
}}
|
||||
onActive={() => {
|
||||
console.log("the anchor is now virtually focused");
|
||||
}}
|
||||
>
|
||||
<input type="text" />
|
||||
</VirtualFocus.Anchor>
|
||||
<VirtualFocus.Content>
|
||||
<VirtualFocus.Item
|
||||
onSelect={() => {
|
||||
console.log("you selected the item");
|
||||
}}
|
||||
onActive={() => {
|
||||
console.log("the item is now virtually focused");
|
||||
}}
|
||||
>
|
||||
<p>item 1</p>
|
||||
</VirtualFocus.Item>
|
||||
<VirtualFocus.Item
|
||||
onSelect={() => {
|
||||
console.log("you selected the item");
|
||||
}}
|
||||
onActive={() => {
|
||||
console.log("the item is now virtually focused");
|
||||
}}
|
||||
>
|
||||
<p>item 2</p>
|
||||
</VirtualFocus.Item>
|
||||
</VirtualFocus.Content>
|
||||
</VirtualFocus>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
# @navikt/ds-tailwind
|
||||
|
||||
## 6.14.0
|
||||
|
||||
## 6.13.0
|
||||
|
||||
## 6.12.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-tailwind",
|
||||
"version": "6.13.0",
|
||||
"version": "6.14.0",
|
||||
"description": "Tailwind config based on @navikt/ds-tokens",
|
||||
"author": "Aksel | NAV designsystem team",
|
||||
"keywords": [
|
||||
@@ -25,7 +25,7 @@
|
||||
"directory": "@navikt/core/tailwind"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@navikt/ds-tokens": "^6.13.0",
|
||||
"@navikt/ds-tokens": "^6.14.0",
|
||||
"color": "4.2.3",
|
||||
"lodash": "^4.17.21",
|
||||
"tailwindcss": "^3.3.3",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @navikt/ds-tokens
|
||||
|
||||
## 6.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Fonts: Added built-in support for cyrillic, cyrillic-ext and latin-ext ([#3044](https://github.com/navikt/aksel/pull/3044))
|
||||
|
||||
## 6.13.0
|
||||
|
||||
## 6.12.0
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"font": [
|
||||
{
|
||||
"name": "--a-font-family",
|
||||
"value": "'Source Sans Pro', Arial, sans-serif"
|
||||
"value": "'Source Sans 3', 'Source Sans Pro', Arial, sans-serif"
|
||||
},
|
||||
{ "name": "--a-font-weight-regular", "value": "400" },
|
||||
{ "name": "--a-font-weight-bold", "value": "600" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-tokens",
|
||||
"version": "6.13.0",
|
||||
"version": "6.14.0",
|
||||
"description": "Design-tokens for NAV designsystem",
|
||||
"author": "Aksel | NAV designsystem team",
|
||||
"keywords": [
|
||||
|
||||
@@ -22,7 +22,9 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
font: {
|
||||
family: { value: "'Source Sans Pro', Arial, sans-serif" },
|
||||
family: {
|
||||
value: "'Source Sans 3', 'Source Sans Pro', Arial, sans-serif",
|
||||
},
|
||||
line: {
|
||||
height: {
|
||||
heading: {
|
||||
|
||||
@@ -6,7 +6,7 @@ export default {
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} as Meta;
|
||||
} satisfies Meta;
|
||||
|
||||
const Border = ({ token }) => (
|
||||
<>
|
||||
|
||||
@@ -4,7 +4,7 @@ import tokens from "../docs.json";
|
||||
|
||||
export default {
|
||||
title: "ds-tokens/Colors",
|
||||
} as Meta;
|
||||
} satisfies Meta;
|
||||
|
||||
/**
|
||||
* No need to create single Chromatic-story since we want to keep global and semantic colors separated.
|
||||
|
||||
@@ -6,7 +6,7 @@ export default {
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} as Meta;
|
||||
} satisfies Meta;
|
||||
|
||||
const Shadow = ({ token }) => (
|
||||
<>
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as tokens from "../dist/tokens.js";
|
||||
|
||||
export default {
|
||||
title: "ds-tokens/Spacing",
|
||||
} as Meta;
|
||||
} satisfies Meta;
|
||||
|
||||
export const Spacing = () => {
|
||||
const allSpacing: { [key: string]: string } = Object.entries(tokens).reduce(
|
||||
|
||||
+434
-408
File diff suppressed because it is too large.
Load diff
@@ -15,10 +15,10 @@
|
||||
"playroom"
|
||||
],
|
||||
"dependencies": {
|
||||
"@navikt/aksel-icons": "^6.13.0",
|
||||
"@navikt/ds-css": "^6.13.0",
|
||||
"@navikt/ds-react": "^6.13.0",
|
||||
"@navikt/ds-tailwind": "^6.13.0",
|
||||
"@navikt/ds-tokens": "^6.13.0"
|
||||
"@navikt/aksel-icons": "^6.14.0",
|
||||
"@navikt/ds-css": "^6.14.0",
|
||||
"@navikt/ds-react": "^6.14.0",
|
||||
"@navikt/ds-tailwind": "^6.14.0",
|
||||
"@navikt/ds-tokens": "^6.14.0"
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
"babel-loader": "^9.1.3",
|
||||
"babel-plugin-preval": "^5.1.0",
|
||||
"css-loader": "^6.8.1",
|
||||
"playroom": "^0.37.0",
|
||||
"playroom": "^0.38.0",
|
||||
"style-loader": "^3.3.3"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -53,7 +53,10 @@ module.exports = {
|
||||
},
|
||||
{
|
||||
test: /\.css$/i,
|
||||
include: [require.resolve("@navikt/ds-css")],
|
||||
include: [
|
||||
require.resolve("@navikt/ds-css"),
|
||||
require.resolve("./src/frameComponent.css"),
|
||||
],
|
||||
use: ["style-loader", "css-loader"],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import "@navikt/ds-css";
|
||||
import "./frameComponent.css";
|
||||
|
||||
const FrameComponent = ({ children }: { children: React.ReactNode }) => {
|
||||
return <div id="sandbox-wrapper">{children}</div>;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
iframe {
|
||||
background-color: white;
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export const WithSidebar = ({
|
||||
size="xlarge"
|
||||
className={cl({
|
||||
"text-deepblue-800": variant === "page",
|
||||
"w-fit text-text-on-action": variant !== "page",
|
||||
"w-fit text-text-on-action": variant !== "page",
|
||||
})}
|
||||
>
|
||||
{pageType.title}
|
||||
|
||||
@@ -23,7 +23,7 @@ const Element = ({ block }: { block: DoDontT["blokker"][number] }) => {
|
||||
"max-w-sm": !block?.fullwidth,
|
||||
})}
|
||||
>
|
||||
<div className="relative rounded-lg shadow-xsmall ring-1 ring-inset ring-border-subtle ">
|
||||
<div className="relative rounded-lg shadow-xsmall ring-1 ring-inset ring-border-subtle">
|
||||
<BodyShort
|
||||
as="span"
|
||||
className={cl(
|
||||
|
||||
@@ -2,6 +2,7 @@ import ErrorBoundary from "@/error-boundary";
|
||||
import { SanityBlockContent } from "@/sanity-block";
|
||||
import { AkselGrunnleggendeDocT, AkselKomponentDocT } from "@/types";
|
||||
import { List, ListItem } from "@/web/List";
|
||||
import { markdownLink } from "./markdown-link";
|
||||
|
||||
type IntroProps = {
|
||||
node: AkselKomponentDocT["intro"] | AkselGrunnleggendeDocT["intro"];
|
||||
@@ -24,7 +25,7 @@ const Intro = ({ node, internal }: IntroProps) => {
|
||||
{internal && <ListItem>Bruk på interne flater</ListItem>}
|
||||
{node.brukes_til.map((x) => (
|
||||
<ListItem icon key={x}>
|
||||
{x}
|
||||
{markdownLink(x)}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
@@ -32,7 +33,7 @@ const Intro = ({ node, internal }: IntroProps) => {
|
||||
<List title="Uegnet til:">
|
||||
{node.brukes_ikke_til.map((x) => (
|
||||
<ListItem icon key={x}>
|
||||
{x}
|
||||
{markdownLink(x)}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import AkselLink from "@/web/AkselLink";
|
||||
|
||||
/**
|
||||
* Splits a string into text and links,
|
||||
* and returns an array of React elements.
|
||||
*/
|
||||
function markdownLink(input: string) {
|
||||
const regex = /\[([^\]]+)\]\(([^\s)]+)\)/g;
|
||||
let lastIndex = 0;
|
||||
const elements: React.ReactNode[] = [];
|
||||
|
||||
input.replace(regex, (match, text, url, index) => {
|
||||
if (index > lastIndex) {
|
||||
elements.push(input.slice(lastIndex, index));
|
||||
}
|
||||
elements.push(
|
||||
<AkselLink href={url} key={index}>
|
||||
{text}
|
||||
</AkselLink>,
|
||||
);
|
||||
|
||||
lastIndex = index + match.length;
|
||||
return match;
|
||||
});
|
||||
|
||||
if (lastIndex < input.length) {
|
||||
elements.push(input.slice(lastIndex));
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
export { markdownLink };
|
||||
@@ -1,66 +1,27 @@
|
||||
import cl from "clsx";
|
||||
import Link from "next/link";
|
||||
import NextLink from "next/link";
|
||||
import React from "react";
|
||||
import ErrorBoundary from "@/error-boundary";
|
||||
import { Link } from "@navikt/ds-react";
|
||||
import { amplitudeLogNavigation } from "@/logging";
|
||||
|
||||
type AkselLinkProps = {
|
||||
type Props = {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
inverted?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const AkselLink = ({
|
||||
href,
|
||||
children,
|
||||
inverted = false,
|
||||
className,
|
||||
}: AkselLinkProps) => {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
passHref
|
||||
className={cl("group relative w-fit pr-5 focus:outline-none", className, {
|
||||
"white focus-visible:bg-blue-200 focus-visible:text-text-default focus-visible:shadow-[0_0_0_3px_var(--a-blue-200)]":
|
||||
inverted,
|
||||
"text-deepblue-500 focus-visible:bg-blue-800 focus-visible:text-text-on-action focus-visible:shadow-[0_0_0_3px_var(--a-blue-800)] group-hover:text-deepblue-800":
|
||||
!inverted,
|
||||
})}
|
||||
onClick={(e) =>
|
||||
amplitudeLogNavigation("link", e.currentTarget.getAttribute("href"))
|
||||
}
|
||||
>
|
||||
{children}
|
||||
<svg
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cl(
|
||||
"ml-2 h-[20px] w-[10px] scale-[0.85] transition-[width,right] group-hover:w-[16px]",
|
||||
"absolute right-[5px] top-1/2 -translate-y-1/2 rotate-180 group-hover:right-0",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M8 17.5L2.12132 11.6213C0.949744 10.4497 0.949746 8.55025 2.12132 7.37868L8 1.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M16 9.72266L8 9.72266"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
className="hidden group-hover:block"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
const AkselLink = ({ href, children }: Props) => (
|
||||
<Link
|
||||
as={NextLink}
|
||||
href={href}
|
||||
inlineText
|
||||
onClick={(e) =>
|
||||
amplitudeLogNavigation("link", e.currentTarget.getAttribute("href"))
|
||||
}
|
||||
{...(href.startsWith("http") && !href.startsWith("https://aksel.nav.no/")
|
||||
? { target: "_blank", rel: "noreferrer noopener" }
|
||||
: {})}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default function Component(props: AkselLinkProps) {
|
||||
return (
|
||||
<ErrorBoundary boundaryName="AkselLink">
|
||||
<AkselLink {...props} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
export default AkselLink;
|
||||
@@ -9,7 +9,7 @@ function EditButton() {
|
||||
href={`https://aksel.nav.no/admin/prod/intent/edit/id=${sanityCtx?.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="absolute right-0 top-0 flex -translate-y-[99%] items-center gap-2 overflow-hidden rounded-tl bg-deepblue-800 px-2 py-1 text-white transition-transform hover:translate-x-0 hover:bg-deepblue-700"
|
||||
className="absolute right-0 top-0 flex -translate-y-[99%] items-center gap-2 overflow-hidden rounded-tl bg-deepblue-800 px-2 py-1 text-white transition-transform hover:translate-x-0 hover:bg-deepblue-700"
|
||||
>
|
||||
<PencilIcon aria-hidden className="shrink-0 text-xl" /> Rediger side
|
||||
</a>
|
||||
|
||||
@@ -4,9 +4,8 @@ import {
|
||||
PortableTextReactComponents,
|
||||
} from "@portabletext/react";
|
||||
import cl from "clsx";
|
||||
import NextLink from "next/link";
|
||||
import { Children } from "react";
|
||||
import { BodyLong, Detail, Heading, Link } from "@navikt/ds-react";
|
||||
import { BodyLong, Detail, Heading } from "@navikt/ds-react";
|
||||
import Accordion from "@/cms/accordion/Accordion";
|
||||
import Alert from "@/cms/alert/Alert";
|
||||
import Bilde from "@/cms/bilde/Bilde";
|
||||
@@ -24,10 +23,10 @@ import TastaturModul from "@/cms/tastatur-tabell/TastaturTabell";
|
||||
import Tips from "@/cms/tips/Tips";
|
||||
import TokenTable from "@/cms/token-tabell/TokenTable";
|
||||
import Video from "@/cms/video/Video";
|
||||
import { amplitudeLogNavigation } from "@/logging";
|
||||
import InlineCode from "@/web/InlineCode";
|
||||
import KBD from "@/web/KBD";
|
||||
import { List, ListItem } from "@/web/List";
|
||||
import AkselLink from "./AkselLink";
|
||||
|
||||
const serializers: Partial<PortableTextReactComponents> = {
|
||||
types: {
|
||||
@@ -102,42 +101,13 @@ const serializers: Partial<PortableTextReactComponents> = {
|
||||
if (!href) {
|
||||
return <span>{text}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
as={NextLink}
|
||||
href={href}
|
||||
inlineText
|
||||
onClick={(e) =>
|
||||
amplitudeLogNavigation("link", e.currentTarget.getAttribute("href"))
|
||||
}
|
||||
{...(href.startsWith("http") &&
|
||||
!href.startsWith("https://aksel.nav.no/")
|
||||
? { target: "_blank", rel: "noreferrer noopener" }
|
||||
: {})}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
);
|
||||
return <AkselLink href={href}>{text}</AkselLink>;
|
||||
},
|
||||
internalLink: ({ text, value: { slug } }) => {
|
||||
if (!slug || !slug.current) {
|
||||
return <span>{text}</span>;
|
||||
}
|
||||
|
||||
const href = `/${slug?.current}`;
|
||||
return (
|
||||
<Link
|
||||
as={NextLink}
|
||||
href={href}
|
||||
inlineText
|
||||
onClick={(e) =>
|
||||
amplitudeLogNavigation("link", e.currentTarget.getAttribute("href"))
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
);
|
||||
return <AkselLink href={`/${slug.current}`}>{text}</AkselLink>;
|
||||
},
|
||||
},
|
||||
unknownMark: () => null,
|
||||
|
||||
@@ -114,7 +114,7 @@ export const IconPage = ({ name }: { name: string }) => {
|
||||
<div className="sticky top-0 z-20 grid items-center rounded-t-2xl border-b border-b-border-subtle bg-surface-default p-1">
|
||||
<form
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
className="flex w-full flex-wrap-reverse items-center gap-4 px-4 py-2 sm:flex-nowrap"
|
||||
className="flex w-full flex-wrap-reverse items-center gap-4 px-4 py-2 sm:flex-nowrap"
|
||||
>
|
||||
<ToggleGroup
|
||||
value={toggle}
|
||||
|
||||
@@ -93,7 +93,7 @@ export function MenuLink({
|
||||
"flex py-05 focus:outline-none *:focus-visible:shadow-focus group-first:pt-0 group-last:last:pb-0",
|
||||
"before:absolute before:-left-px before:top-05 before:h-[calc(100%-0.25rem)] before:rounded-r-sm before:transition-all group-first:before:top-0 group-first:before:h-[calc(100%-0.125rem)] group-last:before:h-[calc(100%-0.125rem)]",
|
||||
{
|
||||
"text-text-subtle before:w-0 before:bg-gray-400 before:duration-100 before:ease-linear hover:text-text-default hover:before:w-1":
|
||||
"text-text-subtle before:w-0 before:bg-gray-400 before:duration-100 before:ease-linear hover:text-text-default hover:before:w-1":
|
||||
!selected,
|
||||
"before:w-1": selected,
|
||||
"text-deepblue-700 before:bg-deepblue-700":
|
||||
|
||||
@@ -91,7 +91,7 @@ export function Collection({
|
||||
)}
|
||||
{heading && !tag && (
|
||||
<Heading
|
||||
className="sticky top-0 z-10 bg-surface-subtle p-2 px-6 md:px-10"
|
||||
className="sticky top-0 z-10 bg-surface-subtle p-2 px-6 md:px-10"
|
||||
size="small"
|
||||
level="2"
|
||||
>
|
||||
|
||||
@@ -22,7 +22,7 @@ export const Search = () => {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
className="h-[90%] max-h-[52rem] "
|
||||
className="h-[90%] max-h-[52rem]"
|
||||
width="medium"
|
||||
closeOnBackdropClick
|
||||
header={{
|
||||
|
||||
@@ -69,7 +69,7 @@ const SuggestionBlockIcon = ({
|
||||
reference?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className={cl("mb-12 mt-6 flex gap-3 rounded-lg py-4 ")}>
|
||||
<div className={cl("mb-12 mt-6 flex gap-3 rounded-lg py-4")}>
|
||||
<span className="shrink-0 text-2xl">{options[variant]?.icon}</span>
|
||||
<div className="grid">
|
||||
{options[variant]?.heading && (
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-responsive-masonry": "^2.1.7",
|
||||
"react-responsive-masonry": "2.2.0",
|
||||
"react-rx": "^2.1.3",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sanity": "3.41.1",
|
||||
|
||||
@@ -38,7 +38,7 @@ const Example = () => {
|
||||
label="Hvilke land har du besøkt de siste 6 ukene? Velg opptil flere."
|
||||
filteredOptions={filteredOptions}
|
||||
isMultiSelect
|
||||
onChange={(event) => setValue(event?.target.value || "")}
|
||||
onChange={setValue}
|
||||
onToggleSelected={onToggleSelected}
|
||||
selectedOptions={selectedOptions}
|
||||
options={initialOptions}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRef } from "react";
|
||||
import { BodyLong, Button, Modal } from "@navikt/ds-react";
|
||||
import { BodyLong, Button, Modal, ReadMore } from "@navikt/ds-react";
|
||||
import { withDsExample } from "@/web/examples/withDsExample";
|
||||
|
||||
const Example = () => {
|
||||
@@ -9,11 +9,20 @@ const Example = () => {
|
||||
<div className="py-32">
|
||||
<Button onClick={() => ref.current?.showModal()}>Åpne modal</Button>
|
||||
|
||||
<Modal ref={ref} header={{ heading: "Overskrift" }} placement="top">
|
||||
<Modal
|
||||
placement="top"
|
||||
ref={ref}
|
||||
header={{ heading: "Overskrift" }}
|
||||
width="small"
|
||||
>
|
||||
<Modal.Body>
|
||||
<BodyLong>
|
||||
Culpa aliquip ut cupidatat laborum minim quis ex in aliqua.
|
||||
</BodyLong>
|
||||
<BodyLong>Culpa aliquip ut cupidatat laborum minim quis ex.</BodyLong>
|
||||
<ReadMore header="Qui incididunt dolor do ad ut.">
|
||||
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Nesciunt
|
||||
debitis deleniti corrupti dolore perspiciatis. Tempora vero numquam
|
||||
ullam aliquam ipsum quo, animi aspernatur facere voluptatum aperiam
|
||||
cum, repellat velit repudiandae.
|
||||
</ReadMore>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@ const Example = () => {
|
||||
<Tabs.Panel value="inbox" className="h-24 w-full bg-gray-50 p-4">
|
||||
Inbox-tab
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="sendt" className="h-24 w-full bg-gray-50 p-4">
|
||||
<Tabs.Panel value="sendt" className="h-24 w-full bg-gray-50 p-4">
|
||||
Sendt-tab
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
@@ -32,7 +32,7 @@ const Example = () => {
|
||||
<Tabs.Panel value="inbox" className="h-24 w-full bg-gray-50 p-4">
|
||||
Inbox-tab
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="sendt" className="h-24 w-full bg-gray-50 p-4">
|
||||
<Tabs.Panel value="sendt" className="h-24 w-full bg-gray-50 p-4">
|
||||
Sendt-tab
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
@@ -15,7 +15,7 @@ const Example = () => {
|
||||
<Tabs.Panel value="inbox" className="h-24 w-full bg-gray-50 p-4">
|
||||
Inbox-tab
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="sendt" className="h-24 w-full bg-gray-50 p-4">
|
||||
<Tabs.Panel value="sendt" className="h-24 w-full bg-gray-50 p-4">
|
||||
Sendt-tab
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
@@ -32,7 +32,7 @@ const Example = () => {
|
||||
<Tabs.Panel value="inbox" className="h-24 w-full bg-gray-50 p-4">
|
||||
Inbox-tab
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="sendt" className="h-24 w-full bg-gray-50 p-4">
|
||||
<Tabs.Panel value="sendt" className="h-24 w-full bg-gray-50 p-4">
|
||||
Sendt-tab
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
@@ -32,7 +32,7 @@ const Example = () => {
|
||||
<Tabs.Panel value="inbox" className="h-24 w-full bg-gray-50 p-4">
|
||||
Inbox-tab
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="sendt" className="h-24 w-full bg-gray-50 p-4">
|
||||
<Tabs.Panel value="sendt" className="h-24 w-full bg-gray-50 p-4">
|
||||
Sendt-tab
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user