Compare commits

..
Author SHA1 Message Date
Ken 8397677049 perf: Skip observing style-changes 2026-11-03 14:31:18 +01:00
Ken 2bbf17e8b0 perf(DataGrid.Table): Optimize table rebuild 2026-11-02 16:23:44 +01:00
108 changed files with 873 additions and 5058 deletions

No files matched your search

-5
View File
@@ -1,5 +0,0 @@
---
"@navikt/aksel-icons": minor
---
Icons: New icons `Gift`, `ArrowsPointingIn` and `ArrowsPointingOut`.
-5
View File
@@ -1,5 +0,0 @@
---
"@navikt/ds-react": patch
---
Combobox: The list is no longer focusable
-5
View File
@@ -1,5 +0,0 @@
---
"@navikt/ds-react": patch
---
Popover: Closed popovers no longer calculate their position on mount. This makes pages with many Dropdowns or HelpTexts render faster.
-5
View File
@@ -1,5 +0,0 @@
---
"@navikt/ds-react": patch
---
MonthPicker: Arrow-key navigation no longer freezes the page when no later or earlier month is enabled. Keyboard search now respects `fromDate`/`toDate` with both caption types and looks at most 100 years ahead or back.
-5
View File
@@ -1,5 +0,0 @@
---
"@navikt/ds-react": patch
---
DataGrid: Fixed text selection inside details panels when keyboard navigation is enabled.
+5
View File
@@ -0,0 +1,5 @@
---
"@navikt/ds-react": patch
---
DataGrid.Table: Keyboard navigation no longer rebuilds the internal grid map on every arrow key press, improving responsiveness in large tables.
-6
View File
@@ -1,6 +0,0 @@
---
"@navikt/ds-react": minor
"@navikt/ds-css": minor
---
New component: Coach, a tutorial component to guide the user to functionality on a page
+1 -7
View File
@@ -61,14 +61,8 @@ runs:
SANITY_AUTH_TOKEN: ${{ inputs.sanity_deploy_studio }}
shell: bash
- name: Generate RSS feed
run: yarn workspace website update:rss
env:
SANITY_READ_NO_DRAFTS: ${{ inputs.sanity_read_no_drafts_token }}
shell: bash
- name: Build Next.js website
run: yarn workspace website build:with-playroom
run: yarn build:next
env:
SANITY_READ: ${{ inputs.sanity_read_token }}
SANITY_READ_NO_DRAFTS: ${{ inputs.sanity_read_no_drafts_token }}
+2
View File
@@ -19,6 +19,7 @@ updates:
applies-to: version-updates
patterns:
- "@next/eslint-plugin-next"
- "@next/bundle-analyzer"
- "next"
sanity:
applies-to: version-updates
@@ -52,6 +53,7 @@ updates:
applies-to: security-updates
patterns:
- "@next/eslint-plugin-next"
- "@next/bundle-analyzer"
- "next"
sanity-security:
applies-to: security-updates
@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.0001 13.2501C10.4142 13.2501 10.7501 13.5859 10.7501 14.0001V18.0001C10.75 18.4142 10.4142 18.75 10.0001 18.7501C9.58586 18.7501 9.25009 18.4142 9.25006 18.0001V15.8106L5.03033 20.0303C4.73746 20.3232 4.26268 20.3232 3.96978 20.0303C3.67689 19.7374 3.67689 19.2627 3.96978 18.9698L8.18951 14.7501H6.00006C5.58586 14.7501 5.25009 14.4142 5.25006 14.0001C5.25006 13.5858 5.58584 13.2501 6.00006 13.2501H10.0001ZM18.0001 13.2501C18.4142 13.2501 18.7501 13.5859 18.7501 14.0001C18.75 14.4142 18.4142 14.75 18.0001 14.7501H15.8106L20.0303 18.9698C20.3232 19.2627 20.3232 19.7375 20.0303 20.0303C19.7375 20.3232 19.2627 20.3232 18.9698 20.0303L14.7501 15.8106V18.0001C14.75 18.4142 14.4142 18.75 14.0001 18.7501C13.5859 18.7501 13.2501 18.4142 13.2501 18.0001V14.0001C13.2501 13.8011 13.3291 13.6104 13.4698 13.4698C13.6104 13.3291 13.8011 13.2501 14.0001 13.2501H18.0001ZM3.96978 3.96978C4.26268 3.67689 4.73744 3.67689 5.03033 3.96978L9.25006 8.18951V6.00005C9.25006 5.58584 9.58584 5.25005 10.0001 5.25005C10.4142 5.25008 10.7501 5.58586 10.7501 6.00005V10.0001C10.75 10.4142 10.4142 10.75 10.0001 10.7501H6.00006C5.58586 10.7501 5.25009 10.4142 5.25006 10.0001C5.25006 9.58584 5.58584 9.25005 6.00006 9.25005H8.18951L3.96978 5.03033C3.67689 4.73743 3.67689 4.26267 3.96978 3.96978ZM18.9698 3.96978C19.2627 3.67689 19.7374 3.67689 20.0303 3.96978C20.3232 4.26268 20.3232 4.73745 20.0303 5.03033L15.8106 9.25005H18.0001C18.4142 9.25008 18.7501 9.58586 18.7501 10.0001C18.75 10.4142 18.4142 10.75 18.0001 10.7501H14.0001C13.5859 10.7501 13.2501 10.4142 13.2501 10.0001V6.00005C13.2501 5.58584 13.5858 5.25005 14.0001 5.25005C14.4142 5.25008 14.7501 5.58586 14.7501 6.00005V8.18951L18.9698 3.96978Z" fill="#202733"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

@@ -1,12 +0,0 @@
name: ArrowsPointingIn
category: Arrows
sub_category: Arrow
keywords:
- arrows
- piler
- fullscreen
- fullskjerm
- lukk
- close
variant: Stroke
created_at: 31.10.2026
@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.96973 13.9697C9.26262 13.6768 9.73738 13.6768 10.0303 13.9697C10.3232 14.2626 10.3232 14.7374 10.0303 15.0303L5.81055 19.25H8C8.41421 19.25 8.75 19.5858 8.75 20C8.75 20.4142 8.41421 20.75 8 20.75H4C3.80109 20.75 3.61038 20.6709 3.46973 20.5303C3.32907 20.3896 3.25 20.1989 3.25 20V16C3.25 15.5858 3.58579 15.25 4 15.25C4.41421 15.25 4.75 15.5858 4.75 16V18.1895L8.96973 13.9697ZM13.9697 13.9697C14.2626 13.6768 14.7374 13.6768 15.0303 13.9697L19.25 18.1895V16C19.25 15.5858 19.5858 15.25 20 15.25C20.4142 15.25 20.75 15.5858 20.75 16V20C20.75 20.1989 20.6709 20.3896 20.5303 20.5303C20.3896 20.6709 20.1989 20.75 20 20.75H16C15.5858 20.75 15.25 20.4142 15.25 20C15.25 19.5858 15.5858 19.25 16 19.25H18.1895L13.9697 15.0303C13.6768 14.7374 13.6768 14.2626 13.9697 13.9697ZM8 3.25C8.41421 3.25 8.75 3.58579 8.75 4C8.75 4.41421 8.41421 4.75 8 4.75H5.81055L10.0303 8.96973C10.3232 9.26262 10.3232 9.73738 10.0303 10.0303C9.73738 10.3232 9.26262 10.3232 8.96973 10.0303L4.75 5.81055V8C4.75 8.41421 4.41421 8.75 4 8.75C3.58579 8.75 3.25 8.41421 3.25 8V4C3.25 3.58579 3.58579 3.25 4 3.25H8ZM20 3.25C20.4142 3.25 20.75 3.58579 20.75 4V8C20.75 8.41421 20.4142 8.75 20 8.75C19.5858 8.75 19.25 8.41421 19.25 8V5.81055L15.0303 10.0303C14.7374 10.3232 14.2626 10.3232 13.9697 10.0303C13.6768 9.73738 13.6768 9.26262 13.9697 8.96973L18.1895 4.75H16C15.5858 4.75 15.25 4.41421 15.25 4C15.25 3.58579 15.5858 3.25 16 3.25H20Z" fill="#202733"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,12 +0,0 @@
name: ArrowsPointingOut
category: Arrows
sub_category: Arrow
keywords:
- arrows
- piler
- fullscreen
- fullskjerm
- open
- åpne
variant: Stroke
created_at: 31.10.2026
@@ -6,7 +6,5 @@ keywords:
- søkeresultater
- tabell
- trakt
- fjern
- slett
variant: Stroke
created_at: 26.10.2026
@@ -6,7 +6,5 @@ keywords:
- søkeresultater
- tabell
- trakt
- fjern
- slett
variant: Fill
created_at: 26.10.2026
-3
View File
@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 3.25C15.5188 3.25 16.75 4.48122 16.75 6C16.75 6.45058 16.6394 6.87468 16.4473 7.25H19C19.9665 7.25 20.75 8.0335 20.75 9V11C20.75 11.6977 20.3407 12.298 19.75 12.5791V19C19.75 19.9665 18.9665 20.75 18 20.75H6C5.0335 20.75 4.25 19.9665 4.25 19V12.5791C3.65928 12.298 3.25 11.6977 3.25 11V9C3.25 8.0335 4.0335 7.25 5 7.25H7.55273C7.36065 6.87468 7.25 6.45058 7.25 6C7.25 4.48122 8.48122 3.25 10 3.25C10.789 3.25 11.4985 3.58399 12 4.11621C12.5015 3.58399 13.211 3.25 14 3.25ZM5.75 19C5.75 19.1381 5.86193 19.25 6 19.25H11.25V12.75H5.75V19ZM12.75 19.25H18C18.1381 19.25 18.25 19.1381 18.25 19V12.75H12.75V19.25ZM5 8.75C4.86193 8.75 4.75 8.86193 4.75 9V11C4.75 11.1381 4.86193 11.25 5 11.25H11.25V8.75H5ZM12.75 11.25H19C19.1381 11.25 19.25 11.1381 19.25 11V9C19.25 8.86193 19.1381 8.75 19 8.75H12.75V11.25ZM10 4.75C9.30964 4.75 8.75 5.30964 8.75 6C8.75 6.69036 9.30964 7.25 10 7.25H11.25V6C11.25 5.30964 10.6904 4.75 10 4.75ZM14 4.75C13.3096 4.75 12.75 5.30964 12.75 6V7.25H14C14.6904 7.25 15.25 6.69036 15.25 6C15.25 5.30964 14.6904 4.75 14 4.75Z" fill="#202733"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-8
View File
@@ -1,8 +0,0 @@
name: Gift
category: Home
sub_category: Toys
keywords:
- gave
- presang
variant: Stroke
created_at: 31.10.2026
-3
View File
@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.56055 13.2197C4.70421 13.2397 4.85088 13.25 5 13.25H11C11.1381 13.25 11.25 13.3619 11.25 13.5V20.5C11.25 20.6381 11.1381 20.75 11 20.75H6C5.0335 20.75 4.25 19.9665 4.25 19V13.4775C4.25 13.3162 4.40077 13.1975 4.56055 13.2197ZM19.4395 13.2197C19.5992 13.1975 19.75 13.3162 19.75 13.4775V19C19.75 19.9665 18.9665 20.75 18 20.75H13C12.8619 20.75 12.75 20.6381 12.75 20.5V13.5C12.75 13.3619 12.8619 13.25 13 13.25H19C19.1491 13.25 19.2958 13.2397 19.4395 13.2197ZM14 3.25C15.5188 3.25 16.75 4.48122 16.75 6C16.75 6.45058 16.6394 6.87468 16.4473 7.25H19C19.9665 7.25 20.75 8.0335 20.75 9V10C20.75 10.9665 19.9665 11.75 19 11.75H13C12.8619 11.75 12.75 11.6381 12.75 11.5V7.25H14C14.6904 7.25 15.25 6.69036 15.25 6C15.25 5.30964 14.6904 4.75 14 4.75C13.3096 4.75 12.75 5.30964 12.75 6V7.24707H11.25V6C11.25 5.30964 10.6904 4.75 10 4.75C9.30964 4.75 8.75 5.30964 8.75 6C8.75 6.69036 9.30964 7.25 10 7.25H11.25V11.5C11.25 11.6381 11.1381 11.75 11 11.75H5C4.0335 11.75 3.25 10.9665 3.25 10V9C3.25 8.0335 4.0335 7.25 5 7.25H7.55273C7.36065 6.87468 7.25 6.45058 7.25 6C7.25 4.48122 8.48122 3.25 10 3.25C10.789 3.25 11.4985 3.58399 12 4.11621C12.5015 3.58399 13.211 3.25 14 3.25Z" fill="#202733"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-8
View File
@@ -1,8 +0,0 @@
name: GiftFill
category: Home
sub_category: Toys
keywords:
- gave
- presang
variant: Fill
created_at: 31.10.2026
+1 -1
View File
@@ -2,6 +2,6 @@ name: Keyboard
category: Interface
sub_category: Devices
keywords:
- Tastatur
- Keyboard
variant: Stroke
created_at: 26.10.2026
+1 -1
View File
@@ -2,6 +2,6 @@ name: KeyboardFill
category: Interface
sub_category: Devices
keywords:
- Tastatur
- KeyboardFill
variant: Fill
created_at: 26.10.2026
@@ -2,9 +2,6 @@ name: TableCellsMerge
category: Interface
sub_category: Table
keywords:
- celle
- slå sammen
- tabell
- datagrid
- TableCellsMerge
variant: Stroke
created_at: 26.10.2026
@@ -2,9 +2,6 @@ name: TableCellsMergeFill
category: Interface
sub_category: Table
keywords:
- celle
- slå sammen
- tabell
- datagrid
- TableCellsMergeFill
variant: Fill
created_at: 26.10.2026
@@ -2,9 +2,6 @@ name: TableCellsSplit
category: Interface
sub_category: Table
keywords:
- celle
- splitt
- tabell
- datagrid
- TableCellsSplit
variant: Stroke
created_at: 26.10.2026
@@ -2,9 +2,6 @@ name: TableCellsSplitFill
category: Interface
sub_category: Table
keywords:
- celle
- splitt
- tabell
- datagrid
- TableCellsSplitFill
variant: Fill
created_at: 26.10.2026
+1 -4
View File
@@ -2,9 +2,6 @@ name: TableCross
category: Interface
sub_category: Table
keywords:
- tabell
- datagrid
- fjern
- slett
- TableCross
variant: Stroke
created_at: 26.10.2026
+1 -4
View File
@@ -2,9 +2,6 @@ name: TableCrossFill
category: Interface
sub_category: Table
keywords:
- tabell
- datagrid
- fjern
- slett
- TableCrossFill
variant: Fill
created_at: 26.10.2026
@@ -2,9 +2,6 @@ name: TableDeleteColumn
category: Interface
sub_category: Table
keywords:
- slett kolonne
- tabell
- datagrid
- fjern
- TableDeleteColumn
variant: Stroke
created_at: 26.10.2026
@@ -2,9 +2,6 @@ name: TableDeleteColumnFill
category: Interface
sub_category: Table
keywords:
- slett kolonne
- tabell
- datagrid
- fjern
- TableDeleteColumnFill
variant: Fill
created_at: 26.10.2026
+1 -4
View File
@@ -2,9 +2,6 @@ name: TableDeleteRow
category: Interface
sub_category: Table
keywords:
- slett rad
- tabell
- datagrid
- fjern
- TableDeleteRow
variant: Stroke
created_at: 26.10.2026
@@ -2,9 +2,6 @@ name: TableDeleteRowFill
category: Interface
sub_category: Table
keywords:
- slett rad
- tabell
- datagrid
- fjern
- TableDeleteRowFill
variant: Fill
created_at: 26.10.2026
+1 -2
View File
@@ -2,7 +2,6 @@ name: TableGrid
category: Interface
sub_category: Table
keywords:
- tabell
- datagrid
- TableGrid
variant: Stroke
created_at: 26.10.2026
+1 -2
View File
@@ -2,7 +2,6 @@ name: TableGridFill
category: Interface
sub_category: Table
keywords:
- tabell
- datagrid
- TableGridFill
variant: Fill
created_at: 26.10.2026
@@ -2,9 +2,6 @@ name: TableInsertColumnLeft
category: Interface
sub_category: Table
keywords:
- ny kolonne
- venstre
- tabell
- datagrid
- TableInsertColumnLeft
variant: Stroke
created_at: 26.10.2026
@@ -2,9 +2,6 @@ name: TableInsertColumnLeftFill
category: Interface
sub_category: Table
keywords:
- ny kolonne
- venstre
- tabell
- datagrid
- TableInsertColumnLeftFill
variant: Fill
created_at: 26.10.2026
@@ -2,9 +2,6 @@ name: TableInsertColumnRight
category: Interface
sub_category: Table
keywords:
- ny kolonne
- høyre
- tabell
- datagrid
- TableInsertColumnRight
variant: Stroke
created_at: 26.10.2026
@@ -2,9 +2,6 @@ name: TableInsertColumnRightFill
category: Interface
sub_category: Table
keywords:
- ny kolonne
- høyre
- tabell
- datagrid
- TableInsertColumnRightFill
variant: Fill
created_at: 26.10.2026
@@ -2,9 +2,6 @@ name: TableInsertRowAbove
category: Interface
sub_category: Table
keywords:
- ny rad
- over
- tabell
- datagrid
- TableInsertRowAbove
variant: Stroke
created_at: 26.10.2026
@@ -2,9 +2,6 @@ name: TableInsertRowAboveFill
category: Interface
sub_category: Table
keywords:
- ny rad
- over
- tabell
- datagrid
- TableInsertRowAboveFill
variant: Fill
created_at: 26.10.2026
@@ -2,9 +2,6 @@ name: TableInsertRowBelow
category: Interface
sub_category: Table
keywords:
- ny rad
- under
- tabell
- datagrid
- TableInsertRowBelow
variant: Stroke
created_at: 26.10.2026
@@ -2,9 +2,6 @@ name: TableInsertRowBelowFill
category: Interface
sub_category: Table
keywords:
- ny rad
- under
- tabell
- datagrid
- TableInsertRowBelowFill
variant: Fill
created_at: 26.10.2026
+1 -4
View File
@@ -2,9 +2,6 @@ name: TablePlus
category: Interface
sub_category: Table
keywords:
- tabell
- legg til
- ny
- datagrid
- TablePlus
variant: Stroke
created_at: 26.10.2026
+1 -4
View File
@@ -2,9 +2,6 @@ name: TablePlusFill
category: Interface
sub_category: Table
keywords:
- tabell
- legg til
- ny
- datagrid
- TablePlusFill
variant: Fill
created_at: 26.10.2026
+1 -1
View File
@@ -91,6 +91,6 @@
"tsx": "^4.23.15",
"typescript": "6.0.3",
"unified": "11.0.5",
"vitest": "5.0.5"
"vitest": "4.1.11"
}
}
+1 -1
View File
@@ -49,7 +49,7 @@
"postcss-selector-parser": "^7.1.7",
"postcss-value-parser": "^4.2.0",
"stylelint": "^17.15.0",
"stylelint-test-rule-node": "^1.3.0",
"stylelint-test-rule-node": "^1.2.0",
"tsx": "^4.23.15",
"typescript": "6.0.3"
}
+1 -1
View File
@@ -58,7 +58,7 @@
"@types/lodash": "4.17.25",
"rimraf": "6.1.3",
"typescript": "6.0.3",
"vitest": "5.0.5"
"vitest": "4.1.11"
},
"sideEffects": false,
"engines": {
-156
View File
@@ -1,156 +0,0 @@
.aksel-coach__popover {
z-index: 2000;
overflow: hidden;
}
.aksel-coach__popover_content {
--__axc-coach__content-width: calc(var(--ax-space-128) + var(--ax-space-128) + var(--ax-space-128) + var(--ax-space-16));
&[data-coach-step-width="small"] {
--__axc-coach__content-width: calc(var(--ax-space-128) + var(--ax-space-128) + var(--ax-space-24));
}
&[data-coach-step-size="small"] {
padding-block: var(--ax-space-12);
padding-inline: var(--ax-space-16);
}
width: var(--__axc-coach__content-width);
/* Creates a small gutter between screen-edges on smaller devices */
max-width: calc(100vw - var(--ax-space-24));
overflow: auto;
}
.aksel-coach__popover_content,
.aksel-coach__dialog-body {
--axc-coach__top-bar-padding-b-e: var(--ax-space-12);
--axc-coach__image-padding-b-e: var(--ax-space-16);
&[data-coach-step-size="small"] {
--axc-coach__top-bar-padding-b-e: var(--ax-space-8);
--axc-coach__image-padding-b-e: var(--ax-space-12);
}
}
.aksel-coach__top-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding-block-end: var(--axc-coach__top-bar-padding-b-e);
}
.aksel-coach__title,
.aksel-coach__description {
padding-block-end: var(--ax-space-8);
}
.aksel-coach__footer {
justify-content: flex-end;
display: flex;
flex-flow: wrap;
gap: var(--ax-space-8);
padding-block-start: var(--ax-space-16);
}
.aksel-coach__image-container {
overflow: hidden;
grid-area: image;
position: relative;
padding-block-end: var(--axc-coach__image-padding-b-e);
display: flex;
& :is(img, picture, svg, video) {
display: block;
object-fit: cover;
max-width: 100%;
height: 100%;
object-position: center;
}
}
.aksel-coach__mark-anchor {
position: relative;
display: inline-flex;
flex-shrink: 0;
width: fit-content;
align-items: flex-start;
}
.aksel-coach__mark-anchor > .aksel-coach__mark-container {
position: absolute;
}
.aksel-coach__mark-container {
--__axc-coach__mark-size: var(--ax-space-16);
--__axc-coach__mark-color: var(--ax-bg-strong);
all: unset;
cursor: pointer;
position: relative;
width: calc(var(--__axc-coach__mark-size) * 2);
height: calc(var(--__axc-coach__mark-size) * 2);
display: flex;
justify-content: center;
align-items: center;
border-radius: var(--ax-radius-full);
inset-block-start: 0;
inset-inline-end: 0;
&:focus-visible {
outline: 3px solid var(--ax-border-focus);
outline-offset: 3px;
}
}
.aksel-coach__mark {
position: absolute;
width: var(--__axc-coach__mark-size);
height: var(--__axc-coach__mark-size);
background-color: var(--__axc-coach__mark-color);
border-radius: var(--ax-radius-full);
border: 2px solid var(--ax-bg-default);
@media (forced-colors: active) {
forced-color-adjust: none;
background: canvastext;
color: canvas;
}
}
.aksel-coach__mark-pulse {
position: absolute;
width: var(--__axc-coach__mark-size);
height: var(--__axc-coach__mark-size);
background-color: var(--__axc-coach__mark-color);
border-radius: var(--ax-radius-full);
animation: pulse 3s infinite;
@media (forced-colors: active) {
forced-color-adjust: none;
background: canvastext;
color: canvas;
}
}
@keyframes pulse {
0% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(2);
opacity: 0;
}
}
.aksel-coach__backdrop {
/* Match the viewport coordinates used by the spotlight clip path. */
position: fixed;
pointer-events: none;
min-height: 100dvh;
inset: 0;
background-color: var(--ax-bg-overlay);
transition: opacity 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
z-index: 2000;
}
-1
View File
@@ -62,7 +62,6 @@
@import "./table.css" layer(aksel.components.modules);
@import "./tabs.css" layer(aksel.components.modules);
@import "./list.css" layer(aksel.components.modules);
@import "./coach.css" layer(aksel.components.modules);
/* --------------------------------- Layout --------------------------------- */
@import "./primitives/index.css" layer(aksel.layout);
+2 -12
View File
@@ -169,16 +169,6 @@
"default": "./cjs/chat/index.js"
}
},
"./Coach": {
"import": {
"types": "./esm/coach/index.d.ts",
"default": "./esm/coach/index.js"
},
"require": {
"types": "./cjs/coach/index.d.ts",
"default": "./cjs/coach/index.js"
}
},
"./Chips": {
"import": {
"types": "./esm/chips/index.d.ts",
@@ -816,7 +806,7 @@
"copyfiles": "^2.4.1",
"cross-env": "^10.1.0",
"jscodeshift": "17.4.0",
"jsdom": "30.1.3",
"jsdom": "29.1.1",
"react": "19.3.1",
"react-dom": "19.3.1",
"rimraf": "6.1.3",
@@ -824,7 +814,7 @@
"tsc-alias": "1.9.7",
"tsx": "^4.23.15",
"typescript": "6.0.3",
"vitest": "5.0.5"
"vitest": "4.1.11"
},
"peerDependencies": {
"@types/react": "^17.0.30 || ^18 || ^19",
@@ -1,33 +0,0 @@
import type { ComponentMetadata } from "../utils/types/metadata";
import { Coach } from "./index";
const metadata: ComponentMetadata = {
name: "Coach",
components: {
Coach,
"Coach.Step": Coach.Step,
"Coach.Mark": Coach.Mark,
"Coach.Progress": Coach.Progress,
"Coach.Title": Coach.Title,
"Coach.Description": Coach.Description,
"Coach.Footer": Coach.Footer,
"Coach.NextTrigger": Coach.NextTrigger,
"Coach.PreviousTrigger": Coach.PreviousTrigger,
"Coach.CloseTrigger": Coach.CloseTrigger,
"Coach.Image": Coach.Image,
},
keywords: [
"coach",
"dialog",
"popup",
"step",
"guide",
"tutorial",
"walkthrough",
"onboarding",
"tour",
"coachmark",
],
};
export { metadata };
@@ -1,552 +0,0 @@
import type { Meta, StoryFn, StoryObj } from "@storybook/react-vite";
import React, { useRef, useState } from "react";
import {
EnvelopeClosedIcon,
EnvelopeOpenIcon,
PaperplaneIcon,
} from "@navikt/aksel-icons";
import { Button } from "../button";
import { Chips } from "../chips";
import { ExpansionCard } from "../expansion-card";
import { Search } from "../form/search";
import { GuidePanel } from "../guide-panel";
import { InfoCard } from "../info-card";
import { HStack, VStack } from "../primitives/stack";
import { Provider } from "../provider";
import { ToggleGroup } from "../toggle-group";
import { BodyShort } from "../typography";
import { en } from "../utils/i18n/locales";
import { Coach, type CoachMark } from "./root/CoachRoot";
export default {
title: "ds-react/Coach",
component: Coach,
parameters: {
chromatic: { disable: true },
},
decorators: [
(Story) => (
<div style={{ minHeight: "5000px" }}>
<Story />
</div>
),
],
} satisfies Meta<typeof Coach>;
export const CoachAnchor: StoryFn<typeof Coach> = () => {
const createRef = useRef<HTMLButtonElement>(null);
const reviewRef = useRef<HTMLButtonElement>(null);
return (
<HStack gap="space-16">
<Button ref={reviewRef}>Review</Button>
<Coach
steps={[
{
id: "step-1",
type: "anchor",
anchorRef: createRef,
title: "Create",
description: "Start by creating a new item.",
},
{
id: "step-2",
type: "anchor",
anchorRef: reviewRef,
placement: "bottom-start",
title: "Review",
description: "Review your item before submitting it.",
},
]}
>
<Coach.Mark>
<Button ref={createRef}>Create</Button>
</Coach.Mark>
</Coach>
</HStack>
);
};
export const MissingAnchor: StoryFn<typeof Coach> = () => {
const anchorRef = useRef<HTMLButtonElement>(null);
const missingRef = useRef<HTMLButtonElement>(null);
return (
<Coach
steps={[
{
id: "step-1",
type: "anchor",
anchorRef,
title: "Anchor",
description: "This step has a valid anchor.",
},
{
id: "step-2",
type: "anchor",
anchorRef: missingRef,
placement: "bottom-start",
title: "Missing anchor",
description: "This step has a missing anchor.",
},
]}
>
<Coach.Mark>
<Button ref={anchorRef}>Create</Button>
</Coach.Mark>
</Coach>
);
};
export const CoachDialog: StoryFn<typeof Coach> = () => {
return (
<HStack gap="space-16">
<Coach
steps={[
{
id: "step-1",
type: "dialog",
title: "Welcome",
description: "Welcome to the new experience.",
},
{
id: "step-2",
type: "dialog",
title: "Changes",
description: "Here is what changed since last time.",
},
]}
>
<Coach.Mark>
<Button>Next step</Button>
</Coach.Mark>
</Coach>
</HStack>
);
};
export const CoachMixed: StoryFn<typeof Coach> = () => {
const dashboardRef = useRef<HTMLButtonElement>(null);
const settingsRef = useRef<HTMLButtonElement>(null);
return (
<VStack
gap="space-16"
align="start"
justify="space-between"
height="5000px"
width="500px"
>
<Coach
steps={[
{
id: "step-1",
type: "dialog",
size: "small",
width: "small",
allowToEndTour: true,
image: (
<img
src="https://i.pinimg.com/originals/59/54/b4/5954b408c66525ad932faa693a647e3f.jpg"
alt="Dashboard overview"
/>
),
title: "Dashboard",
description: "This tour takes 30 seconds.",
},
{
id: "step-2",
type: "anchor",
size: "small",
anchorRef: dashboardRef,
title: "Dashboard",
description: "This tour takes 30 seconds.",
image: (
<img
src="https://thefunnybeaver.com/wp-content/uploads/2019/11/funny-grey-blep.jpg"
alt="Dashboard overview"
/>
),
},
{
id: "step-3",
type: "anchor",
anchorRef: settingsRef,
placement: "left",
allowToEndTour: true,
width: "small",
title: "Settings",
description: "This tour takes 30 seconds.",
/*image: (
<img
src="https://i.pinimg.com/originals/b3/ee/c0/b3eec03459b57860fe1f898b7584683b.jpg"
alt="Settings overview"
/>
),*/
},
]}
>
<Coach.Mark>
<Button ref={dashboardRef}>Dashboard</Button>
</Coach.Mark>
</Coach>
<HStack justify="end" width="100%">
<Button ref={settingsRef}>Settings</Button>
</HStack>
</VStack>
);
};
export const SizeAndWidth: StoryObj<typeof Coach> = {
render: (props) => {
const buttonRef = useRef(null);
return (
<Coach
steps={[
{
id: "render_step",
type: "dialog",
title: "This is a title",
description: "This is some text",
width: props.width,
size: props.size,
},
{
id: "render_step_2",
type: "anchor",
anchorRef: buttonRef,
title: "This is a title",
width: props.width,
size: props.size,
description: "This is some text",
image: (
<img
src="https://i.ytimg.com/vi/1M_oZgVBknw/maxresdefault.jpg"
alt="Render step overview"
/>
),
},
]}
>
<Coach.Mark>
<Button ref={buttonRef}>Button</Button>
</Coach.Mark>
</Coach>
);
},
args: {
size: "medium",
width: "medium",
},
argTypes: {
size: {
control: { type: "select" },
options: ["small", "medium"],
},
width: {
control: { type: "select" },
options: ["small", "medium"],
},
},
};
export const RenderStep: StoryFn<typeof Coach> = () => {
return (
<Coach
renderStep={(step, context) => {
if (context.index === 0) {
return context.defaultContent;
}
return (
<Coach.Step>
<Coach.Title>{step.title}</Coach.Title>
<Coach.Image>{step.image}</Coach.Image>
<Coach.Progress />
<Coach.Description>{step.description}</Coach.Description>
<Coach.Footer>
<Coach.PreviousTrigger>
<Button variant="secondary">Forrige</Button>
</Coach.PreviousTrigger>
<Coach.CloseTrigger>
<Button>Lukk</Button>
</Coach.CloseTrigger>
</Coach.Footer>
</Coach.Step>
);
}}
steps={[
{
id: "render_step",
type: "dialog",
title: "Normal step",
description: "This step demonstrates standard step.",
},
{
id: "render_step_2",
type: "dialog",
title: "Render Step",
description: "This step demonstrates custom renderStep.",
image: (
<img
src="https://i.ytimg.com/vi/1M_oZgVBknw/maxresdefault.jpg"
alt="Render step overview"
/>
),
},
]}
>
<Coach.Mark>
<Button>Feature</Button>
</Coach.Mark>
</Coach>
);
};
export const Mark: StoryObj<typeof CoachMark> = {
render: (props) => {
const [selected, setSelected] = useState(["Nittedal", "Arendal"]);
const options = [
"Lillehammer",
"Nittedal",
"Enebakk",
"Hamar",
"Arendal",
"Gjøvik",
];
return (
<VStack padding="space-40" gap="space-64">
<Coach type="dialog" title="Explore the page">
<Coach.Mark offset={props.offset}>
<ToggleGroup defaultValue="lest" onChange={console.info}>
<ToggleGroup.Item
value="ulest"
icon={<EnvelopeClosedIcon aria-hidden />}
label="Ulest"
/>
<ToggleGroup.Item
value="lest"
icon={<EnvelopeOpenIcon aria-hidden />}
label="Lest"
/>
<ToggleGroup.Item
value="sendt"
icon={<PaperplaneIcon aria-hidden />}
label="Sendt"
/>
</ToggleGroup>
</Coach.Mark>
<Coach.Mark offset={props.offset}>
<Button onClick={() => console.log("Button clicked")}>
Click me
</Button>
</Coach.Mark>
<Coach.Mark offset={props.offset}>
<Chips>
{options.map((option) => (
<Chips.Toggle
key={option}
selected={selected.includes(option)}
onClick={() =>
setSelected(
selected.includes(option)
? selected.filter((x) => x !== option)
: [...selected, option],
)
}
>
{option}
</Chips.Toggle>
))}
</Chips>
</Coach.Mark>
<Coach.Mark offset={props.offset}>
<GuidePanel>
Saksbehandlingstiden varierer fra kommune til kommune. Hvis det
går mer enn X måneder siden du søkte, skal du få brev om at
saksbehandlingstiden er forlenget.
</GuidePanel>
</Coach.Mark>
<Coach.Mark
onClick={() => console.log("Coach dot clicked")}
offset={props.offset}
>
<ExpansionCard aria-label="Demo med bare tittel">
<ExpansionCard.Header>
<ExpansionCard.Title>
Utbetaling av sykepenger
</ExpansionCard.Title>
</ExpansionCard.Header>
<ExpansionCard.Content>
<p>Her er innholdet i ekspansjonskortet.</p>
</ExpansionCard.Content>
</ExpansionCard>
</Coach.Mark>
<Coach.Mark offset={props.offset}>
<InfoCard data-color="info">
<InfoCard.Header>
<InfoCard.Title>Fremhevet informasjon</InfoCard.Title>
</InfoCard.Header>
<InfoCard.Content>
InfoCard brukes for å fremheve informasjon på en side, uten at
det er like kritisk som en alert.
</InfoCard.Content>
</InfoCard>
</Coach.Mark>
<Coach.Mark data-color="success" />
</Coach>
</VStack>
);
},
args: {
offset: "space-16",
},
};
export const Translations: StoryFn<typeof Coach> = () => {
const translations = {
Coach: {
currentStep: "{current}/{total}",
next: "Next",
previous: "Previous",
close: "Close",
},
};
return (
<Provider locale={en} translations={translations}>
<Coach
steps={[
{
id: "progress_translation",
type: "dialog",
title: "Progress Translations",
description: "This step demonstrates translations.",
},
{
id: "progress_translation_2",
type: "dialog",
title: "Translation Test",
description: "See the translation above and on the buttons.",
},
]}
>
<Coach.Mark>
<Button>Feature</Button>
</Coach.Mark>
</Coach>
</Provider>
);
};
export const SingleStepAnchor: StoryFn<typeof Coach> = () => {
const buttonRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
return (
<Coach
showTour={open}
onShowTourChange={setOpen}
type="anchor"
anchorRef={buttonRef}
title="Enkeltsteg"
description="Dette er en enkeltstående coachmark uten steps-prop."
>
<Coach.Mark>
<Button ref={buttonRef} onClick={() => setOpen((prev) => !prev)}>
Feature
</Button>
</Coach.Mark>
</Coach>
);
};
export const SingleStepRenderStep: StoryFn<typeof Coach> = () => {
const buttonRef = useRef<HTMLButtonElement>(null);
return (
<Coach
type="anchor"
anchorRef={buttonRef}
title="Opprett ny"
description="Standardtekst som kan brukes eller overstyres."
renderStep={({ title, description }) => (
<Coach.Step>
<Coach.Title>{title}</Coach.Title>
<Coach.Description>{description}</Coach.Description>
<BodyShort>Egendefinert innhold her!</BodyShort>
<Coach.Footer>
<Coach.CloseTrigger>
<Button size="medium" variant="primary">
Forstått
</Button>
</Coach.CloseTrigger>
</Coach.Footer>
</Coach.Step>
)}
>
<Coach.Mark>
<Button ref={buttonRef}>Feature</Button>
</Coach.Mark>
</Coach>
);
};
export const CoachControlled: StoryFn<typeof Coach> = () => {
const buttonRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
return (
<>
<Button ref={buttonRef} onClick={() => setOpen((prev) => !prev)}>
Toggle tour
</Button>
<Coach
showTour={open}
onShowTourChange={setOpen}
type="anchor"
anchorRef={buttonRef}
title="Kontrollert coach"
description="Denne coachen styres av state."
/>
</>
);
};
export const KeyboardNavigation: StoryFn<typeof Coach> = () => {
const dashboardRef = useRef<HTMLButtonElement>(null);
return (
<Coach
steps={[
{
id: "step-1",
type: "dialog",
size: "small",
width: "small",
allowToEndTour: true,
image: (
<img
src="https://i.pinimg.com/originals/59/54/b4/5954b408c66525ad932faa693a647e3f.jpg"
alt="Dashboard overview"
/>
),
title: "Dashboard",
description: <Search label="Search" />,
},
{
id: "step-2",
type: "anchor",
size: "small",
allowToEndTour: true,
anchorRef: dashboardRef,
title: "Dashboard",
description: <Search label="Search" />,
},
]}
>
<Coach.Mark>
<Button ref={dashboardRef}>Dashboard</Button>
</Coach.Mark>
</Coach>
);
};
-449
View File
@@ -1,449 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { consoleWarning } from "../utils/helpers/consoleWarning";
import { Coach } from "./index";
import { CoachContextProvider } from "./root/Coach.context";
import type { CoachStepItem } from "./types";
import { missingTriggersWarning } from "./warnings";
vi.mock("../utils/helpers/consoleWarning", () => ({
consoleWarning: vi.fn(),
}));
function createContext(
overrides: Partial<React.ComponentProps<typeof CoachContextProvider>> = {},
) {
return {
showTour: true,
onClose: vi.fn(),
currentStep: { id: "first", type: "dialog" as const },
currentStepIndex: 0,
totalSteps: 3,
currentStepSize: "medium" as const,
goToNextStep: vi.fn(),
goToPreviousStep: vi.fn(),
setTitleId: vi.fn(),
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("Coach anchor warnings", () => {
test("warns when an active anchor step has no mounted anchor", () => {
render(
<Coach
type="anchor"
id="missing-anchor"
anchorRef={React.createRef<HTMLButtonElement>()}
showTour
/>,
);
expect(consoleWarning).toHaveBeenCalledWith(
'Coach step "missing-anchor" cannot find its anchor. Attach anchorRef to a mounted element before showing this step.',
);
});
test("does not warn about a missing anchor while the tour is hidden", () => {
render(
<Coach
type="anchor"
anchorRef={React.createRef<HTMLButtonElement>()}
showTour={false}
/>,
);
expect(consoleWarning).not.toHaveBeenCalled();
});
test("waits for anchor refs to attach during the commit", () => {
const anchorRef = React.createRef<HTMLButtonElement>();
render(
<>
<Coach type="anchor" anchorRef={anchorRef} showTour />
<button ref={anchorRef} type="button">
Target
</button>
</>,
);
expect(consoleWarning).not.toHaveBeenCalled();
});
});
describe("Coach invalid steps", () => {
test.each([false, true])(
"does not render empty steps when showTour is %s",
(showTour) => {
const renderStep = vi.fn(({ id }: { id: string }) => id.toUpperCase());
render(<Coach steps={[]} showTour={showTour} renderStep={renderStep} />);
expect(renderStep).not.toHaveBeenCalled();
expect(screen.queryByRole("dialog")).toBeNull();
expect(consoleWarning).toHaveBeenCalledWith(
expect.stringContaining("cannot find a step at index 0 in 0 steps"),
);
},
);
test.each([-1, 2, 0.5, NaN])(
"warns for invalid controlled index %s",
(currentStep) => {
const renderStep = vi.fn();
render(
<Coach
steps={[{ id: "first", type: "dialog" }]}
currentStep={currentStep}
renderStep={renderStep}
/>,
);
expect(renderStep).not.toHaveBeenCalled();
expect(consoleWarning).toHaveBeenCalledWith(
expect.stringContaining(`index ${currentStep}`),
);
},
);
test("does not call the renderer after steps shrink, and recovers when the step exists again", () => {
const steps: CoachStepItem[] = [
{ id: "first", type: "dialog" },
{ id: "second", type: "dialog" },
];
const renderStep = vi.fn(({ id }: { id: string }) => id.toUpperCase());
const { rerender } = render(
<Coach steps={steps} defaultStep={1} renderStep={renderStep} />,
);
expect(renderStep).toHaveBeenCalledWith(
expect.objectContaining({ id: "second" }),
expect.anything(),
);
renderStep.mockClear();
rerender(
<Coach
steps={steps.slice(0, 1)}
defaultStep={1}
renderStep={renderStep}
/>,
);
expect(renderStep).not.toHaveBeenCalled();
expect(consoleWarning).toHaveBeenCalledWith(
expect.stringContaining("index 1 in 1 steps"),
);
rerender(<Coach steps={steps} defaultStep={1} renderStep={renderStep} />);
expect(renderStep).toHaveBeenCalledWith(
expect.objectContaining({ id: "second" }),
expect.anything(),
);
});
});
describe("Coach.Mark", () => {
test("opens the tour without triggering its anchor click handler", async () => {
const user = userEvent.setup();
const openTour = vi.fn();
const onClick = vi.fn();
const onAnchorClick = vi.fn();
const ref = React.createRef<HTMLButtonElement>();
render(
<CoachContextProvider {...createContext({ openTour })}>
<Coach.Mark ref={ref} aria-label="Show help" onClick={onClick}>
<button type="button" onClick={onAnchorClick}>
Target
</button>
</Coach.Mark>
</CoachContextProvider>,
);
const mark = screen.getByRole("button", { name: "Show help" });
expect(ref.current).toBe(mark);
expect(mark).toHaveAttribute("aria-haspopup", "dialog");
await user.click(mark);
expect(onClick).toHaveBeenCalledTimes(1);
expect(openTour).toHaveBeenCalledTimes(1);
expect(onAnchorClick).not.toHaveBeenCalled();
});
test("allows the consumer to cancel opening", async () => {
const user = userEvent.setup();
const openTour = vi.fn();
render(
<CoachContextProvider {...createContext({ openTour })}>
<Coach.Mark
aria-label="Show help"
onClick={(event) => event.preventDefault()}
/>
</CoachContextProvider>,
);
await user.click(screen.getByRole("button", { name: "Show help" }));
expect(openTour).not.toHaveBeenCalled();
});
test("hides disabled marks without hiding their anchor", () => {
render(
<CoachContextProvider {...createContext({ disableTour: true })}>
<Coach.Mark aria-label="Show help">
<button type="button">Target</button>
</Coach.Mark>
</CoachContextProvider>,
);
expect(screen.queryByRole("button", { name: "Show help" })).toBeNull();
expect(screen.getByRole("button", { name: "Target" })).toBeVisible();
});
});
describe.each([
{
name: "NextTrigger",
Trigger: Coach.NextTrigger,
action: "goToNextStep" as const,
},
{
name: "PreviousTrigger",
Trigger: Coach.PreviousTrigger,
action: "goToPreviousStep" as const,
},
{
name: "CloseTrigger",
Trigger: Coach.CloseTrigger,
action: "onClose" as const,
},
])("Coach.$name", ({ Trigger, action }) => {
test("composes handlers and forwards both refs to the child button", async () => {
const user = userEvent.setup();
const context = createContext();
const onTriggerClick = vi.fn();
const onChildClick = vi.fn();
const triggerRef = React.createRef<HTMLButtonElement>();
const childRef = React.createRef<HTMLButtonElement>();
render(
<CoachContextProvider {...context}>
<Trigger ref={triggerRef} onClick={onTriggerClick}>
<button type="button" ref={childRef} onClick={onChildClick}>
Action
</button>
</Trigger>
</CoachContextProvider>,
);
const button = screen.getByRole("button", { name: "Action" });
expect(triggerRef.current).toBe(button);
expect(childRef.current).toBe(button);
expect(button).toHaveAttribute("type", "button");
await user.click(button);
expect(onTriggerClick).toHaveBeenCalledTimes(1);
expect(onChildClick).toHaveBeenCalledTimes(1);
expect(context[action]).toHaveBeenCalledTimes(1);
});
test.each(["trigger", "child"])(
"allows the %s click handler to cancel the action",
async (source) => {
const user = userEvent.setup();
const context = createContext();
const preventDefault = (event: React.MouseEvent<HTMLButtonElement>) =>
event.preventDefault();
render(
<CoachContextProvider {...context}>
<Trigger onClick={source === "trigger" ? preventDefault : undefined}>
<button
type="button"
onClick={source === "child" ? preventDefault : undefined}
>
Action
</button>
</Trigger>
</CoachContextProvider>,
);
await user.click(screen.getByRole("button", { name: "Action" }));
expect(context[action]).not.toHaveBeenCalled();
},
);
});
describe("Coach.Title", () => {
test.each([undefined, "custom-title"])(
"registers its title id (%s) and clears it on unmount",
(id) => {
const context = createContext();
const titleRef = React.createRef<HTMLHeadingElement>();
const { unmount } = render(
<CoachContextProvider {...context}>
<Coach.Title id={id} ref={titleRef}>
Welcome
</Coach.Title>
</CoachContextProvider>,
);
const title = screen.getByRole("heading", { name: "Welcome", level: 1 });
expect(title.id).not.toBe("");
const expectedId = id ?? title.id;
expect(title).toHaveAttribute("id", expectedId);
expect(titleRef.current).toBe(title);
expect(context.setTitleId).toHaveBeenLastCalledWith(title.id);
unmount();
expect(context.setTitleId).toHaveBeenLastCalledWith(undefined);
},
);
});
describe("Coach.Progress", () => {
test("uses one-based numbering and supports custom translations", () => {
const context = createContext({ currentStepIndex: 1 });
render(
<CoachContextProvider {...context}>
<Coach.Progress
translations={{ currentStep: "Step {current}/{total}" }}
/>
</CoachContextProvider>,
);
expect(screen.getByText("Step 2/3")).toBeInTheDocument();
});
test.each([0, 1])("hides progress for %s steps", (totalSteps) => {
const context = createContext({ totalSteps });
render(
<CoachContextProvider {...context}>
<Coach.Progress
translations={{ currentStep: "Step {current}/{total}" }}
/>
</CoachContextProvider>,
);
expect(screen.queryByText(/^Step /)).not.toBeInTheDocument();
});
});
describe("missingTriggersWarning", () => {
function createPopup({
id = "current",
next = false,
previous = false,
close = false,
topClose = false,
} = {}) {
render(
<div role="dialog" data-coach-step-id={id}>
{next && (
<button type="button" data-coach-next-trigger>
Next
</button>
)}
{previous && (
<button type="button" data-coach-previous-trigger>
Previous
</button>
)}
{close && (
<button type="button" data-coach-close-trigger>
Close
</button>
)}
{topClose && (
<button
type="button"
data-coach-close-trigger
data-coach-top-close-trigger
>
Dismiss
</button>
)}
</div>,
);
return screen.getByRole("dialog");
}
const step: CoachStepItem = { id: "current", type: "dialog" };
test.each([
{ index: 0, total: 3, triggers: ["NextTrigger"] },
{ index: 1, total: 3, triggers: ["NextTrigger", "PreviousTrigger"] },
{ index: 2, total: 3, triggers: ["PreviousTrigger", "CloseTrigger"] },
{ index: 0, total: 1, triggers: ["CloseTrigger"] },
])(
"requires $triggers on step $index of $total",
({ index, total, triggers }) => {
missingTriggersWarning(index, total, step, createPopup());
expect(consoleWarning).toHaveBeenCalledTimes(triggers.length);
for (const trigger of triggers) {
expect(consoleWarning).toHaveBeenCalledWith(
expect.stringContaining(`<Coach.${trigger}>`),
);
}
},
);
test.each([
{ index: 0, total: 3, next: true },
{ index: 1, total: 3, next: true, previous: true },
{ index: 2, total: 3, previous: true, close: true },
{ index: 0, total: 1, close: true },
])("accepts valid triggers on step $index of $total", (configuration) => {
missingTriggersWarning(
configuration.index,
configuration.total,
step,
createPopup(configuration),
);
expect(consoleWarning).not.toHaveBeenCalled();
});
test("does not count the top close button as the final action", () => {
missingTriggersWarning(0, 1, step, createPopup({ topClose: true }));
expect(consoleWarning).toHaveBeenCalledWith(
expect.stringContaining("<Coach.CloseTrigger>"),
);
});
test("ignores a previous step's popup during transitions", () => {
missingTriggersWarning(1, 3, step, createPopup({ id: "previous" }));
expect(consoleWarning).not.toHaveBeenCalled();
});
test("ignores unmounted and not-yet-mounted popups", () => {
const { unmount } = render(
<div role="dialog" data-coach-step-id={step.id} />,
);
const popup = screen.getByRole("dialog");
unmount();
missingTriggersWarning(0, 3, step, popup);
missingTriggersWarning(0, 3, step, null);
expect(consoleWarning).not.toHaveBeenCalled();
});
test("does not count triggers outside the current popup", () => {
const popup = createPopup();
render(
<button type="button" data-coach-next-trigger>
Unrelated next
</button>,
);
missingTriggersWarning(0, 3, step, popup);
expect(consoleWarning).toHaveBeenCalledWith(
expect.stringContaining("<Coach.NextTrigger>"),
);
});
});
@@ -1,931 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import React, { useRef, useState } from "react";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { Button } from "../button";
import { Popover } from "../popover";
import { Portal } from "../portal";
import { Provider } from "../provider";
import { Coach } from "./index";
import type {
CoachProps,
CoachSingleStepProps,
CoachStepItem,
CoachTourProps,
} from "./types";
const meta = {
title: "ds-react/Coach/Tests",
component: Coach,
parameters: {
chromatic: { disable: true },
},
decorators: [
(Story) => {
const [container, setContainer] = useState<HTMLDivElement | null>(null);
return (
<Provider rootElement={container ?? undefined}>
<Story />
<div ref={setContainer} />
</Provider>
);
},
],
beforeEach: () => {
globalThis.AKSEL_NO_EXIT_ANIMATIONS = true;
return () => {
globalThis.AKSEL_NO_EXIT_ANIMATIONS = false;
};
},
} satisfies Meta<typeof Coach>;
export default meta;
type Story = StoryObj<CoachTourProps>;
type SingleStepStory = StoryObj<
Extract<CoachSingleStepProps, { type: "dialog" }>
>;
const translations = {
next: "Next",
previous: "Previous",
close: "Close",
};
const dialogSteps: CoachStepItem[] = [
{ id: "welcome", type: "dialog", title: "Welcome" },
{ id: "features", type: "dialog", title: "Features" },
{ id: "finished", type: "dialog", title: "Finished", allowToEndTour: true },
];
function Tour({
controlled = false,
...props
}: CoachProps & { controlled?: boolean }) {
const [open, setOpen] = useState(false);
const [step, setStep] = useState(props.defaultStep ?? 0);
return (
<>
<Button onClick={() => setOpen(true)}>Start tour</Button>
<Coach
{...props}
showTour={open}
translations={translations}
currentStep={controlled ? step : props.currentStep}
onStepChange={(nextStep) => {
if (controlled) {
setStep(nextStep);
}
props.onStepChange?.(nextStep);
}}
onShowTourChange={(nextOpen) => {
setOpen(nextOpen);
props.onShowTourChange?.(nextOpen);
}}
/>
</>
);
}
function expectLabelledByTitle(popup: HTMLElement, title: string) {
expect(popup).toHaveAttribute(
"aria-labelledby",
within(popup).getByRole("heading", { name: title }).id,
);
}
export const UncontrolledNavigation: Story = {
args: {
showTour: false,
onShowTourChange: fn(),
onStepChange: fn(),
steps: dialogSteps,
},
render: (args) => <Tour {...args} />,
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
expect(canvas.queryByRole("dialog")).not.toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Start tour" }));
const first = await canvas.findByRole("dialog", { name: "Welcome" });
expectLabelledByTitle(first, "Welcome");
expect(within(first).getByText("1 av 3")).toBeVisible();
expect(
within(first).queryByRole("button", { name: "Previous" }),
).toBeNull();
await waitFor(() =>
expect(within(first).getByRole("button", { name: "Next" })).toHaveFocus(),
);
await userEvent.click(within(first).getByRole("button", { name: "Next" }));
const second = await canvas.findByRole("dialog", { name: "Features" });
expectLabelledByTitle(second, "Features");
expect(within(second).getByText("2 av 3")).toBeVisible();
expect(second).toBe(first);
await waitFor(() =>
expect(
within(second).getByRole("button", { name: "Next" }),
).toHaveFocus(),
);
await userEvent.click(
within(second).getByRole("button", { name: "Previous" }),
);
await canvas.findByRole("dialog", { name: "Welcome" });
await waitFor(() =>
expect(within(first).getByRole("button", { name: "Next" })).toHaveFocus(),
);
await userEvent.keyboard("{ArrowRight}{ArrowRight}");
const last = await canvas.findByRole("dialog", { name: "Finished" });
expect(within(last).queryByRole("button", { name: "Next" })).toBeNull();
const closeButtons = within(last).getAllByRole("button", { name: "Close" });
await waitFor(() =>
expect(closeButtons[closeButtons.length - 1]).toHaveFocus(),
);
await userEvent.click(
within(last).getByRole("button", { name: "Previous" }),
);
await canvas.findByRole("dialog", { name: "Features" });
await waitFor(() =>
expect(
within(second).getByRole("button", { name: "Next" }),
).toHaveFocus(),
);
await userEvent.keyboard("{ArrowRight}");
await canvas.findByRole("dialog", { name: "Finished" });
const finalCloseButtons = within(last).getAllByRole("button", {
name: "Close",
});
await waitFor(() =>
expect(finalCloseButtons[finalCloseButtons.length - 1]).toHaveFocus(),
);
await userEvent.click(finalCloseButtons[finalCloseButtons.length - 1]);
await waitFor(() => expect(canvas.queryByRole("dialog")).toBeNull());
expect(args.onStepChange).toHaveBeenNthCalledWith(1, 1);
expect(args.onStepChange).toHaveBeenNthCalledWith(2, 0);
expect(args.onShowTourChange).toHaveBeenCalledWith(false);
await userEvent.click(canvas.getByRole("button", { name: "Start tour" }));
await canvas.findByRole("dialog", { name: "Welcome" });
},
};
export const ShrinkingSteps: Story = {
args: {
steps: dialogSteps,
},
render: () => {
const [steps, setSteps] = useState(dialogSteps);
return (
<>
<Button onClick={() => setSteps(dialogSteps)}>Restore steps</Button>
<Coach
showTour
currentStep={1}
steps={steps}
renderStep={({ id }, { defaultContent }) => (
<>
{defaultContent}
<p>{id.toUpperCase()}</p>
<Button onClick={() => setSteps(dialogSteps.slice(0, 1))}>
Remove active step
</Button>
</>
)}
/>
</>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const popup = await canvas.findByRole("dialog", { name: "Features" });
expect(within(popup).getByText("FEATURES")).toBeVisible();
await userEvent.click(
within(popup).getByRole("button", { name: "Remove active step" }),
);
await waitFor(() => expect(canvas.queryByRole("dialog")).toBeNull());
await userEvent.click(
canvas.getByRole("button", { name: "Restore steps" }),
);
expect(
await canvas.findByRole("dialog", { name: "Features" }),
).toBeVisible();
},
};
export const ControlledNavigation: Story = {
args: {
showTour: false,
onShowTourChange: fn(),
onStepChange: fn(),
steps: dialogSteps,
defaultStep: 1,
},
render: (args) => <Tour {...args} controlled />,
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Start tour" }));
const second = await canvas.findByRole("dialog", { name: "Features" });
await userEvent.click(
within(second).getByRole("button", { name: "Previous" }),
);
await canvas.findByRole("dialog", { name: "Welcome" });
expect(args.onStepChange).toHaveBeenCalledWith(0);
},
};
export const ControlledStepDoesNotAdvanceItself: Story = {
args: {
showTour: false,
onShowTourChange: fn(),
onStepChange: fn(),
currentStep: 0,
steps: dialogSteps,
},
render: (args) => <Tour {...args} />,
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Start tour" }));
const popup = await canvas.findByRole("dialog", { name: "Welcome" });
await userEvent.click(within(popup).getByRole("button", { name: "Next" }));
expect(args.onStepChange).toHaveBeenCalledWith(1);
expect(canvas.getByRole("dialog", { name: "Welcome" })).toBeVisible();
expect(canvas.queryByRole("dialog", { name: "Features" })).toBeNull();
},
};
export const DefaultStep: Story = {
args: {
showTour: false,
onShowTourChange: fn(),
steps: dialogSteps,
defaultStep: 1,
},
render: (args) => <Tour {...args} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Start tour" }));
const second = await canvas.findByRole("dialog", { name: "Features" });
expect(within(second).getByText("2 av 3")).toBeVisible();
await userEvent.click(within(second).getByRole("button", { name: "Next" }));
await canvas.findByRole("dialog", { name: "Finished" });
await userEvent.keyboard("{Escape}");
await waitFor(() => expect(canvas.queryByRole("dialog")).toBeNull());
await userEvent.click(canvas.getByRole("button", { name: "Start tour" }));
await canvas.findByRole("dialog", { name: "Features" });
},
};
export const KeyboardNavigation: Story = {
args: {
showTour: false,
onShowTourChange: fn(),
onStepChange: fn(),
steps: dialogSteps,
},
render: (args) => <Tour {...args} />,
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Start tour" }));
await canvas.findByRole("dialog", { name: "Welcome" });
await userEvent.keyboard("{ArrowLeft}{Escape}");
expect(args.onStepChange).not.toHaveBeenCalled();
expect(args.onShowTourChange).not.toHaveBeenCalled();
expect(canvas.getByRole("dialog", { name: "Welcome" })).toBeVisible();
await userEvent.keyboard("{ArrowRight}");
await canvas.findByRole("dialog", { name: "Features" });
await userEvent.keyboard("{ArrowLeft}");
await canvas.findByRole("dialog", { name: "Welcome" });
await userEvent.keyboard("{ArrowRight}{ArrowRight}");
await canvas.findByRole("dialog", { name: "Finished" });
await userEvent.keyboard("{ArrowRight}");
expect(args.onShowTourChange).not.toHaveBeenCalled();
await userEvent.keyboard("{Escape}");
await waitFor(() => expect(canvas.queryByRole("dialog")).toBeNull());
expect(args.onShowTourChange).toHaveBeenCalledWith(false);
expect(args.onStepChange).toHaveBeenCalledTimes(4);
await userEvent.keyboard("{ArrowRight}{Escape}");
expect(args.onStepChange).toHaveBeenCalledTimes(4);
expect(args.onShowTourChange).toHaveBeenCalledTimes(1);
},
};
export const KeyboardNavigationInCustomContent: Story = {
args: {
showTour: false,
onShowTourChange: fn(),
onStepChange: fn(),
steps: dialogSteps,
renderStep: (_step, { defaultContent }) => (
<>
{defaultContent}
<input aria-label="Name" defaultValue="Coach" />
<textarea aria-label="Notes" defaultValue="Notes" />
<select aria-label="Choice" defaultValue="first">
<option value="first">First</option>
<option value="second">Second</option>
</select>
{/* biome-ignore lint/a11y/useSemanticElements: Exercise a contenteditable editor rather than a native input. */}
<div
contentEditable
suppressContentEditableWarning
role="textbox"
aria-label="Editor"
tabIndex={0}
>
<span>Editable content</span>
</div>
<Button
onKeyDown={(event) => {
if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
event.preventDefault();
}
}}
>
Handles arrows
</Button>
</>
),
},
render: (args) => <Tour {...args} />,
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Start tour" }));
const first = await canvas.findByRole("dialog", { name: "Welcome" });
const name = within(first).getByRole<HTMLInputElement>("textbox", {
name: "Name",
});
await userEvent.click(name);
await userEvent.keyboard("{End}{ArrowLeft}");
expect(name.selectionStart).toBe(4);
await userEvent.keyboard("{ArrowRight}");
expect(name.selectionStart).toBe(5);
for (const control of [
within(first).getByRole("textbox", { name: "Notes" }),
within(first).getByRole("combobox", { name: "Choice" }),
within(first).getByRole("textbox", { name: "Editor" }),
within(first).getByRole("button", { name: "Handles arrows" }),
]) {
await userEvent.click(control);
await userEvent.keyboard("{ArrowRight}{ArrowLeft}");
expect(first).toHaveAccessibleName("Welcome");
}
const next = within(first).getByRole("button", { name: "Next" });
await userEvent.click(
within(first).getByRole("heading", { name: "Welcome" }),
);
next.focus();
expect(next).toHaveFocus();
await userEvent.keyboard(
"{Shift>}{ArrowRight}{/Shift}{Control>}{ArrowRight}{/Control}{Alt>}{ArrowRight}{/Alt}{Meta>}{ArrowRight}{/Meta}",
);
expect(args.onStepChange).not.toHaveBeenCalled();
await userEvent.keyboard("{ArrowRight}");
const second = await canvas.findByRole("dialog", { name: "Features" });
within(second).getByRole("button", { name: "Previous" }).focus();
await userEvent.keyboard("{ArrowLeft}");
await canvas.findByRole("dialog", { name: "Welcome" });
await userEvent.keyboard("{ArrowRight}{ArrowRight}");
const last = await canvas.findByRole("dialog", { name: "Finished" });
await userEvent.click(within(last).getByRole("textbox", { name: "Name" }));
await userEvent.keyboard("{Escape}");
await waitFor(() => expect(canvas.queryByRole("dialog")).toBeNull());
expect(args.onShowTourChange).toHaveBeenCalledWith(false);
},
};
export const AnchorKeyboardNavigationInCustomContent: Story = {
...KeyboardNavigationInCustomContent,
play: KeyboardNavigationInCustomContent.play,
render: (args) => {
const anchorRef = useRef<HTMLButtonElement>(null);
return (
<>
<Button ref={anchorRef}>Target</Button>
<Tour
{...args}
steps={dialogSteps.map((step) => ({
...step,
type: "anchor",
anchorRef,
}))}
/>
</>
);
},
};
function NestedEscapeContent({ children }: { children: React.ReactNode }) {
const [anchor, setAnchor] = useState<HTMLButtonElement | null>(null);
const [open, setOpen] = useState(false);
return (
<>
{children}
<input
aria-label="Handles Escape"
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
}
}}
/>
<Button ref={setAnchor} onClick={() => setOpen(true)}>
Open nested help
</Button>
<Portal>
<Popover anchorEl={anchor} open={open} onClose={() => setOpen(false)}>
<Popover.Content>Nested help content</Popover.Content>
</Popover>
</Portal>
</>
);
}
export const NestedEscape: Story = {
args: {
showTour: false,
onShowTourChange: fn(),
steps: [dialogSteps[0], dialogSteps[2]],
renderStep: (_step, { defaultContent }) => (
<NestedEscapeContent>{defaultContent}</NestedEscapeContent>
),
},
render: (args) => <Tour {...args} />,
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Start tour" }));
for (const title of ["Welcome", "Finished"]) {
const popup = await canvas.findByRole("dialog", { name: title });
await userEvent.click(
within(popup).getByRole("button", { name: "Open nested help" }),
);
expect(await canvas.findByText("Nested help content")).toBeVisible();
await userEvent.keyboard("{Escape}");
await waitFor(() =>
expect(canvas.queryByText("Nested help content")).not.toBeVisible(),
);
expect(popup).toBeVisible();
expect(args.onShowTourChange).not.toHaveBeenCalled();
await userEvent.click(
within(popup).getByRole("textbox", { name: "Handles Escape" }),
);
await userEvent.keyboard("{Escape}");
expect(popup).toBeVisible();
expect(args.onShowTourChange).not.toHaveBeenCalled();
// Move focus off the input without consuming Escape in its own handler.
within(popup)
.getByRole("button", {
name: title === "Welcome" ? "Next" : "Previous",
})
.focus();
await userEvent.keyboard("{Escape}");
if (title === "Welcome") {
expect(popup).toBeVisible();
expect(args.onShowTourChange).not.toHaveBeenCalled();
await userEvent.click(
within(popup).getByRole("button", { name: "Next" }),
);
}
}
await waitFor(() => expect(canvas.queryByRole("dialog")).toBeNull());
expect(args.onShowTourChange).toHaveBeenCalledTimes(1);
expect(args.onShowTourChange).toHaveBeenCalledWith(false);
},
};
export const AnchorNestedEscape: Story = {
...NestedEscape,
play: NestedEscape.play,
render: (args) => {
const anchorRef = useRef<HTMLButtonElement>(null);
return (
<>
<Button ref={anchorRef}>Target</Button>
<Tour
{...args}
steps={[dialogSteps[0], dialogSteps[2]].map((step) => ({
...step,
type: "anchor",
anchorRef,
}))}
/>
</>
);
},
};
export const MixedSteps: StoryObj = {
render: () => {
const firstRef = useRef<HTMLButtonElement>(null);
const secondRef = useRef<HTMLButtonElement>(null);
return (
<>
<Button ref={firstRef}>First target</Button>
<Button ref={secondRef}>Second target</Button>
<Tour
showTour={false}
onShowTourChange={fn()}
steps={[
dialogSteps[0],
{
id: "first-anchor",
type: "anchor",
anchorRef: firstRef,
title: "First anchor",
},
{
id: "second-anchor",
type: "anchor",
anchorRef: secondRef,
title: "Second anchor",
allowToEndTour: true,
},
dialogSteps[2],
]}
/>
</>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Start tour" }));
const first = await canvas.findByRole("dialog", { name: "Welcome" });
await userEvent.click(within(first).getByRole("button", { name: "Next" }));
const anchor = await canvas.findByRole("dialog", { name: "First anchor" });
await waitFor(() =>
expect(
within(anchor).getByRole("button", { name: "Next" }),
).toHaveFocus(),
);
expectLabelledByTitle(anchor, "First anchor");
expect(canvas.queryByRole("button", { name: "First target" })).toBeNull();
await userEvent.keyboard("{Escape}");
expect(canvas.getByRole("dialog", { name: "First anchor" })).toBeVisible();
await userEvent.click(within(anchor).getByRole("button", { name: "Next" }));
const second = await canvas.findByRole("dialog", { name: "Second anchor" });
expectLabelledByTitle(second, "Second anchor");
await userEvent.tab();
expect(second.contains(document.activeElement)).toBe(true);
await userEvent.tab({ shift: true });
expect(second.contains(document.activeElement)).toBe(true);
await userEvent.click(within(second).getByRole("button", { name: "Next" }));
const last = await canvas.findByRole("dialog", { name: "Finished" });
const closeButtons = within(last).getAllByRole("button", { name: "Close" });
await waitFor(() =>
expect(closeButtons[closeButtons.length - 1]).toHaveFocus(),
);
await userEvent.click(
within(last).getByRole("button", { name: "Previous" }),
);
await canvas.findByRole("dialog", { name: "Second anchor" });
await userEvent.keyboard("{Escape}");
await waitFor(() => expect(canvas.queryByRole("dialog")).toBeNull());
},
};
export const SpotlightBackdrop: StoryObj = {
render: () => {
const anchorRef = useRef<HTMLButtonElement>(null);
return (
<>
<section
aria-label="Scrollable targets"
style={{ height: 200, overflow: "auto" }}
>
<div style={{ height: 600, paddingTop: 80 }}>
<Button ref={anchorRef}>Spotlight target</Button>
</div>
</section>
<Tour type="anchor" anchorRef={anchorRef} title="Spotlight" />
</>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const scroller = canvas.getByRole("region", { name: "Scrollable targets" });
await userEvent.click(canvas.getByRole("button", { name: "Start tour" }));
await canvas.findByRole("dialog", { name: "Spotlight" });
const backdrop = canvasElement.querySelector<HTMLElement>(
".aksel-coach__backdrop",
);
if (!backdrop) {
throw new Error("Expected a spotlight backdrop");
}
expect(backdrop.style.length).toBe(1);
backdrop.style.setProperty("--ax-bg-overlay", "rgb(10, 20, 30)");
expect(getComputedStyle(backdrop).backgroundColor).toBe("rgb(10, 20, 30)");
expect(getComputedStyle(backdrop).position).toBe("fixed");
expect(getComputedStyle(backdrop).pointerEvents).toBe("none");
const initialClipPath = backdrop.style.clipPath;
scroller.scrollTop = 40;
await waitFor(() => {
expect(scroller.scrollTop).toBe(40);
expect(backdrop.style.clipPath).not.toBe(initialClipPath);
});
const bounds = backdrop.getBoundingClientRect();
expect(bounds.top).toBe(0);
expect(bounds.left).toBe(0);
expect(bounds.width).toBe(document.documentElement.clientWidth);
expect(bounds.height).toBeGreaterThanOrEqual(
document.documentElement.clientHeight,
);
},
};
export const OverrideOneStep: Story = {
args: {
showTour: false,
onShowTourChange: fn(),
steps: dialogSteps,
renderStep: (step, { index }) => {
if (step.id !== "features") {
return undefined;
}
return (
<Coach.Step>
<Coach.Title id="custom-step-title">Custom {step.title}</Coach.Title>
<Coach.Description>
Custom layout for step {index + 1}
</Coach.Description>
<Coach.Progress />
<Coach.Footer>
<Coach.PreviousTrigger>
<Button>Go back</Button>
</Coach.PreviousTrigger>
<Coach.NextTrigger>
<Button>Continue</Button>
</Coach.NextTrigger>
</Coach.Footer>
</Coach.Step>
);
},
},
render: (args) => <Tour {...args} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Start tour" }));
const first = await canvas.findByRole("dialog", { name: "Welcome" });
await userEvent.click(within(first).getByRole("button", { name: "Next" }));
const custom = await canvas.findByRole("dialog", {
name: "Custom Features",
});
expect(custom).toHaveAttribute("aria-labelledby", "custom-step-title");
expect(within(custom).getByText("Custom layout for step 2")).toBeVisible();
expect(within(custom).getByText("2 av 3")).toBeVisible();
await userEvent.click(
within(custom).getByRole("button", { name: "Continue" }),
);
await canvas.findByRole("dialog", { name: "Finished" });
},
};
export const SingleStep: SingleStepStory = {
args: {
type: "dialog",
title: "Announcement",
description: "A single step without a steps prop",
showTour: false,
onShowTourChange: fn(),
},
render: (args) => <Tour {...args} />,
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Start tour" }));
const popup = await canvas.findByRole("dialog", { name: "Announcement" });
expectLabelledByTitle(popup, "Announcement");
expect(
within(popup).getByText("A single step without a steps prop"),
).toBeVisible();
expect(within(popup).queryByText(/av 1/)).toBeNull();
expect(within(popup).queryByRole("button", { name: "Next" })).toBeNull();
expect(
within(popup).queryByRole("button", { name: "Previous" }),
).toBeNull();
const closeButtons = within(popup).getAllByRole("button", {
name: "Close",
});
await waitFor(() =>
expect(closeButtons[closeButtons.length - 1]).toHaveFocus(),
);
await userEvent.keyboard("{Escape}");
await waitFor(() => expect(canvas.queryByRole("dialog")).toBeNull());
expect(args.onShowTourChange).toHaveBeenCalledWith(false);
},
};
export const SingleStepRenderStep: SingleStepStory = {
args: {
type: "dialog",
title: "Default title",
description: "Default description",
image: <svg role="img" aria-label="Illustration" />,
showTour: false,
onShowTourChange: fn(),
renderStep: ({ title, description, image }, { defaultContent }) => (
<Coach.Step>
<p>Before default content</p>
{defaultContent}
<p>
Received: {title}, {description}
</p>
{image}
</Coach.Step>
),
},
render: (args) => <Tour {...args} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Start tour" }));
const popup = await canvas.findByRole("dialog", { name: "Default title" });
expect(within(popup).getByText("Before default content")).toBeVisible();
expect(
within(popup).getByText("Received: Default title, Default description"),
).toBeVisible();
expect(
within(popup).getAllByRole("img", { name: "Illustration" }),
).toHaveLength(2);
},
};
export const SingleAnchorWithRenderStep: StoryObj = {
render: () => {
const anchorRef = useRef<HTMLButtonElement>(null);
return (
<>
<Button ref={anchorRef}>Target</Button>
<Tour
showTour={false}
onShowTourChange={fn()}
type="anchor"
anchorRef={anchorRef}
title="Unused title"
aria-labelledby="custom-anchor-label"
renderStep={() => (
<Coach.Step>
<Coach.Title>Custom title</Coach.Title>
<Coach.Description id="custom-anchor-label">
Custom accessible name
</Coach.Description>
<Coach.Footer>
<Coach.CloseTrigger>
<Button>Done</Button>
</Coach.CloseTrigger>
</Coach.Footer>
</Coach.Step>
)}
/>
</>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Start tour" }));
const popup = await canvas.findByRole("dialog", {
name: "Custom accessible name",
});
expect(
within(popup).getByRole("heading", { name: "Custom title" }),
).toBeVisible();
expect(within(popup).queryByText("Unused title")).toBeNull();
await userEvent.click(within(popup).getByRole("button", { name: "Done" }));
await waitFor(() => expect(canvas.queryByRole("dialog")).toBeNull());
},
};
export const Mark: StoryObj = {
render: () => {
return (
<Coach type="dialog" title="Help" translations={translations}>
<Coach.Mark aria-label="Show help">
<Button>Target action</Button>
</Coach.Mark>
</Coach>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: "Target action" }),
);
expect(canvas.queryByRole("dialog")).toBeNull();
await userEvent.click(canvas.getByRole("button", { name: "Show help" }));
const popup = await canvas.findByRole("dialog", { name: "Help" });
const closeButtons = within(popup).getAllByRole("button", {
name: "Close",
});
await userEvent.click(closeButtons[closeButtons.length - 1]);
await waitFor(() => expect(canvas.queryByRole("dialog")).toBeNull());
await userEvent.click(canvas.getByRole("button", { name: "Show help" }));
await canvas.findByRole("dialog", { name: "Help" });
},
};
export const ControlledMark: Story = {
args: {
showTour: false,
steps: dialogSteps,
onShowTourChange: fn(),
},
render: (args) => {
const [open, setOpen] = useState(false);
return (
<Coach
{...args}
showTour={open}
onShowTourChange={(nextOpen) => {
args.onShowTourChange?.(nextOpen);
setOpen(nextOpen);
}}
translations={translations}
>
<Coach.Mark aria-label="Start tour" />
</Coach>
);
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Start tour" }));
expect(args.onShowTourChange).toHaveBeenLastCalledWith(true);
await canvas.findByRole("dialog", { name: "Welcome" });
await userEvent.keyboard("{ArrowRight}{ArrowRight}");
const popup = await canvas.findByRole("dialog", { name: "Finished" });
const closeButtons = within(popup).getAllByRole("button", {
name: "Close",
});
await userEvent.click(closeButtons[closeButtons.length - 1]);
await waitFor(() => expect(canvas.queryByRole("dialog")).toBeNull());
expect(args.onShowTourChange).toHaveBeenLastCalledWith(false);
await userEvent.click(canvas.getByRole("button", { name: "Start tour" }));
await canvas.findByRole("dialog", { name: "Welcome" });
},
};
export const DisabledMark: StoryObj = {
render: () => (
<Coach type="dialog" title="Help" disableTour showTour>
<Coach.Mark aria-label="Show help">
<Button>Target action</Button>
</Coach.Mark>
</Coach>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.queryByRole("button", { name: "Show help" })).toBeNull();
expect(canvas.getByRole("button", { name: "Target action" })).toBeVisible();
expect(canvas.queryByRole("dialog")).toBeNull();
},
};
export const MarkWithCustomStep: StoryObj = {
render: () => {
const anchorRef = useRef<HTMLButtonElement>(null);
return (
<Coach
type="anchor"
anchorRef={anchorRef}
title="Default title"
description="Default description"
renderStep={({ title, description }) => (
<Coach.Step>
<Coach.Title>{title}</Coach.Title>
<Coach.Description>{description}</Coach.Description>
<Coach.Footer>
<Coach.CloseTrigger>
<Button>Done</Button>
</Coach.CloseTrigger>
</Coach.Footer>
</Coach.Step>
)}
>
<Coach.Mark aria-label="Show help">
<Button ref={anchorRef}>Target</Button>
</Coach.Mark>
</Coach>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const target = canvas.getByRole("button", { name: "Target" });
expect(canvas.queryByText("Default description")).toBeNull();
await userEvent.click(canvas.getByRole("button", { name: "Show help" }));
const popup = await canvas.findByRole("dialog", { name: "Default title" });
expect(within(popup).getByText("Default description")).toBeVisible();
await userEvent.click(within(popup).getByRole("button", { name: "Done" }));
await waitFor(() => expect(canvas.queryByRole("dialog")).toBeNull());
expect(canvas.getByRole("button", { name: "Target" })).toBe(target);
await userEvent.click(canvas.getByRole("button", { name: "Show help" }));
await canvas.findByRole("dialog", { name: "Default title" });
},
};
@@ -1,45 +0,0 @@
import React, { useState } from "react";
import { useClientLayoutEffect } from "../../utils-external";
interface CoachBackdropProps {
anchorEl: Element;
}
const CoachBackdrop = ({ anchorEl }: CoachBackdropProps) => {
const [rect, setRect] = useState<DOMRect | null>(null);
useClientLayoutEffect(() => {
const updateRect = () => setRect(anchorEl.getBoundingClientRect());
const resizeObserver = new ResizeObserver(updateRect);
updateRect();
resizeObserver.observe(anchorEl);
window.addEventListener("resize", updateRect);
window.addEventListener("scroll", updateRect, true);
return () => {
resizeObserver.disconnect();
window.removeEventListener("resize", updateRect);
window.removeEventListener("scroll", updateRect, true);
};
}, [anchorEl]);
if (!rect) {
return null;
}
const padding = 10;
const radius = 12;
const left = rect.left - padding;
const top = rect.top - padding;
const right = rect.right + padding;
const bottom = rect.bottom + padding;
const clipPath = `path(evenodd, "M 0 0 H ${window.innerWidth} V ${window.innerHeight} H 0 Z M ${left + radius} ${top} H ${right - radius} A ${radius} ${radius} 0 0 1 ${right} ${top + radius} V ${bottom - radius} A ${radius} ${radius} 0 0 1 ${right - radius} ${bottom} H ${left + radius} A ${radius} ${radius} 0 0 1 ${left} ${bottom - radius} V ${top + radius} A ${radius} ${radius} 0 0 1 ${left + radius} ${top} Z")`;
return (
<div aria-hidden className="aksel-coach__backdrop" style={{ clipPath }} />
);
};
export { CoachBackdrop };
export type { CoachBackdropProps };
@@ -1,31 +0,0 @@
import React, { forwardRef } from "react";
import { cl } from "../../utils/helpers";
type CoachDescriptionProps = React.HTMLAttributes<HTMLDivElement>;
/**
* @see 🏷️ {@link CoachDescriptionProps}
* @example
* ```jsx
* <Coach.Step>
* <Coach.Title>Coach title</Coach.Title>
* <Coach.Description>Coach description</Coach.Description>
* </Coach.Step>
* ```
*/
const CoachDescription = forwardRef<HTMLDivElement, CoachDescriptionProps>(
({ className, children, ...restProps }, forwardedRef) => {
return (
<div
{...restProps}
ref={forwardedRef}
className={cl("aksel-coach__description", className)}
>
{children}
</div>
);
},
);
export { CoachDescription };
export type { CoachDescriptionProps };
@@ -1,33 +0,0 @@
import React, { forwardRef } from "react";
import { cl } from "../../utils/helpers";
type CoachFooterProps = React.HTMLAttributes<HTMLDivElement>;
/**
* @see 🏷️ {@link CoachFooterProps}
* @example
* ```jsx
* <Coach.Footer>
* <Coach.CloseTrigger>
* <Button>Close dialog</Button>
* </Coach.CloseTrigger>
* </Coach.Footer>
* ```
*/
const CoachFooter = forwardRef<HTMLDivElement, CoachFooterProps>(
({ className, children, ...restProps }, forwardedRef) => {
return (
<div
{...restProps}
ref={forwardedRef}
className={cl("aksel-coach__footer", className)}
data-coach-footer
>
{children}
</div>
);
},
);
export { CoachFooter };
export type { CoachFooterProps };
@@ -1,48 +0,0 @@
import React, { forwardRef } from "react";
import { Bleed } from "../../primitives/bleed";
import { cl } from "../../utils/helpers";
import { useCoachContext } from "../root/Coach.context";
type ImageAspectRatio = "1/1" | "16/9" | "16/10" | "4/3" | (string & {});
interface CoachImageProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
/**
* The aspect-ratio CSS property allows you to define the desired width-to-height ratio of an element's box.
* This means that even if the parent container or viewport size changes, the browser will adjust the element's dimensions to maintain the specified width-to-height ratio.
*/
aspectRatio?: ImageAspectRatio;
}
/**
* @see 🏷️ {@link CoachImageProps}
* @example
* ```jsx
* <Coach.Image>
* <img src="image.jpg" alt="Description" />
* </Coach.Image>
* ```
*/
const CoachImage = forwardRef<HTMLDivElement, CoachImageProps>(
({ className, children, style, aspectRatio, ...restProps }, forwardedRef) => {
const { currentStepSize } = useCoachContext();
return (
<Bleed
asChild
marginInline={currentStepSize === "small" ? "space-16" : "space-20"}
>
<div
ref={forwardedRef}
className={cl("aksel-coach__image-container", className)}
style={{ aspectRatio, ...style }}
{...restProps}
>
{children}
</div>
</Bleed>
);
},
);
export { CoachImage };
export type { CoachImageProps };
-34
View File
@@ -1,34 +0,0 @@
"use client";
export {
Coach,
CoachStep,
CoachTitle,
CoachDescription,
CoachFooter,
CoachImage,
CoachProgress,
CoachCloseTrigger,
CoachNextTrigger,
CoachPreviousTrigger,
CoachMark,
} from "./root/CoachRoot";
export type {
CoachAnchorStep,
CoachDialogStep,
CoachStepBase,
CoachStepItem,
CoachStepTarget,
CoachProps,
CoachTourProps,
CoachSingleStepProps,
CoachStepProps,
CoachTitleProps,
CoachDescriptionProps,
CoachFooterProps,
CoachImageProps,
CoachProgressProps,
CoachCloseTriggerProps,
CoachPreviousTriggerProps,
CoachNextTriggerProps,
CoachMarkProps,
} from "./root/CoachRoot";
@@ -1,124 +0,0 @@
import React, { forwardRef } from "react";
import type { AkselColor } from "../../types";
import { cl, composeEventHandlers } from "../../utils/helpers";
import { consoleWarning } from "../../utils/helpers/consoleWarning";
import type { SpacingScale } from "../../utils/hooks/base-primitive/helpers/types";
import { useCoachContext } from "../root/Coach.context";
type CoachMarkProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
({
/**
* Badge color.
* @default "danger"
* @see 🏷️ {@link AkselColor}
* @see [📝 Documentation](https://aksel.nav.no/grunnleggende/styling/farger-tokens)
*/
"data-color"?: AkselColor;
} & (
| {
/**
* Element the coach mark is anchored to.
* Leave empty to render the mark standalone.
*/
children: React.ReactElement;
/**
* Offset for the coach mark relative to its anchor.
* @default "space-16"
*/
offset?: SpacingScale | `${SpacingScale} ${SpacingScale}`;
}
| { children?: never; offset?: never }
));
/**
* @see 🏷️ {@link CoachMarkProps}
* @example
* ```jsx
* <Coach type="dialog" title="Help">
* <Coach.Mark>
* <Button>Target</Button>
* </Coach.Mark>
* </Coach>
* ```
*/
const CoachMark = forwardRef<HTMLButtonElement, CoachMarkProps>(
(
{
children,
onClick,
className,
"data-color": color = "danger",
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledby,
"aria-hidden": ariaHidden,
style,
...restProps
},
forwardedRef,
) => {
const coach = useCoachContext(false);
if (coach === undefined && onClick === undefined) {
consoleWarning(
"Coach.Mark used outside of Coach and without onClick handler.",
);
}
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
coach?.openTour?.();
};
const offset =
"offset" in restProps ? (restProps.offset ?? "space-16") : "space-16";
const offsetValues = offset.split(" ");
const offsetX = offsetValues[0] ?? "space-16";
const offsetY = offsetValues[1] ?? offsetX;
const containerStyle = children
? {
transform: `translate(var(--ax-${offsetX}), calc(-1 * var(--ax-${offsetY})))`,
...style,
}
: style;
const Mark = (
<button
type="button"
{...restProps}
ref={forwardedRef}
data-color={color}
aria-label={ariaLabel ?? "Åpne veiledning"}
aria-labelledby={ariaLabelledby}
aria-hidden={ariaHidden}
aria-haspopup="dialog"
className={cl("aksel-coach__mark-container", className)}
style={containerStyle}
onClick={composeEventHandlers(onClick, handleClick)}
>
<div className="aksel-coach__mark-pulse" />
<div className="aksel-coach__mark" />
</button>
);
// If tour is disabled, only render the children without the mark
if (coach?.disableTour) {
return children ?? null;
}
// If there are no children, only render the mark itself
if (!children) {
return Mark;
}
return (
<div className="aksel-coach__mark-anchor">
{children}
{Mark}
</div>
);
},
);
export { CoachMark };
export type { CoachMarkProps };
@@ -1,55 +0,0 @@
import React, { forwardRef } from "react";
import { BodyShort } from "../../typography";
import { cl } from "../../utils/helpers";
import { useI18n } from "../../utils/i18n/i18n.hooks";
import type { ComponentTranslation } from "../../utils/i18n/i18n.types";
import { useCoachContext } from "../root/Coach.context";
interface CoachProgressProps extends Omit<
React.HTMLAttributes<HTMLParagraphElement>,
"children"
> {
translations?: ComponentTranslation<"Coach">;
}
/**
* @see 🏷️ {@link CoachProgressProps}
* @example
* ```jsx
* <Coach.Step>
* <Coach.Progress />
* <Coach.Title>Coach title</Coach.Title>
* <Coach.Description>Coach description</Coach.Description>
* </Coach.Step>
* ```
*/
const CoachProgress = forwardRef<HTMLParagraphElement, CoachProgressProps>(
({ className, translations, ...restProps }, forwardedRef) => {
const { currentStepIndex, totalSteps } = useCoachContext();
const readableCurrentStep = currentStepIndex + 1;
const translate = useI18n("Coach", translations);
if (totalSteps <= 1) {
return null;
}
return (
<BodyShort
{...restProps}
ref={forwardedRef}
className={cl("aksel-coach__progress", className)}
data-color="neutral"
textColor="subtle"
>
{translate("currentStep", {
current: readableCurrentStep,
total: totalSteps,
})}
</BodyShort>
);
},
);
export { CoachProgress };
export type { CoachProgressProps };
@@ -1,57 +0,0 @@
import { createStrictContext } from "../../utils/helpers";
import type { CoachStepItem } from "../types";
interface CoachContextProps {
/**
* Whether the tour is currently open.
*/
showTour: boolean;
/** Opens the tour from a mark. */
openTour?: () => void;
/** Whether tour triggers are disabled. */
disableTour?: boolean;
/**
* Called when the tour is dismissed or finished.
*/
onClose?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
/**
* The object of the current step in the coach sequence.
*/
currentStep: CoachStepItem | null;
/**
* The index of the current step in the coach sequence (0-based).
*/
currentStepIndex: number;
/**
* The total number of steps in the coach sequence.
*/
totalSteps: number;
/**
* Advances to the next step in the coach sequence.
*/
goToNextStep: () => void;
/**
* Goes to the previous step in the coach sequence.
*/
goToPreviousStep: () => void;
/**
* The size of the current step in the coach sequence.
*/
currentStepSize: "small" | "medium";
/**
* ID of the title element for aria-labelledby.
*/
titleId?: string;
/**
* Sets the title element ID.
*/
setTitleId: React.Dispatch<React.SetStateAction<string | undefined>>;
}
const { Provider: CoachContextProvider, useContext: useCoachContext } =
createStrictContext<CoachContextProps>({
name: "CoachContext",
errorMessage: "useCoachContext must be used within Coach",
});
export { CoachContextProvider, useCoachContext };
@@ -1,511 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { XMarkIcon } from "@navikt/aksel-icons";
import { Button } from "../../button";
import { Dialog } from "../../dialog";
import { Popover } from "../../popover";
import { Portal } from "../../portal";
import { useClientLayoutEffect } from "../../utils-external";
// import { DismissableLayer } from "../../utils/components/dismissablelayer/DismissableLayer";
import { FocusBoundary } from "../../utils/components/focus-boundary/FocusBoundary";
import { FocusGuards } from "../../utils/components/focus-guards/FocusGuards";
import { consoleWarning } from "../../utils/helpers/consoleWarning";
import { useMergeRefs } from "../../utils/hooks";
import { useI18n } from "../../utils/i18n/i18n.hooks";
import { CoachBackdrop } from "../backdrop/CoachBackdrop";
import {
CoachDescription,
type CoachDescriptionProps,
} from "../description/CoachDescription";
import { CoachFooter, type CoachFooterProps } from "../footer/CoachFooter";
import { CoachImage, type CoachImageProps } from "../image/CoachImage";
import { CoachMark, type CoachMarkProps } from "../mark/CoachMark";
import {
CoachProgress,
type CoachProgressProps,
} from "../progress/CoachProgress";
import { CoachStep, type CoachStepProps } from "../step/CoachStep";
import { CoachTitle, type CoachTitleProps } from "../title/CoachTitle";
import {
CoachCloseTrigger,
type CoachCloseTriggerProps,
} from "../trigger/CoachCloseTrigger";
import {
CoachNextTrigger,
type CoachNextTriggerProps,
} from "../trigger/CoachNextTrigger";
import {
CoachPreviousTrigger,
type CoachPreviousTriggerProps,
} from "../trigger/CoachPreviousTrigger";
import type {
CoachAnchorStep,
CoachDialogStep,
CoachProps,
CoachSingleStepProps,
CoachStepBase,
CoachStepItem,
CoachStepTarget,
CoachTourProps,
} from "../types";
import { missingTriggersWarning } from "../warnings";
import { CoachContextProvider } from "./Coach.context";
/**
* Guides the user through a sequence of steps, anchored to elements or shown as dialogs.
*
* @example
* ```tsx
* <Coach
* steps={[
* { id: "welcome", type: "dialog", title: "Welcome!" },
* { id: "start", type: "anchor", anchorRef: buttonRef, title: "Start here." },
* ]}
* >
* <Coach.Mark aria-label="Start tour" />
* </Coach>
* ```
*/
const CoachRoot = (props: CoachProps) => {
const {
showTour: showTourControlled,
onShowTourChange,
disableTour = false,
currentStep: currentStepControlled,
defaultStep = 0,
onStepChange,
translations,
children,
renderStep,
} = props;
const translate = useI18n("Coach", translations);
const [uncontrolledShowTour, setUncontrolledShowTour] = useState(false);
const [uncontrolledStep, setUncontrolledStep] = useState(defaultStep);
const [anchorEl, setAnchorEl] = useState<Element | null>(null);
const [titleId, setTitleId] = useState<string>();
const [stepElement, setStepElement] = useState<HTMLElement | null>(null);
const stepRef = useRef<HTMLElement | null>(null);
const mergedStepRef = useMergeRefs(stepRef, setStepElement);
const showTour = !disableTour && (showTourControlled ?? uncontrolledShowTour);
// Generate the steps for the tour based on props or fallback to single step.
const steps: readonly CoachStepItem[] =
"steps" in props && props.steps !== undefined
? props.steps
: [
{
id: props.id ?? "step-1",
type: props.type,
title: props.title,
description: props.description,
image: props.image,
allowToEndTour: props.allowToEndTour ?? true,
size: props.size,
width: props.width,
"aria-labelledby": props["aria-labelledby"],
...(props.type === "anchor"
? {
anchorRef: props.anchorRef,
placement: props.placement,
offset: props.offset,
}
: {}),
} as CoachStepItem,
];
const activeStepIndex = currentStepControlled ?? uncontrolledStep;
const currentStep = steps[activeStepIndex];
const anchorRef =
currentStep?.type === "anchor" ? currentStep.anchorRef : undefined;
useClientLayoutEffect(() => {
// Smooth scroll to the anchor element if it's outside the viewport
const nextAnchorEl = anchorRef?.current ?? null;
if (!showTour) {
return;
}
if (nextAnchorEl) {
const { bottom, left, right, top } = nextAnchorEl.getBoundingClientRect();
const isOutsideViewport =
top < 0 ||
left < 0 ||
bottom > window.innerHeight ||
right > window.innerWidth;
if (isOutsideViewport) {
nextAnchorEl.scrollIntoView({
block: "center",
inline: "center",
behavior: window.matchMedia("(prefers-reduced-motion: reduce)")
.matches
? "auto"
: "smooth",
});
}
}
setAnchorEl(nextAnchorEl);
}, [anchorRef, showTour, activeStepIndex]);
useEffect(() => {
if (!currentStep) {
consoleWarning(
`Coach cannot find a step at index ${activeStepIndex} in ${steps.length} steps. Provide a non-empty steps array and a valid currentStep or defaultStep index.`,
);
}
}, [activeStepIndex, currentStep, steps.length]);
useEffect(() => {
// Show warning for missing triggers if the tour is active
if (!showTour || !currentStep) return;
missingTriggersWarning(
activeStepIndex,
steps.length,
currentStep,
stepElement,
);
}, [showTour, activeStepIndex, steps.length, currentStep, stepElement]);
useEffect(() => {
// Warn if the current step is an anchor type but its anchor element is not found
if (showTour && currentStep?.type === "anchor" && !anchorRef?.current) {
consoleWarning(
`Coach step "${currentStep.id}" cannot find its anchor. Attach anchorRef to a mounted element before showing this step.`,
);
}
}, [showTour, currentStep?.type, currentStep?.id, anchorRef]);
useClientLayoutEffect(() => {
// Reset uncontrolled step when the tour is hidden
if (!showTour) {
setUncontrolledStep(defaultStep);
}
}, [defaultStep, showTour]);
// Get the size and width of the current step, with default values if not specified
const getStepSize = () => currentStep?.size ?? "medium";
const getStepWidth = () => currentStep?.width ?? "medium";
const setStep = useCallback(
(nextStep: number) => {
if (currentStepControlled === undefined) {
setUncontrolledStep(nextStep);
}
onStepChange?.(nextStep);
},
[currentStepControlled, onStepChange],
);
const setShowTour = useCallback(
(open: boolean) => {
if (showTourControlled === undefined) {
setUncontrolledShowTour(open);
}
onShowTourChange?.(open);
},
[onShowTourChange, showTourControlled],
);
const endTour = useCallback(() => setShowTour(false), [setShowTour]);
const goToNextStep = useCallback(() => {
if (activeStepIndex === steps.length - 1) {
endTour();
return;
}
setStep(activeStepIndex + 1);
}, [activeStepIndex, endTour, setStep, steps.length]);
const goToPreviousStep = useCallback(() => {
if (activeStepIndex > 0) {
setStep(activeStepIndex - 1);
}
}, [activeStepIndex, setStep]);
const handleEscapeKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) {
return;
}
event.preventDefault();
if (currentStep?.allowToEndTour) {
endTour();
}
};
const handleArrowKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
const target = event.target;
// Let editable content and nested widgets handle their own arrow keys first.
if (
event.defaultPrevented ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.nativeEvent.isComposing ||
(target instanceof HTMLElement && target.isContentEditable) ||
(target instanceof Element &&
target.closest(
'input, textarea, select, [role="textbox"], [role="combobox"]',
))
) {
return;
}
if (event.key === "ArrowRight" && activeStepIndex < steps.length - 1) {
event.preventDefault();
goToNextStep();
} else if (event.key === "ArrowLeft" && activeStepIndex > 0) {
event.preventDefault();
goToPreviousStep();
}
};
const getInitialFocus = useCallback(() => {
const popup = stepRef.current;
if (!popup || popup.dataset.coachStepId !== currentStep?.id) {
return null;
}
const nextTriggers = popup.querySelectorAll<HTMLElement>(
"[data-coach-next-trigger]",
);
const closeTriggers = popup.querySelectorAll<HTMLElement>(
"[data-coach-close-trigger]",
);
const trigger =
nextTriggers[nextTriggers.length - 1] ??
closeTriggers[closeTriggers.length - 1];
if (!trigger) return null;
// Prevent focus from interrupting the smooth scroll to the anchor.
trigger.focus?.({ preventScroll: true });
// Returns undefined to override the default focus behavior of the FocusBoundary and Dialog components
return undefined;
}, [currentStep?.id]);
useClientLayoutEffect(() => {
// Dialog stays mounted between dialog steps, so its initial focus does not rerun.
if (showTour && currentStep?.type === "dialog") {
getInitialFocus();
}
}, [showTour, currentStep?.type, getInitialFocus, stepElement]);
// Default content for a step
const defaultStepContent = (
<CoachStep>
<div className="aksel-coach__top-bar">
<CoachProgress />
{currentStep?.allowToEndTour && (
<CoachCloseTrigger data-coach-top-close-trigger>
<Button
size="small"
icon={<XMarkIcon title={translate("close")} />}
variant="tertiary"
data-color="neutral"
/>
</CoachCloseTrigger>
)}
</div>
{currentStep?.image && <CoachImage>{currentStep.image}</CoachImage>}
{currentStep?.title && <CoachTitle>{currentStep.title}</CoachTitle>}
{currentStep?.description && (
<CoachDescription>{currentStep.description}</CoachDescription>
)}
<CoachFooter>
{activeStepIndex > 0 && (
<CoachPreviousTrigger>
<Button size={getStepSize()} variant="secondary">
{translate("previous")}
</Button>
</CoachPreviousTrigger>
)}
{activeStepIndex === steps.length - 1 ? (
<CoachCloseTrigger>
<Button size={getStepSize()} variant="primary">
{translate("close")}
</Button>
</CoachCloseTrigger>
) : (
<CoachNextTrigger>
<Button size={getStepSize()} variant="primary">
{translate("next")}
</Button>
</CoachNextTrigger>
)}
</CoachFooter>
</CoachStep>
);
// Step content, either custom rendered or default content
const stepContent =
(currentStep &&
renderStep?.(
{
id: currentStep.id,
title: currentStep.title,
description: currentStep.description,
image: currentStep.image,
},
{
defaultContent: defaultStepContent,
index: activeStepIndex,
totalSteps: steps.length,
},
)) ??
defaultStepContent;
// Render the current step based on its type (dialog or anchor)
const renderCurrentStep = () => {
const labelledBy = currentStep?.["aria-labelledby"] ?? titleId;
if (currentStep?.type === "dialog") {
return (
<Dialog
open={true}
size={getStepSize()}
onOpenChange={(_open, event) => {
// TODO:C Look at this
console.log(_open);
if (event instanceof KeyboardEvent && event.key === "Escape") {
handleEscapeKeyDown(event);
}
}}
>
<Dialog.Popup
// Keep focus trapped without locking scroll, so dialog-to-anchor transitions can scroll freely.
modal="trap-focus"
withBackdrop
ref={mergedStepRef}
data-coach-step-id={currentStep.id}
initialFocusTo={getInitialFocus}
onKeyDown={handleArrowKeyDown}
width={getStepWidth()}
aria-labelledby={labelledBy}
>
<Dialog.Body
className="aksel-coach__dialog-body"
data-coach-step-size={getStepSize()}
>
{stepContent}
</Dialog.Body>
</Dialog.Popup>
</Dialog>
);
}
if (currentStep?.type === "anchor" && anchorEl) {
return (
<Portal key={currentStep.id}>
<CoachBackdrop anchorEl={anchorEl} />
<FocusGuards>
<FocusBoundary loop trapped modal initialFocus={getInitialFocus}>
<Popover
ref={mergedStepRef}
data-coach-step-id={currentStep.id}
anchorEl={anchorEl}
open
onClose={endTour}
onKeyDown={handleArrowKeyDown}
placement={currentStep.placement}
offset={currentStep.offset ?? 12}
role="dialog"
aria-labelledby={labelledBy}
className="aksel-coach__popover"
// TODO:C - onClose makes it close on any outside interaction, not just Escape key.
// Wanted to use the already built escape key handling.
>
{/* Popover's onClose does not distinguish Escape from outside interactions. */}
{/*<DismissableLayer asChild onEscapeKeyDown={handleEscapeKeyDown}>*/}
<Popover.Content
className="aksel-coach__popover_content"
data-top-close-button={currentStep.allowToEndTour ?? false}
data-coach-step-size={getStepSize()}
data-coach-step-width={getStepWidth()}
>
{stepContent}
</Popover.Content>
{/*</DismissableLayer>*/}
</Popover>
</FocusBoundary>
</FocusGuards>
</Portal>
);
}
return null;
};
return (
<CoachContextProvider
showTour={showTour}
openTour={() => setShowTour(true)}
disableTour={disableTour}
currentStepSize={getStepSize()}
onClose={endTour}
currentStep={currentStep ?? null}
currentStepIndex={activeStepIndex}
totalSteps={steps.length}
goToNextStep={goToNextStep}
goToPreviousStep={goToPreviousStep}
titleId={titleId}
setTitleId={setTitleId}
>
{children}
{showTour && renderCurrentStep()}
</CoachContextProvider>
);
};
const Coach = Object.assign(CoachRoot, {
Step: CoachStep,
Title: CoachTitle,
Description: CoachDescription,
CloseTrigger: CoachCloseTrigger,
Progress: CoachProgress,
NextTrigger: CoachNextTrigger,
PreviousTrigger: CoachPreviousTrigger,
Footer: CoachFooter,
Image: CoachImage,
Mark: CoachMark,
});
export {
Coach,
CoachStep,
CoachTitle,
CoachDescription,
CoachCloseTrigger,
CoachProgress,
CoachNextTrigger,
CoachPreviousTrigger,
CoachFooter,
CoachImage,
CoachMark,
};
export type {
CoachAnchorStep,
CoachDialogStep,
CoachStepBase,
CoachStepItem,
CoachStepTarget,
CoachProps,
CoachTourProps,
CoachSingleStepProps,
CoachStepProps,
CoachTitleProps,
CoachDescriptionProps,
CoachCloseTriggerProps,
CoachProgressProps,
CoachNextTriggerProps,
CoachPreviousTriggerProps,
CoachFooterProps,
CoachImageProps,
CoachMarkProps,
};
@@ -1,31 +0,0 @@
import React, { forwardRef } from "react";
import { cl } from "../../utils/helpers";
type CoachStepProps = React.HTMLAttributes<HTMLDivElement>;
/**
* @see 🏷️ {@link CoachStepProps}
* @example
* ```jsx
* <Coach.Step>
* <Coach.Title>Coach title</Coach.Title>
* <Coach.Description>Coach description</Coach.Description>
* </Coach.Step>
* ```
*/
const CoachStep = forwardRef<HTMLDivElement, CoachStepProps>(
({ className, children, ...restProps }, forwardedRef) => {
return (
<div
{...restProps}
ref={forwardedRef}
className={cl("aksel-coach__step", className)}
>
{children}
</div>
);
},
);
export { CoachStep };
export type { CoachStepProps };
@@ -1,46 +0,0 @@
import React, { forwardRef } from "react";
import { Heading } from "../../typography";
import { useClientLayoutEffect, useId } from "../../utils-external";
import { cl } from "../../utils/helpers";
import { useCoachContext } from "../root/Coach.context";
type CoachTitleProps = React.HTMLAttributes<HTMLHeadingElement>;
/**
* @see 🏷️ {@link CoachTitleProps}
* @example
* ```jsx
* <Coach.Step>
* <Coach.Title>Coach title</Coach.Title>
* </Coach.Step>
* ```
*/
const CoachTitle = forwardRef<HTMLHeadingElement, CoachTitleProps>(
({ className, id, children, ...restProps }, forwardedRef) => {
const { currentStepSize, setTitleId } = useCoachContext();
const titleId = useId(id);
useClientLayoutEffect(() => {
setTitleId(titleId);
return () => {
setTitleId(undefined);
};
}, [titleId, setTitleId]);
return (
<Heading
{...restProps}
id={titleId}
ref={forwardedRef}
className={cl("aksel-coach__title", className)}
size={currentStepSize}
level="1"
>
{children}
</Heading>
);
},
);
export { CoachTitle };
export type { CoachTitleProps };
@@ -1,46 +0,0 @@
import React, { forwardRef } from "react";
import { Slot } from "../../utils/components/slot/Slot";
import { composeEventHandlers } from "../../utils/helpers";
import { useCoachContext } from "../root/Coach.context";
interface CoachCloseTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactElement;
}
/**
* @see 🏷️ {@link CoachCloseTriggerProps}
* @example
* ```jsx
* <Coach.CloseTrigger>
* <Button>Close coach</Button>
* </Coach.CloseTrigger>
* ```
*/
const CoachCloseTrigger = forwardRef<HTMLButtonElement, CoachCloseTriggerProps>(
({ children, onClick, ...restProps }, forwardedRef) => {
const { showTour, onClose } = useCoachContext();
const handleClick = (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => {
if (showTour) {
onClose?.(event);
}
};
return (
<Slot
type="button"
{...restProps}
ref={forwardedRef}
data-coach-close-trigger
onClick={composeEventHandlers(onClick, handleClick)}
>
{children}
</Slot>
);
},
);
export { CoachCloseTrigger };
export type { CoachCloseTriggerProps };
@@ -1,38 +0,0 @@
import React, { forwardRef } from "react";
import { Slot } from "../../utils/components/slot/Slot";
import { composeEventHandlers } from "../../utils/helpers";
import { useCoachContext } from "../root/Coach.context";
interface CoachNextTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactElement;
}
/**
* @see 🏷️ {@link CoachNextTriggerProps}
* @example
* ```jsx
* <Coach.NextTrigger>
* <Button>Next step</Button>
* </Coach.NextTrigger>
* ```
*/
const CoachNextTrigger = forwardRef<HTMLButtonElement, CoachNextTriggerProps>(
({ children, onClick, ...restProps }, forwardedRef) => {
const { goToNextStep } = useCoachContext();
return (
<Slot
type="button"
{...restProps}
ref={forwardedRef}
data-coach-next-trigger
onClick={composeEventHandlers(onClick, goToNextStep)}
>
{children}
</Slot>
);
},
);
export { CoachNextTrigger };
export type { CoachNextTriggerProps };
@@ -1,39 +0,0 @@
import React, { forwardRef } from "react";
import { Slot } from "../../utils/components/slot/Slot";
import { composeEventHandlers } from "../../utils/helpers";
import { useCoachContext } from "../root/Coach.context";
interface CoachPreviousTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactElement;
}
/**
* @see 🏷️ {@link CoachPreviousTriggerProps}
* @example
* ```jsx
* <Coach.PreviousTrigger>
* <Button>Previous step</Button>
* </Coach.PreviousTrigger>
* ```
*/
const CoachPreviousTrigger = forwardRef<
HTMLButtonElement,
CoachPreviousTriggerProps
>(({ children, onClick, ...restProps }, forwardedRef) => {
const { goToPreviousStep } = useCoachContext();
return (
<Slot
type="button"
{...restProps}
ref={forwardedRef}
data-coach-previous-trigger
onClick={composeEventHandlers(onClick, goToPreviousStep)}
>
{children}
</Slot>
);
});
export { CoachPreviousTrigger };
export type { CoachPreviousTriggerProps };
-182
View File
@@ -1,182 +0,0 @@
import type React from "react";
import type { Popover } from "../popover";
import type { ComponentTranslation } from "../utils/i18n/i18n.types";
import type { CoachMark } from "./mark/CoachMark";
interface CoachStepBase {
/**
* Unique identifier for the coach step.
*/
id: string;
/**
* Whether the user is allowed to end the tour at this step.
* TODO:C - Best naming? "allowEarlyEnd" or similar?
*/
allowToEndTour?: boolean;
/**
* Updates sub-component padding, Coach.Title font-size and button size.
* @default "medium"
*/
size?: "medium" | "small";
/**
* Width of the coach step
* @default "medium"
*/
width?: "small" | "medium";
/**
* Image content for the coach step.
*/
image?: React.ReactNode;
/**
* Title content for the coach step.
*/
title?: React.ReactNode;
/**
* Description content for the coach step.
*/
description?: React.ReactNode;
/**
* Overrides `aria-labelledby` on the step container.
* If omitted, defaults to the ID of `<Coach.Title>`.
*/
"aria-labelledby"?: string;
}
interface CoachDialogStep {
/**
* Dialog type coach step.
*/
type: "dialog";
}
interface CoachAnchorStep {
/**
* Anchor type coach step.
*/
type: "anchor";
/**
* Reference to the anchor element for the anchor type coach step.
*/
anchorRef: React.RefObject<HTMLElement | null>;
/**
* Placement of the anchor type coach step relative to the anchor element.
*/
placement?: NonNullable<React.ComponentProps<typeof Popover>["placement"]>;
/**
* Offset of the anchor type coach step relative to the anchor element.
*/
offset?: number;
}
type CoachStepTarget = CoachDialogStep | CoachAnchorStep;
type CoachStepItem = CoachStepBase & CoachStepTarget;
interface CoachBaseProps {
/**
* Controls whether the tour is visible.
* When omitted, Coach.Mark opens the tour using internal state.
*/
showTour?: boolean;
/**
* Called when a mark opens the tour or the tour closes.
*/
onShowTourChange?: (show: boolean) => void;
/**
* Hides the tour and its marks without hiding the elements they are anchored to.
* @default false
*/
disableTour?: boolean;
/**
* Active step index when controlled.
*/
currentStep?: number;
/**
* Initial active step index when uncontrolled.
* @default 0
*/
defaultStep?: number;
/**
* Called when the active step changes.
*/
onStepChange?: (step: number) => void;
/**
* Translations for buttons in the coach component: Next, Previous, Close.
*/
translations?: ComponentTranslation<"Coach">;
/**
* Coach.Mark triggers rendered outside the popup.
* Provide step content through props or renderStep instead.
*/
children?:
| React.ReactElement<
React.ComponentProps<typeof CoachMark>,
typeof CoachMark
>
| React.ReactElement<
React.ComponentProps<typeof CoachMark>,
typeof CoachMark
>[];
/**
* Custom render function for each coach step.
* Return `defaultContent` or `undefined` to keep standard layout for that step.
*/
renderStep?: (
step: {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
image?: React.ReactNode;
},
context: {
defaultContent: React.ReactNode;
index: number;
totalSteps: number;
},
) => React.ReactNode | undefined;
}
interface CoachTourProps extends CoachBaseProps {
/**
* Steps shown in sequence.
*/
steps: readonly CoachStepItem[];
type?: never;
id?: never;
title?: never;
description?: never;
image?: never;
allowToEndTour?: never;
size?: never;
width?: never;
anchorRef?: never;
placement?: never;
offset?: never;
"aria-labelledby"?: never;
}
type CoachSingleStepProps = CoachBaseProps &
(Omit<CoachStepBase, "id"> & {
/**
* Unique identifier for the coach step.
* @default "step-1"
*/
id?: string;
}) &
CoachStepTarget & {
steps?: never;
};
type CoachProps = CoachTourProps | CoachSingleStepProps;
export type {
CoachAnchorStep,
CoachBaseProps,
CoachDialogStep,
CoachProps,
CoachSingleStepProps,
CoachStepBase,
CoachStepItem,
CoachStepTarget,
CoachTourProps,
};
-52
View File
@@ -1,52 +0,0 @@
import { consoleWarning } from "../utils/helpers/consoleWarning";
import type { CoachStepItem } from "./types";
function missingTriggersWarning(
activeStep: number,
stepsLength: number,
currentStep: CoachStepItem,
container: HTMLElement | null,
) {
// Ignore detached popups and the previous step's popup during transitions.
if (
!container?.isConnected ||
container.getAttribute("data-coach-step-id") !== currentStep.id
) {
return;
}
const hasNextTrigger = Boolean(
container.querySelector("[data-coach-next-trigger]"),
);
const hasPreviousTrigger = Boolean(
container.querySelector("[data-coach-previous-trigger]"),
);
const hasCloseTrigger = Boolean(
container.querySelector(
"[data-coach-close-trigger]:not([data-coach-top-close-trigger])",
),
);
const isFirstStep = activeStep === 0;
const isLastStep = activeStep === stepsLength - 1;
if (!isLastStep && !hasNextTrigger) {
consoleWarning(
`Step "${currentStep.id}" is missing a <Coach.NextTrigger>. Steps that are not the last step should have a next trigger to allow navigation.`,
);
}
if (!isFirstStep && !hasPreviousTrigger) {
consoleWarning(
`Step "${currentStep.id}" is missing a <Coach.PreviousTrigger>. Steps that are not the first step should have a previous trigger to allow navigation.`,
);
}
if (isLastStep && !hasCloseTrigger) {
consoleWarning(
`Step "${currentStep.id}" is missing a <Coach.CloseTrigger>. The last step should have a close trigger to finish the tour.`,
);
}
}
export { missingTriggersWarning };
@@ -82,6 +82,8 @@ interface DataGridComponent {
/**
* Component for displaying tabular data.
*
* **WARNING: This component is in active development and may receive breaking changes outside major releases!**
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/datagrid)
* @see 🏷️ {@link DataGridProps}
*
@@ -12,7 +12,7 @@ function DataTableDetailsPanelRow<T>({
rowId: string;
rowData: T;
}) {
const { tableId, totalColSpan, withKeyboardNav } = useDataTableContext();
const { tableId, totalColSpan } = useDataTableContext();
const {
enableDetailsPanel,
isExpanded,
@@ -46,7 +46,6 @@ function DataTableDetailsPanelRow<T>({
<td
id={expansionId}
colSpan={totalColSpan}
tabIndex={withKeyboardNav ? -1 : undefined}
className="aksel-data-table__details-panel-row-cell"
>
<div style={style}>{content}</div>
@@ -5,7 +5,9 @@ import { type GridCache, buildTableGridMap } from "../helpers/table-grid-nav";
/**
* Manages the table grid cache and observes DOM changes that require grid rebuilding.
* Watches for structural changes (rows/cells added/removed) and attribute changes
* (colspan, rowspan, hidden, style) that affect grid layout and focusability.
* (colspan, rowspan, hidden) that affect grid layout.
* Inline `style` is intentionally not observed: focus navigation writes
* scroll-margin on every move, and the grid never reads styles.
*/
function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
const gridCacheRef = useRef<GridCache>({
@@ -34,7 +36,7 @@ function useGridCache(tableRef: HTMLTableElement | null, enabled: boolean) {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["colspan", "rowspan", "hidden", "style"],
attributeFilter: ["colspan", "rowspan", "hidden"],
});
return () => {
@@ -343,21 +343,18 @@ export const EnterNotSubmittingForm: StoryObj<{
args: {
onSubmit: fn(),
},
render: ({ onSubmit }) => (
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit(event);
}}
>
<UNSAFE_Combobox
options={options}
label="Hva er dine favorittfrukter?"
isMultiSelect
allowNewValues
/>
</form>
),
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);
@@ -115,7 +115,6 @@ const FilteredOptions = () => {
ref={setFilteredOptionsRef}
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: The <ul> element acts as a listbox in the combobox.
role="listbox"
tabIndex={-1}
className="aksel-combobox__list-options"
>
{isValueNew && !maxSelected.isLimitReached && allowNewValues && (
-1
View File
@@ -3,7 +3,6 @@ export { ActionMenu, type ActionMenuProps } from "./action-menu";
export { Alert, type AlertProps } from "./legacy/alert";
export { Button, type ButtonProps } from "./button";
export { Chat, type ChatProps } from "./chat";
export { Coach, type CoachProps } from "./coach";
export { Chips, type ChipsProps } from "./chips";
export { CopyButton, type CopyButtonProps } from "./copybutton";
export {
@@ -1,6 +1,6 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import React, { useState } from "react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { describe, expect, test } from "vitest";
import { Button } from "../../button";
import { Modal } from "../index";
@@ -18,10 +18,6 @@ const Test = () => {
};
describe("Modal", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("should be visible", async () => {
render(<Test />);
expect(await screen.findByText("Foobar")).toBeVisible();
@@ -33,37 +29,25 @@ describe("Modal", () => {
expect(screen.getByText("Foobar")).not.toBeVisible();
});
test.each([false, true])(
"should toggle scroll lock (scrollbar-gutter support: %s)",
async (supportsScrollbarGutter) => {
vi.spyOn(CSS, "supports").mockReturnValue(supportsScrollbarGutter);
render(<Test />);
test("should toggle scroll lock", async () => {
render(<Test />);
const lockedElement = supportsScrollbarGutter
? document.body
: document.documentElement;
await waitFor(() => {
expect(document.documentElement.style.overflowX).toBe("hidden");
});
await waitFor(() => {
expect(document.documentElement.style.overflowY).toBe("hidden");
});
await waitFor(() => {
expect(document.documentElement.style.scrollBehavior).toBe("unset");
});
await waitFor(() => {
expect(document.documentElement.style.scrollbarGutter).toBe("stable");
});
await waitFor(() => {
expect(lockedElement.style.overflowX).toBe("hidden");
});
await waitFor(() => {
expect(lockedElement.style.overflowY).toBe("hidden");
});
await waitFor(() => {
expect(document.documentElement.style.scrollBehavior).toBe(
supportsScrollbarGutter ? "" : "unset",
);
});
await waitFor(() => {
expect(document.documentElement.style.scrollbarGutter).toBe("stable");
});
fireEvent.click(screen.getByText("Close"));
await waitFor(() => {
expect(document.documentElement.style.cssText).toBe("");
expect(lockedElement.style.overflowX).toBe("");
expect(lockedElement.style.overflowY).toBe("");
});
},
);
fireEvent.click(screen.getByText("Close"));
await waitFor(() => {
expect(document.documentElement.style.cssText).toBe("");
});
});
});
@@ -83,25 +83,6 @@ export const DisabledMonths = () => (
/>
);
/**
* Only June is enabled. ArrowRight/ArrowLeft on June keeps focus on June instead
* of searching forever. Keyboard search stops at the
* fromDate/toDate years, or after 100 years when no bounds are set.
*/
export const KeyboardNoEnabledMonth = () => (
<div>
<p>Focus June and press ArrowRight or ArrowLeft. Focus stays on June.</p>
<MonthPicker.Standalone
defaultSelected={new Date(year, 5, 1)}
disabled={[
(date) => !(date.getFullYear() === year && date.getMonth() === 5),
]}
fromDate={new Date(year, 0, 1)}
toDate={new Date(year, 11, 1)}
/>
</div>
);
export const Standalone = () => {
return <MonthPicker.Standalone defaultSelected={new Date(`Jan 1 ${year}`)} />;
};
@@ -1,108 +0,0 @@
import { isSameMonth } from "date-fns/isSameMonth";
import { describe, expect, test, vi } from "vitest";
import { nextEnabled } from "./MonthPicker.util";
const months = Array.from({ length: 12 }, (_, i) => new Date(2024, i, 1));
const june2024 = new Date(2024, 5, 1);
/* Disables every month except `enabled`. Throws instead of hanging if the search becomes unbounded. */
const onlyEnabled = (...enabled: Date[]) => {
let calls = 0;
return (date: Date) => {
calls += 1;
if (calls > 5000) {
throw new Error("Month search did not terminate");
}
return !enabled.some((month) => isSameMonth(month, date));
};
};
const search = (
key: string,
disabled: (date: Date) => boolean,
options: { dropdownCaption?: boolean; fromDate?: Date; toDate?: Date } = {},
) => {
const setYearState = vi.fn();
const result = nextEnabled(
months,
key,
[disabled],
june2024,
setYearState,
june2024,
options.dropdownCaption ?? false,
options.fromDate,
options.toDate,
);
return { result, setYearState };
};
describe("nextEnabled", () => {
test.each(["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp"])(
"keeps focus when no month is enabled in direction (%s, no bounds)",
(key) => {
const { result, setYearState } = search(key, onlyEnabled(june2024));
expect(result).toEqual(june2024);
expect(setYearState).not.toHaveBeenCalled();
},
);
test.each([false, true])(
"stops at fromDate/toDate years (dropdownCaption: %s)",
(dropdownCaption) => {
const bounds = {
dropdownCaption,
fromDate: new Date(2023, 0, 1),
toDate: new Date(2025, 11, 1),
};
const disabled = onlyEnabled(
june2024,
new Date(2022, 5, 1),
new Date(2026, 5, 1),
);
for (const key of ["ArrowRight", "ArrowLeft"]) {
const { result, setYearState } = search(key, disabled, bounds);
expect(result).toEqual(june2024);
expect(setYearState).not.toHaveBeenCalled();
}
},
);
test("skips disabled months/years and moves to the next enabled year", () => {
const target = new Date(2027, 2, 1);
const { result, setYearState } = search(
"ArrowRight",
onlyEnabled(june2024, target),
);
expect(result).toEqual(target);
expect(setYearState).toHaveBeenCalledTimes(1);
expect(setYearState.mock.calls[0][0].getFullYear()).toBe(2027);
});
test("finds previous enabled month within bounds", () => {
const target = new Date(2023, 10, 1);
const { result, setYearState } = search(
"ArrowLeft",
onlyEnabled(june2024, target),
{ fromDate: new Date(2023, 0, 1) },
);
expect(result).toEqual(target);
expect(setYearState.mock.calls[0][0].getFullYear()).toBe(2023);
});
test("searches at most 100 years without bounds", () => {
const within = new Date(2124, 0, 1);
expect(search("ArrowRight", onlyEnabled(june2024, within)).result).toEqual(
within,
);
const beyond = new Date(2125, 0, 1);
expect(search("ArrowRight", onlyEnabled(june2024, beyond)).result).toEqual(
june2024,
);
});
});
@@ -56,14 +56,14 @@ export const nextEnabled = (
}
}
if (key === "ArrowRight") {
const nextMonth = searchEnabledMonth(
1,
const nextMonth = loopForward(
currentIndex,
months,
disabled,
yearState,
setYearState,
disabled,
false,
dropdownCaption,
fromDate,
toDate,
);
@@ -72,14 +72,14 @@ export const nextEnabled = (
}
}
if (key === "ArrowLeft") {
const prevMonth = searchEnabledMonth(
-1,
currentIndex,
const prevMonth = loopBack(
currentIndex - 1,
months,
disabled,
yearState,
setYearState,
false,
dropdownCaption,
fromDate,
toDate,
);
@@ -98,14 +98,14 @@ export const nextEnabled = (
return setYear(months[currentIndex + 4], yearState.getFullYear());
}
const fallbackNext = searchEnabledMonth(
1,
const fallbackNext = loopForward(
currentIndex,
months,
disabled,
yearState,
setYearState,
disabled,
true,
dropdownCaption,
fromDate,
toDate,
);
@@ -127,14 +127,14 @@ export const nextEnabled = (
)
)
return setYear(months[currentIndex - 4], Number(yearState.getFullYear()));
const fallbackPrev = searchEnabledMonth(
-1,
const fallbackPrev = loopBack(
currentIndex,
months,
disabled,
yearState,
setYearState,
true,
dropdownCaption,
fromDate,
toDate,
);
@@ -144,59 +144,90 @@ export const nextEnabled = (
return currentMonth;
};
/**
* Keyboard search for an enabled month never crosses fromDate/toDate years and
* gives up after this many years when no bounds are set, so a matcher that
* disables every remaining month cannot hang the page.
*/
const MAX_SEARCH_YEARS = 100;
const searchEnabledMonth = (
direction: 1 | -1,
const loopBack = (
currentIndex: number,
months: Date[],
disabled: Matcher[],
yearState: Date,
setYearState: (date: Date) => void,
rowCheck: boolean,
dropdownCaption: boolean,
fromDate?: Date,
toDate?: Date,
): { index: number; year: number } | undefined => {
const currentYear = yearState.getFullYear();
if (Number.isNaN(currentYear)) {
return;
}
const boundYear =
direction === 1 ? toDate?.getFullYear() : fromDate?.getFullYear();
const horizonYear = currentYear + direction * MAX_SEARCH_YEARS;
const lastYear = !boundYear
? horizonYear
: direction === 1
? Math.min(boundYear, horizonYear)
: Math.max(boundYear, horizonYear);
let nextYear = currentYear;
let monthIndex = currentIndex;
while (true) {
monthIndex += direction;
if (monthIndex < 0 || monthIndex > 11) {
if (nextYear === lastYear || (nextYear - lastYear) * direction > 0) {
let currentYear = setYear(yearState, Number(yearState.getFullYear()));
for (let i = currentIndex; i >= -1; i--) {
if (i === -1) {
if (
isOutOfRange(
dropdownCaption,
setYear(currentYear, Number(currentYear.getFullYear() - 1)),
fromDate,
toDate,
)
)
return;
}
nextYear += direction;
monthIndex = direction === 1 ? 0 : 11;
currentYear = setYear(currentYear, Number(currentYear.getFullYear() - 1));
setYearState(currentYear);
i = 11;
}
const month = months[i];
const isDisabled = !isMatch(
setYear(month, Number(currentYear.getFullYear())),
disabled,
);
if (rowCheck) {
if (isDisabled && getRow(i) !== getRow(currentIndex)) {
return { index: i, year: Number(currentYear.getFullYear()) };
}
} else {
if (isDisabled) {
return { index: i, year: Number(currentYear.getFullYear()) };
}
}
}
};
const isEnabled = !isMatch(setYear(months[monthIndex], nextYear), disabled);
if (
isEnabled &&
(!rowCheck || getRow(monthIndex) !== getRow(currentIndex))
) {
if (nextYear !== currentYear) {
setYearState(setYear(yearState, nextYear));
const loopForward = (
currentIndex: number,
months: Date[],
yearState: Date,
setYearState: (date: Date) => void,
disabled: Matcher[],
rowCheck: boolean,
dropdownCaption: boolean,
fromDate?: Date,
toDate?: Date,
): { index: number; year: number } | undefined => {
let currentYear = setYear(yearState, Number(yearState.getFullYear()));
for (let i = currentIndex + 1; i < months.length + 1; i++) {
if (i === 12) {
if (
isOutOfRange(
dropdownCaption,
setYear(currentYear, Number(currentYear.getFullYear() + 1)),
fromDate,
toDate,
)
)
return;
currentYear = setYear(currentYear, Number(currentYear.getFullYear() + 1));
setYearState(currentYear);
i = 0;
}
const month = months[i];
const isDisabled = !isMatch(
setYear(month, Number(currentYear.getFullYear())),
disabled,
);
if (rowCheck) {
if (isDisabled && getRow(i) !== getRow(currentIndex)) {
return { index: i, year: Number(currentYear.getFullYear()) };
}
} else {
if (isDisabled) {
return { index: i, year: Number(currentYear.getFullYear()) };
}
return { index: monthIndex, year: nextYear };
}
}
};
@@ -207,6 +238,25 @@ const getRow = (index: number): number => {
return 3;
};
const isOutOfRange = (
dropdownCaption: boolean,
year: Date,
fromDate?: Date,
toDate?: Date,
): boolean => {
if (
dropdownCaption &&
fromDate &&
toDate &&
(year.getFullYear() < fromDate?.getFullYear() ||
year.getFullYear() > toDate?.getFullYear())
) {
return true;
}
return false;
};
const nextOnRow = (
currentIndex: number,
months: Date[],
@@ -123,13 +123,9 @@ const PopoverRoot = forwardRef<HTMLDivElement, PopoverProps>(
],
});
/**
* Only give Floating UI a reference while open. Otherwise every closed instance
* computes (and commits) a position
*/
useClientLayoutEffect(() => {
refs.setReference(open ? anchorEl : null);
}, [anchorEl, open, refs]);
refs.setReference(anchorEl);
}, [anchorEl, refs]);
const floatingRef = useMergeRefs(refs.setFloating, ref);
@@ -1,6 +1,6 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, test } from "vitest";
import { bench, describe } from "vitest";
import { HStack, VStack } from "../stack";
import { Box } from "./Box";
@@ -15,86 +15,82 @@ import { Box } from "./Box";
*/
describe("Box – single element", () => {
test("render throughput", async ({ bench }) => {
await bench("bare (no props)", () => {
renderToStaticMarkup(<Box>Hei</Box>);
}).run();
bench("bare (no props)", () => {
renderToStaticMarkup(<Box>Hei</Box>);
});
await bench("styling props only (background/border/radius/shadow)", () => {
renderToStaticMarkup(
<Box
background="neutral-soft"
borderColor="neutral-subtle"
borderWidth="1"
borderRadius="12"
shadow="dialog"
>
Hei
</Box>,
);
}).run();
bench("styling props only (background/border/radius/shadow)", () => {
renderToStaticMarkup(
<Box
background="neutral-soft"
borderColor="neutral-subtle"
borderWidth="1"
borderRadius="12"
shadow="dialog"
>
Hei
</Box>,
);
});
await bench("single responsive/primitive prop (padding)", () => {
renderToStaticMarkup(<Box padding="space-16">Hei</Box>);
}).run();
bench("single responsive/primitive prop (padding)", () => {
renderToStaticMarkup(<Box padding="space-16">Hei</Box>);
});
await bench("many primitive props (padding/margin/width/position/inset)", () => {
renderToStaticMarkup(
<Box
padding="space-16"
margin="space-8"
width="200px"
maxWidth="400px"
position="relative"
inset="space-4"
>
Hei
</Box>,
);
}).run();
bench("many primitive props (padding/margin/width/position/inset)", () => {
renderToStaticMarkup(
<Box
padding="space-16"
margin="space-8"
width="200px"
maxWidth="400px"
position="relative"
inset="space-4"
>
Hei
</Box>,
);
});
await bench("responsive object prop (padding per breakpoint)", () => {
renderToStaticMarkup(
<Box
padding={{
xs: "space-8",
sm: "space-12",
md: "space-16",
lg: "space-20",
xl: "space-24",
}}
>
Hei
</Box>,
);
}).run();
bench("responsive object prop (padding per breakpoint)", () => {
renderToStaticMarkup(
<Box
padding={{
xs: "space-8",
sm: "space-12",
md: "space-16",
lg: "space-20",
xl: "space-24",
}}
>
Hei
</Box>,
);
});
await bench("asChild (Slot merge path)", () => {
renderToStaticMarkup(
<Box padding="space-16" asChild>
<section>Hei</section>
</Box>,
);
}).run();
bench("asChild (Slot merge path)", () => {
renderToStaticMarkup(
<Box padding="space-16" asChild>
<section>Hei</section>
</Box>,
);
});
});
describe("Box – realistic tree (24 children)", () => {
const children = Array.from({ length: 24 }, (_, i) => i);
test("render throughput", async ({ bench }) => {
await bench("VStack > Box grid of cards", () => {
renderToStaticMarkup(
<VStack gap="space-16">
{children.map((i) => (
<HStack key={i} gap="space-8" align="center">
<Box padding="space-16" background="neutral-soft" borderRadius="8">
Card {i}
</Box>
</HStack>
))}
</VStack>,
);
}).run();
bench("VStack > Box grid of cards", () => {
renderToStaticMarkup(
<VStack gap="space-16">
{children.map((i) => (
<HStack key={i} gap="space-8" align="center">
<Box padding="space-16" background="neutral-soft" borderRadius="8">
Card {i}
</Box>
</HStack>
))}
</VStack>,
);
});
});
@@ -8,9 +8,6 @@ interface ListboxInputSlotProps {
virtuallyFocusedOptionId: string;
}
/**
* Remember that role=combobox requires aria-expanded
*/
const ListboxInputSlot = ({
children,
virtuallyFocusedOptionId,
@@ -1,6 +1,6 @@
import { renderHook } from "@testing-library/react";
import React from "react";
import { describe, test } from "vitest";
import { bench, describe } from "vitest";
import { Provider } from "../../provider";
import { get } from "./get";
import { useI18n } from "./i18n.hooks";
@@ -8,16 +8,14 @@ import type { ComponentTranslation } from "./i18n.types";
import { nb } from "./locales";
describe("translate function throughput", () => {
test("translation lookups", async ({ bench }) => {
const { result } = renderHook(() => useI18n("FileUpload"));
const { result } = renderHook(() => useI18n("FileUpload"));
await bench("simple key lookup (no replacements)", () => {
result.current("item.uploading");
}).run();
bench("simple key lookup (no replacements)", () => {
result.current("item.uploading");
});
await bench("key lookup with replacements", () => {
result.current("item.uploading", { name: "Test", count: 5 } as never);
}).run();
bench("key lookup with replacements", () => {
result.current("item.uploading", { name: "Test", count: 5 } as never);
});
});
@@ -25,13 +23,10 @@ describe("translate with local overrides", () => {
const local = {
item: { uploading: "Hello {name}, you have {count} files" },
};
const { result } = renderHook(() => useI18n("FileUpload", local));
test("local override lookup", async ({ bench }) => {
const { result } = renderHook(() => useI18n("FileUpload", local));
await bench("local override with replacements", () => {
result.current("item.uploading", { name: "Test", count: 5 });
}).run();
bench("local override with replacements", () => {
result.current("item.uploading", { name: "Test", count: 5 });
});
});
@@ -43,21 +38,18 @@ describe("translate with multiple context objects", () => {
{children}
</Provider>
);
const { result } = renderHook(() => useI18n("FileUpload"), { wrapper });
test("context lookups", async ({ bench }) => {
const { result } = renderHook(() => useI18n("FileUpload"), { wrapper });
bench("hit in first context object", () => {
result.current("dropzone.button");
});
await bench("hit in first context object", () => {
result.current("dropzone.button");
}).run();
bench("hit in second context object", () => {
result.current("item.uploading");
});
await bench("hit in second context object", () => {
result.current("item.uploading");
}).run();
await bench("fallback to locale default", () => {
result.current("item.downloading");
}).run();
bench("fallback to locale default", () => {
result.current("item.downloading");
});
});
@@ -69,21 +61,19 @@ describe("get() function throughput", () => {
nb.FileUpload,
];
test("translation lookup", async ({ bench }) => {
await bench("shallow key hit in first object", () => {
get("item.uploading", objects);
}).run();
bench("shallow key hit in first object", () => {
get("item.uploading", objects);
});
await bench("key hit in second object (skip first)", () => {
get("item.downloading", objects);
}).run();
bench("key hit in second object (skip first)", () => {
get("item.downloading", objects);
});
await bench("deep fallback to last object", () => {
get("dropzone.button", objects);
}).run();
bench("deep fallback to last object", () => {
get("dropzone.button", objects);
});
await bench("long key dot notation parsing", () => {
get("dropzone.dragAndDropMultiple", [nb.FileUpload]);
}).run();
bench("long key dot notation parsing", () => {
get("dropzone.dragAndDropMultiple", [nb.FileUpload]);
});
});
@@ -19,12 +19,6 @@ export default {
labelSuffix: "delete",
},
},
Coach: {
currentStep: "{current} of {total}",
next: "Next",
previous: "Previous",
close: "Close",
},
Combobox: {
addOption: "Add",
noMatches: "No search hits",
@@ -24,12 +24,6 @@ export default {
labelSuffix: "slett",
},
},
Coach: {
currentStep: "{current} av {total}",
next: "Neste",
previous: "Forrige",
close: "Lukk",
},
Combobox: {
/** The input value will be appended to the end of this text, e.g. `Legg til "input value"`. */
addOption: "Legg til",
@@ -19,12 +19,6 @@ export default {
labelSuffix: "slett",
},
},
Coach: {
currentStep: "{current} av {total}",
next: "Neste",
previous: "Førre",
close: "Lukk",
},
Combobox: {
addOption: "Legg til",
noMatches: "Ingen søketreff",
@@ -6,7 +6,6 @@ export default defineConfig({
globals: true,
setupFiles: "./tests/setup.ts",
environment: "jsdom",
pool: "vmThreads",
typecheck: {
enabled: true,
},
+2 -2
View File
@@ -30,10 +30,10 @@
"devDependencies": {
"@navikt/ds-tokens": "^8.21.0",
"@types/node": "^24.10.9",
"tailwindcss": "^4.3.3",
"tailwindcss": "^3.3.3",
"tsx": "^4.23.15",
"typescript": "6.0.3",
"vitest": "5.0.5"
"vitest": "4.1.11"
},
"publishConfig": {
"access": "public",
+2 -2
View File
@@ -34,7 +34,7 @@
},
"homepage": "https://aksel.nav.no/grunnleggende/styling/design-tokens/",
"devDependencies": {
"@figma/plugin-typings": "^1.142.0",
"@figma/plugin-typings": "^1.131.0",
"@types/lodash": "4.17.25",
"colorjs.io": "^0.7.1",
"esbuild": "^0.28.2",
@@ -43,7 +43,7 @@
"style-dictionary": "^5.5.5",
"tsx": "^4.23.15",
"typescript": "6.0.3",
"vitest": "5.0.5"
"vitest": "4.1.11"
},
"publishConfig": {
"access": "public",
-8
View File
@@ -37,14 +37,6 @@ yarn build:next
yarn serve:next
```
`yarn build:next` genererer ikke RSS-feeden og bygger ikke Playroom. Kjør dette for å bygge alt:
```bash
yarn workspace website update:rss
yarn workspace website build:with-playroom
yarn serve:next
```
**Rettigheter**
Sanity-datasettet er privat, noe som betyr du må ha tilgang til Sanity-applikasjonen og følgende tokens:
+1 -1
View File
@@ -46,7 +46,7 @@
"@types/react": "19.2.14",
"tsx": "^4.23.15",
"typescript": "6.0.3",
"vitest": "5.0.5"
"vitest": "4.1.11"
},
"exports": {
"./schema": {
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Masonry } from "react-responsive-masonry";
import Masonry from "react-responsive-masonry";
import { Box, HGrid, Heading } from "@navikt/ds-react";
import { useMedia } from "@/app/_ui/utils/hooks/useMedia";
import Card, { type ArticleT } from "./FrontpageMasonryCard";
+3 -1
View File
@@ -1,3 +1,4 @@
import BundleAnalyzer from "@next/bundle-analyzer";
import type { NextConfig } from "next";
import { sanity } from "next-sanity/live/cache-life";
import path from "node:path";
@@ -172,4 +173,5 @@ const nextConfig: NextConfig = {
serverExternalPackages: ["@navikt/next-logger", "next-logger", "pino"],
};
module.exports = nextConfig;
module.exports =
process.env.ANALYZE === "true" ? BundleAnalyzer()(nextConfig) : nextConfig;
+8 -9
View File
@@ -6,8 +6,7 @@
"author": "Aksel | Nav designsystem team",
"scripts": {
"dev": "next | pino-pretty --messageKey=message",
"build": "yarn next build && yarn cp:assets",
"build:with-playroom": "concurrently \"yarn workspace aksel-playroom build:playroom\" \"yarn next build\" && yarn cp:assets:with-playroom",
"build": "concurrently \"yarn workspace aksel-playroom build:playroom\" \"yarn next build\" \"yarn update:rss\" && yarn cp:assets",
"start": "cd .next/standalone/aksel.nav.no/website && PORT=3000 HOSTNAME=0.0.0.0 node server.js",
"e2e:dev": "yarn generate-testpaths && playwright test --ui",
"e2e:docker": "yarn generate-testpaths && playwright test",
@@ -18,7 +17,7 @@
"update:changelog": "yarn tsx --env-file-if-exists=.env ./scripts/changelog",
"update:stats": "yarn tsx --env-file-if-exists=.env ./scripts/update-stats/update-stats",
"update:sanity": "yarn update:metadata && yarn update:eksempler && yarn update:changelog && yarn update:stats",
"analyze": "next analyze",
"analyze": "cross-env ANALYZE=true yarn build",
"test": "vitest run -c config/vitest.config.ts",
"test:watch": "vitest watch -c config/vitest.config.ts",
"playwright": "playwright test",
@@ -27,8 +26,7 @@
"cp:playroom": "copyfiles -E -u 5 '../../apps/playroom/dist/sandbox/**' public/",
"cp:static": "copyfiles -E -u 1 '.next/static/**' .next/standalone/aksel.nav.no/website/.next/",
"cp:public": "copyfiles -E 'public/**' .next/standalone/aksel.nav.no/website/",
"cp:assets": "yarn cp:public && yarn cp:static",
"cp:assets:with-playroom": "yarn cp:playroom && yarn cp:assets"
"cp:assets": "yarn cp:playroom && yarn cp:public && yarn cp:static"
},
"dependencies": {
"@navikt/analytics-types": "0.0.9",
@@ -50,13 +48,13 @@
"next-logger": "^5.0.2",
"next-sanity": "13.2.3",
"next-themes": "^0.4.6",
"pino": "^10.4.0",
"pino": "^10.3.1",
"prism-react-renderer": "^2.0.0",
"react": "19.3.1",
"react-dom": "19.3.1",
"react-hook-form": "^7.90.0",
"react-markdown": "^9.0.1",
"react-responsive-masonry": "2.9.0",
"react-responsive-masonry": "2.7.2",
"react-snowfall": "^2.4.0",
"remark-gfm": "^4.0.0",
"rxjs": "^7.8.2",
@@ -74,6 +72,7 @@
"@babel/parser": "^7.27.5",
"@babel/traverse": "^7.27.4",
"@google-cloud/bigquery": "^9.5.0",
"@next/bundle-analyzer": "16.4.3",
"@next/eslint-plugin-next": "16.4.3",
"@playwright/test": "1.61.1",
"@portabletext/block-tools": "^6.0.0",
@@ -91,7 +90,7 @@
"concurrently": "10.0.6",
"copyfiles": "^2.4.1",
"cross-env": "^10.1.0",
"jsdom": "30.1.3",
"jsdom": "29.1.1",
"json5": "2.2.3",
"pino-pretty": "^13.1.3",
"rss": "^1.2.2",
@@ -99,6 +98,6 @@
"sitemapper": "^4.1.6",
"tsx": "^4.23.15",
"typescript": "6.0.3",
"vitest": "5.0.5"
"vitest": "4.1.11"
}
}
@@ -1,70 +0,0 @@
import { useRef } from "react";
import { Button, Coach } from "@navikt/ds-react";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
const buttonRef = useRef<HTMLButtonElement>(null);
return (
<Coach
steps={[
{
id: "step-1",
type: "dialog",
title: "Lukkbar dialog",
allowToEndTour: true,
description:
"Denne dialogen kan avslutte turen ved å trykke på krysset øverst til høyre.",
},
{
id: "step-2",
type: "dialog",
title: "Ikke lukkbar dialog",
description:
"Denne dialogen har ikke knapp øverst til høyre og kan ikke avslutte turen.",
},
{
id: "step-3",
type: "anchor",
anchorRef: buttonRef,
title: "Lukkbar anchor",
allowToEndTour: true,
description:
"Denne anchoren kan avslutte Coachen ved å trykke på krysset øverst til høyre.",
},
{
id: "step-4",
type: "anchor",
anchorRef: buttonRef,
title: "Ikke lukkbar anchor",
description:
"Denne anchoren har ikke knapp øverst til høyre og kan ikke avslutte turen.",
},
{
id: "step-5",
type: "anchor",
anchorRef: buttonRef,
title: "Siste steg",
description:
"Dette er siste steg i turen og kan dermed alltid avslutte turen.",
},
]}
>
<Coach.Mark>
<Button ref={buttonRef}>Opprett oppgave</Button>
</Coach.Mark>
</Coach>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example);
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 3,
};
@@ -1,53 +0,0 @@
import { useRef, useState } from "react";
import { Button, Coach } from "@navikt/ds-react";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
const opprettRef = useRef<HTMLButtonElement>(null);
const [showTour, setShowTour] = useState(false);
return (
<>
<Button onClick={() => setShowTour(true)} ref={opprettRef}>
Opprett oppgave
</Button>
<Coach
showTour={showTour}
onShowTourChange={setShowTour}
steps={[
{
id: "velkommen",
type: "dialog",
allowToEndTour: true,
title: "Velkommen til oppgavelisten!",
description:
"Her får du oversikt over oppgavene dine. Vi viser deg hvordan du legger til oppgaver og finner dem som gjenstår.",
},
{
id: "opprett",
type: "anchor",
anchorRef: opprettRef,
placement: "bottom-end",
allowToEndTour: true,
title: "Legg til en oppgave",
description:
"Bruk denne knappen når du har noe nytt å gjøre. Etter omvisningen kan du prøve å legge til en oppgave selv.",
},
]}
/>
</>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example, { variant: "full" });
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 7,
desc: "Du kan kontrollere åpen-tilstanden ved å bruke `showTour`- og `onShowTourChange`-props på Coach-komponenten. Dette er nyttig når du må åpne coachen programmatisk, eller triggeren for å starte omvisningen er utenfor Coach-komponenten.",
};
@@ -1,98 +0,0 @@
import { useRef } from "react";
import { Button, Coach, Table, VStack } from "@navikt/ds-react";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
const opprettRef = useRef<HTMLButtonElement>(null);
const statusRef = useRef<HTMLButtonElement>(null);
return (
<VStack gap="space-16">
<Coach
steps={[
{
id: "velkommen",
type: "dialog",
allowToEndTour: true,
title: "Velkommen til oppgavelisten!",
description:
"Her får du oversikt over oppgavene dine. Vi viser deg hvordan du legger til oppgaver og finner dem som gjenstår.",
},
{
id: "opprett",
type: "anchor",
anchorRef: opprettRef,
placement: "bottom-end",
allowToEndTour: true,
title: "Legg til en oppgave",
description:
"Bruk denne knappen når du har noe nytt å gjøre. Etter omvisningen kan du prøve å legge til en oppgave selv.",
},
{
id: "filtrer",
type: "anchor",
anchorRef: statusRef,
placement: "bottom-start",
allowToEndTour: true,
title: "Se status på oppgavene",
description:
"I denne kolonnen kan du se statusen på oppgavene dine.",
},
{
id: "ferdig",
type: "dialog",
allowToEndTour: true,
title: "Nå er du klar!",
description: "Prøv å legge til en oppgave.",
},
]}
>
<Coach.Mark>
<Button ref={opprettRef}>Opprett oppgave</Button>
</Coach.Mark>
</Coach>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell scope="col">Navn</Table.HeaderCell>
<Table.HeaderCell scope="col">Fødselsnr.</Table.HeaderCell>
<Table.HeaderCell scope="col">Status</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row key="1">
<Table.HeaderCell scope="row">Scott, Michael</Table.HeaderCell>
<Table.DataCell>03999265463</Table.DataCell>
<Table.DataCell>
<span ref={statusRef}>Fullført</span>
</Table.DataCell>
</Table.Row>
<Table.Row key="2">
<Table.HeaderCell scope="row">Malone, Kevin</Table.HeaderCell>
<Table.DataCell>02334545234</Table.DataCell>
<Table.DataCell>Pågående</Table.DataCell>
</Table.Row>
<Table.Row key="3">
<Table.HeaderCell scope="row">Schrute, Dwight</Table.HeaderCell>
<Table.DataCell>34324234234</Table.DataCell>
<Table.DataCell>Pågående</Table.DataCell>
</Table.Row>
</Table.Body>
</Table>
</VStack>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example, { variant: "full" });
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 0,
title: "Omvisning i en oppgaveliste",
desc: "En omvisning med dialogsteg, forankrede steg og et egendefinert avslutningssteg. Coach håndterer aktivt steg.",
};
@@ -1,50 +0,0 @@
import { useRef } from "react";
import { Button, Coach } from "@navikt/ds-react";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
const opprettRef = useRef<HTMLButtonElement>(null);
return (
<Coach
disableTour={true}
steps={[
{
id: "velkommen",
type: "dialog",
allowToEndTour: true,
title: "Velkommen til oppgavelisten!",
description:
"Her får du oversikt over oppgavene dine. Vi viser deg hvordan du legger til oppgaver og finner dem som gjenstår.",
},
{
id: "opprett",
type: "anchor",
anchorRef: opprettRef,
placement: "bottom-end",
allowToEndTour: true,
title: "Legg til en oppgave",
description:
"Bruk denne knappen når du har noe nytt å gjøre. Etter omvisningen kan du prøve å legge til en oppgave selv.",
},
]}
>
<Coach.Mark>
<Button ref={opprettRef}>Opprett oppgave</Button>
</Coach.Mark>
</Coach>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example, { variant: "full" });
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 8,
desc: "Når `disableTour` er satt til `true`, vil `Coach.Mark` ikke vises og omvisningen vil være deaktivert.",
};
@@ -1,73 +0,0 @@
import { useRef } from "react";
import { Button, Coach } from "@navikt/ds-react";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
const buttonRef = useRef<HTMLButtonElement>(null);
return (
<Coach
steps={[
{
id: "step-dialog-with-image",
type: "dialog",
title: "Dialog med bilde",
description: "Denne dialogen har et bilde.",
image: (
<img
src="/images/og/blogg/image-1.png"
alt="Alt-tekst for bilde"
width="700"
/>
),
},
{
id: "step-anchor-with-image-and-close",
type: "anchor",
anchorRef: buttonRef,
allowToEndTour: true,
title: "Anchor med bilde",
description:
"Dette anchoret har et bilde, og en lukkeknapp øverst til høyre.",
image: (
<img
src="/images/og/blogg/image-1.png"
alt="Alt-tekst for bilde"
width="700"
/>
),
},
{
id: "step-anchor-with-image",
type: "anchor",
anchorRef: buttonRef,
title: "Anchor med bilde",
description: "Dette anchoret har et bilde.",
image: (
<img
src="/images/og/blogg/image-2.png"
alt="Alt-tekst for bilde"
width="700"
/>
),
},
]}
>
<Coach.Mark>
<Button ref={buttonRef}>Opprett oppgave</Button>
</Coach.Mark>
</Coach>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example);
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 6,
};
@@ -1,86 +0,0 @@
import { useRef } from "react";
import { Button, Coach } from "@navikt/ds-react";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
const buttonRef = useRef<HTMLButtonElement>(null);
return (
<Coach
renderStep={(step, context) => {
if (context.index === context.totalSteps - 1) {
return context.defaultContent;
}
return (
<Coach.Step>
<Coach.Title>{step.title}</Coach.Title>
<Coach.Image>{step.image}</Coach.Image>
<Coach.Progress />
<Coach.Description>{step.description}</Coach.Description>
<Coach.Footer>
{context.index > 0 && (
<Coach.PreviousTrigger>
<Button variant="secondary">Forrige</Button>
</Coach.PreviousTrigger>
)}
{context.index === context.totalSteps - 1 ? (
<Coach.CloseTrigger>
<Button>Lukk</Button>
</Coach.CloseTrigger>
) : (
<Coach.NextTrigger>
<Button data-color="danger">Neste</Button>
</Coach.NextTrigger>
)}
</Coach.Footer>
</Coach.Step>
);
}}
steps={[
{
id: "step-custom-format-dialog",
type: "dialog",
title: "Ulikt format",
description: "Denne dialogen har ulikt format fra standard.",
},
{
id: "step-custom-format-anchor",
type: "anchor",
anchorRef: buttonRef,
image: (
<img
src="/images/og/blogg/image-1.png"
alt="Alt-tekst for bilde"
width="700"
/>
),
title: "Ulikt format",
description: "Denne anchor-steget har ulikt format fra standard.",
},
{
id: "step-standard-format",
type: "anchor",
anchorRef: buttonRef,
title: "Standard format",
description: "Denne anchor-steget har standard format.",
},
]}
>
<Coach.Mark>
<Button ref={buttonRef}>Lorem ipsum</Button>
</Coach.Mark>
</Coach>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example);
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 5,
};
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more