mirror of
https://github.com/navikt/aksel.git
synced 2026-11-09 06:14:06 +00:00
Compare commits
156
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
230f05edb2 | ||
|
|
ab28d90022 | ||
|
|
c202c15ee6 | ||
|
|
ab413d54d4 | ||
|
|
ebb7e708d4 | ||
|
|
62f56475c5 | ||
|
|
9153a32aec | ||
|
|
883641a886 | ||
|
|
d1e69518b2 | ||
|
|
9a1d837cd1 | ||
|
|
9ee6e4534e | ||
|
|
ca8ee1848f | ||
|
|
314391d3d0 | ||
|
|
f26d80f771 | ||
|
|
e0bcee33fe | ||
|
|
edb90ee9c9 | ||
|
|
dfe69dac56 | ||
|
|
50067c66b5 | ||
|
|
d8c6add6f8 | ||
|
|
ec54ed60ee | ||
|
|
10b3e90bd4 | ||
|
|
df3b86ac13 | ||
|
|
0fceb75679 | ||
|
|
decb602e04 | ||
|
|
025ec06d9b | ||
|
|
d6866d71f9 | ||
|
|
2152987be0 | ||
|
|
4e249beb2b | ||
|
|
1274086697 | ||
|
|
d3304525a3 | ||
|
|
206724e5e0 | ||
|
|
39edbd7a46 | ||
|
|
7eb447981f | ||
|
|
2747af8d53 | ||
|
|
fd53f1a23d | ||
|
|
29413d4a04 | ||
|
|
4554928e49 | ||
|
|
d08b98aa46 | ||
|
|
56145483b0 | ||
|
|
86285e1488 | ||
|
|
49d756e890 | ||
|
|
b099154b97 | ||
|
|
a5e399ded4 | ||
|
|
dca4cd2e5d | ||
|
|
8307dcb464 | ||
|
|
d89c7fe86e | ||
|
|
6ebe5bc39e | ||
|
|
2fa0b86536 | ||
|
|
77e77cfff5 | ||
|
|
54690ad51f | ||
|
|
fd84d6c4a2 | ||
|
|
ac91378e4c | ||
|
|
fd25decd17 | ||
|
|
1c2bdae5d1 | ||
|
|
709f13cd63 | ||
|
|
f41188c688 | ||
|
|
6e5902a350 | ||
|
|
49a6e4ec27 | ||
|
|
97dd6f13ac | ||
|
|
bf8262da22 | ||
|
|
53d1c1a919 | ||
|
|
18a88be454 | ||
|
|
0d4e7d7c8c | ||
|
|
b688d8ab48 | ||
|
|
dd59358edd | ||
|
|
310933f923 | ||
|
|
56a281ae98 | ||
|
|
595826d326 | ||
|
|
d8e9eb97fd | ||
|
|
3dd23b46ab | ||
|
|
695f334a76 | ||
|
|
90b127a5f6 | ||
|
|
cac5373cd0 | ||
|
|
e914bfe970 | ||
|
|
8441d31783 | ||
|
|
c4597ef90f | ||
|
|
09b36ea169 | ||
|
|
ab6e0a7e40 | ||
|
|
58719c6025 | ||
|
|
1405bbc880 | ||
|
|
3fd7e79b59 | ||
|
|
a261edd03e | ||
|
|
1d4874476f | ||
|
|
6ceacad822 | ||
|
|
11956016c0 | ||
|
|
d9967c5a6b | ||
|
|
2a294632e4 | ||
|
|
47ec045d5c | ||
|
|
007554cf97 | ||
|
|
969cf2ff64 | ||
|
|
076d8f57d2 | ||
|
|
e5d72d08fc | ||
|
|
4eae9a00ff | ||
|
|
20b915e60a | ||
|
|
2e9623e6ae | ||
|
|
50df220467 | ||
|
|
3092a47caf | ||
|
|
9548ee4dfe | ||
|
|
eca4d8998c | ||
|
|
45c2f97bc7 | ||
|
|
0c44eddf32 | ||
|
|
0a5541291f | ||
|
|
e45ea82cb1 | ||
|
|
a718b9b2f1 | ||
|
|
dd10d8826f | ||
|
|
147d250bb1 | ||
|
|
61ec4392f2 | ||
|
|
6b9a4961f2 | ||
|
|
4e6df60bea | ||
|
|
6b2060bc28 | ||
|
|
a12f2ab821 | ||
|
|
4b44b82af4 | ||
|
|
4d676fb851 | ||
|
|
6f479078bd | ||
|
|
7ac6e2f493 | ||
|
|
3a6e91ab67 | ||
|
|
ff5ed0d7d3 | ||
|
|
fc743c5d6c | ||
|
|
588d68d2e7 | ||
|
|
bb6c4f1e4e | ||
|
|
e88d0c3769 | ||
|
|
8f5bba7f67 | ||
|
|
baafa77279 | ||
|
|
07b243c7a3 | ||
|
|
0a7fa1c18c | ||
|
|
bcfd40d45c | ||
|
|
ba20806ee0 | ||
|
|
6604dcb972 | ||
|
|
b007538123 | ||
|
|
bb2a3e0e9f | ||
|
|
f0b9fcaf57 | ||
|
|
7058f715a3 | ||
|
|
b33b3c9ce3 | ||
|
|
b5361473b5 | ||
|
|
aae2254fde | ||
|
|
9355cfcc49 | ||
|
|
0e7c731cc0 | ||
|
|
5d2de11b8c | ||
|
|
fda63d6265 | ||
|
|
1b21182e3e | ||
|
|
a733f4e35c | ||
|
|
4a62aac9d7 | ||
|
|
156e1b7a91 | ||
|
|
2df9885977 | ||
|
|
9626a24a83 | ||
|
|
1546c86286 | ||
|
|
cace69f9d1 | ||
|
|
6fefd933cb | ||
|
|
e2d7e2f019 | ||
|
|
e69638ad6d | ||
|
|
61786beb69 | ||
|
|
fbf5561aaf | ||
|
|
562e73fda5 | ||
|
|
4bf686b670 | ||
|
|
d49b3d5167 | ||
|
|
71326cdaac |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@navikt/aksel-icons": minor
|
||||
---
|
||||
|
||||
Icons: New icons `Gift`, `ArrowsPointingIn` and `ArrowsPointingOut`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@navikt/ds-react": patch
|
||||
---
|
||||
|
||||
Combobox: The list is no longer focusable
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@navikt/ds-react": patch
|
||||
---
|
||||
|
||||
Popover: Closed popovers no longer calculate their position on mount. This makes pages with many Dropdowns or HelpTexts render faster.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@navikt/ds-react": patch
|
||||
"@navikt/aksel-icons": patch
|
||||
---
|
||||
|
||||
ESM: Mark ESM output as side-effect free, so bundlers like webpack, Turbopack and esbuild can tree-shake unused components and icons.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@navikt/ds-react": patch
|
||||
---
|
||||
|
||||
Date utilities: Import date-fns functions and locales from their own paths, so unbundled Node imports (SSR, Vitest/Jest) no longer load every date-fns locale.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@navikt/ds-react": patch
|
||||
---
|
||||
|
||||
DataGrid: Fixed text selection inside details panels when keyboard navigation is enabled.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@navikt/aksel-icons": minor
|
||||
---
|
||||
|
||||
Icons: All icons are now available as CSS custom properties. Import `@navikt/aksel-icons/css/<IconName>.css` for a single icon (e.g. `--aksel-icon-magnifying-glass`), or `@navikt/aksel-icons/css/index.css` for every icon.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@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 +1,7 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Meld inn bugs eller feil
|
||||
title: "[BUG] "
|
||||
title: "[BUG]"
|
||||
labels: "bug \U0001F41B"
|
||||
assignees: ""
|
||||
---
|
||||
@@ -21,7 +21,7 @@ assignees: ""
|
||||
## Steg for å reprodusere problemet (valgfri)
|
||||
|
||||
<!--
|
||||
1. Steg én
|
||||
1. Steg en
|
||||
2. Steg to
|
||||
3. ....
|
||||
-->
|
||||
@@ -31,10 +31,10 @@ assignees: ""
|
||||
## Eksempel/Tilleggsinformasjon (valgfri)
|
||||
|
||||
<!--
|
||||
Screenshots, kodesnippets eller sandbox-lenke.
|
||||
Legg gjerne ved info som nettleser eller desktop/mobil hvis det er relevant.
|
||||
Screenshots eller kodesnippets
|
||||
Legg gjerne ved ekstrainfo som nettleser eller desktop/mobil hvis det er relevant.
|
||||
-->
|
||||
|
||||
```
|
||||
Hvis mulig, legg gjerne til kode her, ev. lenke til kode i repo.
|
||||
Hvis mulig, legg gjerne til kode her, evt lenke til kode i repo
|
||||
```
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Foreslå noe nytt!
|
||||
title: "[NEW] "
|
||||
labels: nytt ✨
|
||||
title: "[NEW]"
|
||||
labels: new ✨
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Eksempel på bruk
|
||||
description: Send et eksempel på ting fra Aksel som er brukt i det fri (tokens, komponenter, maler, etc.)
|
||||
description: Send et eksempel på ting fra Aksel som er brukt i det fri (tokens, komponenter, maler, etc)
|
||||
title: "[Eksempel på bruk]: "
|
||||
labels: ["dokumentasjon 📖"]
|
||||
body:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Ny komponent
|
||||
description: Send et ønske om én eller flere nye komponenter
|
||||
description: Send et ønske om en eller flere nye komponenter
|
||||
title: "[Ny komponent]: "
|
||||
labels: ["nytt ✨", "forespørsel 🥰", "komponenter 🧩"]
|
||||
body:
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Meld inn bugs eller feil
|
||||
title: "[BUG]"
|
||||
labels: "bug \U0001F41B"
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
## Hva forventet du å finne for dette søket?
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Innspill til komponent
|
||||
description: Send inn tilbakemelding på komponent
|
||||
description: Send inn innspill til komponent
|
||||
title: "[Innspill til komponent]: "
|
||||
labels: ["forespørsel 🥰", "komponenter 🧩", "nytt ✨", "beta 🧪"]
|
||||
body:
|
||||
@@ -8,7 +8,7 @@ body:
|
||||
value: |
|
||||
Takk for at du bidrar inn i Aksel!
|
||||
- type: textarea
|
||||
id: feedback
|
||||
id: feeback
|
||||
attributes:
|
||||
label: Tilbakemelding
|
||||
description: Beskriv hvilke tilbakemeldinger du har til komponenten
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Innspill til ikon
|
||||
description: Send inn tilbakemelding på ikon
|
||||
title: "[Innspill til ikon]: "
|
||||
description: Send inn innspill til ikon
|
||||
title: "[Tilbakemelding på ikon]: "
|
||||
labels: ["forespørsel 🥰", "ikoner 🖼️"]
|
||||
body:
|
||||
- type: markdown
|
||||
@@ -8,7 +8,7 @@ body:
|
||||
value: |
|
||||
Takk for at du bidrar inn i Aksel!
|
||||
- type: textarea
|
||||
id: feedback
|
||||
id: feeback
|
||||
attributes:
|
||||
label: Tilbakemelding
|
||||
description: Beskriv hvilke tilbakemeldinger du har til ikonet
|
||||
@@ -21,7 +21,7 @@
|
||||
## Repo summary
|
||||
|
||||
- Nav's design system. Yarn 4 workspaces monorepo: published packages under `@navikt/` + docs site `aksel.nav.no/` + `apps/` + `tooling/`.
|
||||
- Stack: TypeScript 6, React 19 (dev), Next.js 16, Storybook 10, Vitest 4, Oxlint 1 + ESLint 9, Stylelint 17, Biome 2 (lint only), Prettier 3 (formatting). Node 22+, Yarn 4.18.0.
|
||||
- Stack: TypeScript 6, React 19 (dev), Next.js 16, Storybook 10, Vitest 4, ESLint 9, Stylelint 17, Biome 2 (lint only), Prettier 3 (formatting). Node 22+, Yarn 4.18.0.
|
||||
- Published `@navikt/*` packages must stay React 17 compatible (peer `react ^17 || ^18 || ^19`).
|
||||
|
||||
## Start and validate
|
||||
@@ -44,12 +44,12 @@
|
||||
|
||||
## Commands
|
||||
|
||||
- `corepack yarn oxlint --deny-warnings <files...>` / `corepack yarn eslint --max-warnings=0 --no-warn-ignored <files...>` (both: oxlint runs most rules, ESLint the rest) / `corepack yarn stylelint <files...>` / `corepack yarn biome lint --no-errors-on-unmatched <files...>`
|
||||
- `corepack yarn eslint --max-warnings=0 --no-warn-ignored <files...>` / `corepack yarn stylelint <files...>` / `corepack yarn biome lint --no-errors-on-unmatched <files...>`
|
||||
- `corepack yarn tsc --noEmit --incremental false --project <tsconfig>` - targeted type-check
|
||||
- `corepack yarn workspace <name> <script>` - e.g. `@navikt/ds-react test` (Vitest + tsc), `website test`
|
||||
- `corepack yarn lint` / `corepack yarn test` / `corepack yarn boot` - root suites (slow; cross-workspace changes only)
|
||||
- `corepack yarn storybook` (:6006) / `corepack yarn storybook:aksel` (website examples, :6007, no secrets) / `corepack yarn storybook:test` (play tests, Playwright Firefox; Chromium when `COPILOT_CLI` is set) / `corepack yarn dev` (website, :3000, needs Sanity token)
|
||||
- Targeted play tests: `corepack yarn storybook:test <path/to/X.tests.stories.tsx>` (repo root). Run after changing play tests. Under Copilot CLI this runs Chromium; if the browser can't launch, report tests as not run with the exact command.
|
||||
- Targeted play tests: `corepack yarn vitest run --config ./vitest.config.ts --project=storybook <path/to/X.tests.stories.tsx>` (repo root). Run after changing play tests. Under Copilot CLI this runs Chromium; if the browser can't launch, report tests as not run with the exact command.
|
||||
|
||||
## Where to edit
|
||||
|
||||
@@ -63,7 +63,7 @@ Path-specific rules load automatically from `.github/instructions/` (see each `a
|
||||
- `@navikt/aksel/src/` - CLI and codemods
|
||||
- `aksel.nav.no/website/` - Next.js docs site · `aksel.nav.no/sanity-studio/` - CMS schema
|
||||
- `apps/playroom/`, `apps/mcp-server/`, `apps/figma-icon-plugin/` - apps · `tooling/` - ESLint plugin, scripts, changelog, analyzer
|
||||
- `.changeset/` - release notes · Root config: `.oxlintrc.json`, `eslint.config.js`, `stylelint.config.mjs`, `biome.json`, `vitest.config.mts`, `.storybook/`
|
||||
- `.changeset/` - release notes · Root config: `eslint.config.js`, `stylelint.config.mjs`, `biome.json`, `vitest.config.ts`, `.storybook/`
|
||||
|
||||
## Skills and agents
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ applyTo: "**/*.test.ts,**/*.test.tsx,**/*.stories.tsx"
|
||||
# Tests and stories
|
||||
|
||||
- User flows: use a story `play` function in `<Component>.tests.stories.tsx`. Pure logic and hooks: use Vitest `*.test.ts(x)` next to the source file.
|
||||
- `corepack yarn storybook:test` runs every story that has a `play` function (Storybook auto-tags them `play-fn`, see root `vitest.config.mts`). To opt out, use `tags: ["skip-test"]` with a reason.
|
||||
- `corepack yarn storybook:test` runs every story that has a `play` function (Storybook auto-tags them `play-fn`, see root `vitest.config.ts`). To opt out, use `tags: ["!play-fn", "skip-test"]` with a reason.
|
||||
- Play-test files set `parameters: { chromatic: { disable: true } }`. Visual stories feed Chromatic; use `renderStoriesForChromatic` from `utils/` when adding visual variants.
|
||||
- Import from `@storybook/react-vite` and `storybook/test`, not `@storybook/react` or `@storybook/test`.
|
||||
- Query by role and accessible name (`getByRole("button", { name })`). Avoid snapshots unless nearby tests already use them.
|
||||
@@ -17,4 +17,4 @@ applyTo: "**/*.test.ts,**/*.test.tsx,**/*.stories.tsx"
|
||||
- Before removing or consolidating tests, map each meaningful old scenario/assertion to its replacement story or test. Keep coverage that still represents a supported behavior; explain when a scenario is intentionally obsolete.
|
||||
- For state, accessibility, keyboard or focus behavior, use the [component-contract skill](../skills/component-contract/SKILL.md) to identify the contract and evidence to preserve.
|
||||
- ds-react unit tests run with `TZ=UTC`. Date logic must not depend on the local timezone.
|
||||
- Browser tests need Playwright Firefox (Chromium under Copilot CLI, see `vitest.config.mts`). Yarn has `enableScripts: false`, so if the browser is missing, report it instead of installing globally.
|
||||
- Browser tests need Playwright Firefox (Chromium under Copilot CLI, see `vitest.config.ts`). Yarn has `enableScripts: false`, so if the browser is missing, report it instead of installing globally.
|
||||
@@ -57,7 +57,6 @@ Read the current manifest before running a table command. A manifest is source o
|
||||
Group only matching files:
|
||||
|
||||
```sh
|
||||
corepack yarn oxlint --deny-warnings <changed .ts/.tsx/.js/.jsx files>
|
||||
corepack yarn eslint --max-warnings=0 --no-warn-ignored <changed .ts/.tsx/.js/.jsx files>
|
||||
corepack yarn stylelint <changed .css files>
|
||||
corepack yarn biome lint --no-errors-on-unmatched <changed supported source/config files>
|
||||
|
||||
@@ -54,7 +54,6 @@ Hard rules (enforced by `tooling/eslint` rules `aksel-local/*`):
|
||||
## 3. Validate
|
||||
|
||||
```sh
|
||||
corepack yarn oxlint --deny-warnings <file>
|
||||
corepack yarn eslint --max-warnings=0 --no-warn-ignored <file>
|
||||
corepack yarn workspace website validate:eksempler
|
||||
corepack yarn storybook:aksel # visual check at :6007, needs no secrets
|
||||
|
||||
@@ -17,7 +17,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint (Oxlint, ESLint, Stylelint etc.)
|
||||
name: Lint (ESLint, Stylelint etc.)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
-467
@@ -1,467 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "import"],
|
||||
"categories": {
|
||||
"correctness": "off"
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "19.3.1"
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"builtin": true,
|
||||
"es2026": true,
|
||||
"browser": true,
|
||||
"node": true
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"**/public",
|
||||
"@navikt/aksel/**/*.input.*",
|
||||
"@navikt/aksel/**/*.output.*",
|
||||
"**/codemod/**/*.js",
|
||||
"**/tokens/**/plugin.js",
|
||||
"**/query-types.ts",
|
||||
".yarn",
|
||||
"**/next-env.d.ts",
|
||||
"@navikt/aksel-icons/src/**",
|
||||
"@navikt/aksel/src/version.ts",
|
||||
"@navikt/core/tailwind/{src/tokens.ts,tailwind.config.js,tailwind.darkside.config.js}",
|
||||
"@navikt/core/tokens/token_docs.js",
|
||||
"apps/playroom/{playroom.config.js,src/auto-generated-imports.ts}",
|
||||
"**/.build",
|
||||
"**/storybook-static",
|
||||
"**/test-results",
|
||||
"tooling/analyzer/temp",
|
||||
"apps/figma-icon-plugin/src/ui/vite-env.d.ts"
|
||||
],
|
||||
"rules": {
|
||||
"constructor-super": "error",
|
||||
"for-direction": "error",
|
||||
"getter-return": "error",
|
||||
"no-async-promise-executor": "error",
|
||||
"no-case-declarations": "error",
|
||||
"no-class-assign": "error",
|
||||
"no-compare-neg-zero": "error",
|
||||
"no-cond-assign": "error",
|
||||
"no-const-assign": "error",
|
||||
"no-constant-binary-expression": "error",
|
||||
"no-constant-condition": "error",
|
||||
"no-control-regex": "error",
|
||||
"no-debugger": "error",
|
||||
"no-delete-var": "error",
|
||||
"no-dupe-class-members": "error",
|
||||
"no-dupe-else-if": "error",
|
||||
"no-dupe-keys": "error",
|
||||
"no-duplicate-case": "error",
|
||||
"no-empty": "error",
|
||||
"no-empty-character-class": "error",
|
||||
"no-empty-pattern": "error",
|
||||
"no-empty-static-block": "error",
|
||||
"no-ex-assign": "error",
|
||||
"no-extra-boolean-cast": "error",
|
||||
"no-fallthrough": "error",
|
||||
"no-func-assign": "error",
|
||||
"no-global-assign": "error",
|
||||
"no-import-assign": "error",
|
||||
"no-invalid-regexp": "error",
|
||||
"no-irregular-whitespace": "error",
|
||||
"no-loss-of-precision": "error",
|
||||
"no-misleading-character-class": "error",
|
||||
"no-new-native-nonconstructor": "error",
|
||||
"no-nonoctal-decimal-escape": "error",
|
||||
"no-obj-calls": "error",
|
||||
"no-prototype-builtins": "error",
|
||||
"no-redeclare": "error",
|
||||
"no-regex-spaces": "error",
|
||||
"no-self-assign": "error",
|
||||
"no-setter-return": "error",
|
||||
"no-shadow-restricted-names": "error",
|
||||
"no-sparse-arrays": "error",
|
||||
"no-this-before-super": "error",
|
||||
"no-unassigned-vars": "error",
|
||||
"no-unexpected-multiline": "error",
|
||||
"no-unreachable": "error",
|
||||
"no-unsafe-finally": "error",
|
||||
"no-unsafe-negation": "error",
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"no-unused-labels": "error",
|
||||
"no-unused-private-class-members": "error",
|
||||
"no-unused-vars": "error",
|
||||
"no-useless-backreference": "error",
|
||||
"no-useless-catch": "error",
|
||||
"no-useless-escape": "error",
|
||||
"no-with": "error",
|
||||
"preserve-caught-error": "error",
|
||||
"require-yield": "error",
|
||||
"use-isnan": "error",
|
||||
"valid-typeof": "error",
|
||||
"react/jsx-key": "error",
|
||||
"react/jsx-no-comment-textnodes": "error",
|
||||
"react/jsx-no-duplicate-props": "error",
|
||||
"react/jsx-no-target-blank": "error",
|
||||
"react/jsx-no-undef": "error",
|
||||
"react/no-children-prop": "error",
|
||||
"react/no-danger-with-children": "error",
|
||||
"react/no-direct-mutation-state": "error",
|
||||
"react/no-find-dom-node": "error",
|
||||
"react/no-is-mounted": "error",
|
||||
"react/no-render-return-value": "error",
|
||||
"react/no-string-refs": "error",
|
||||
"react/no-unescaped-entities": "error",
|
||||
"react/no-unknown-property": "error",
|
||||
"react/react-in-jsx-scope": "error",
|
||||
"import/default": "error",
|
||||
"import/no-named-as-default-member": "warn",
|
||||
"import/no-duplicates": "warn",
|
||||
"react/jsx-curly-brace-presence": [
|
||||
"error",
|
||||
{
|
||||
"propElementValues": "always"
|
||||
}
|
||||
],
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/exhaustive-deps": [
|
||||
"warn",
|
||||
{
|
||||
"additionalHooks": "(useClientLayoutEffect)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["aksel.nav.no/sanity-studio/**"],
|
||||
"rules": {
|
||||
"react/react-in-jsx-scope": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"**/*.stories.{ts,tsx,js,jsx,mjs,cjs}",
|
||||
"**/*.story.{ts,tsx,js,jsx,mjs,cjs}"
|
||||
],
|
||||
"rules": {
|
||||
"storybook/await-interactions": "error",
|
||||
"storybook/context-in-play-function": "error",
|
||||
"storybook/default-exports": "error",
|
||||
"storybook/hierarchy-separator": "warn",
|
||||
"storybook/no-redundant-story-name": "warn",
|
||||
"storybook/no-renderer-packages": "error",
|
||||
"storybook/prefer-pascal-case": "warn",
|
||||
"storybook/story-exports": "error",
|
||||
"storybook/use-storybook-expect": "error",
|
||||
"storybook/use-storybook-testing-library": "error",
|
||||
"import/no-anonymous-default-export": "off",
|
||||
"react/rules-of-hooks": "off"
|
||||
},
|
||||
"jsPlugins": ["eslint-plugin-storybook"]
|
||||
},
|
||||
{
|
||||
"files": ["**/.storybook/main.{js,cjs,mjs,ts}"],
|
||||
"rules": {
|
||||
"storybook/no-uninstalled-addons": "error"
|
||||
},
|
||||
"jsPlugins": ["eslint-plugin-storybook"]
|
||||
},
|
||||
{
|
||||
"files": ["**/*.{ts,tsx}"],
|
||||
"rules": {
|
||||
"constructor-super": "off",
|
||||
"getter-return": "off",
|
||||
"no-class-assign": "off",
|
||||
"no-const-assign": "off",
|
||||
"no-dupe-class-members": "off",
|
||||
"no-dupe-keys": "off",
|
||||
"no-func-assign": "off",
|
||||
"no-import-assign": "off",
|
||||
"no-new-native-nonconstructor": "off",
|
||||
"no-obj-calls": "off",
|
||||
"no-redeclare": "off",
|
||||
"no-setter-return": "off",
|
||||
"no-this-before-super": "off",
|
||||
"no-unreachable": "off",
|
||||
"no-unsafe-negation": "off",
|
||||
"no-with": "off",
|
||||
"no-var": "error",
|
||||
"prefer-const": "error",
|
||||
"prefer-rest-params": "error",
|
||||
"prefer-spread": "error",
|
||||
"no-array-constructor": "error",
|
||||
"no-unused-expressions": [
|
||||
"error",
|
||||
{
|
||||
"allowShortCircuit": true,
|
||||
"allowTernary": true
|
||||
}
|
||||
],
|
||||
"typescript/ban-ts-comment": "error",
|
||||
"typescript/no-duplicate-enum-values": "error",
|
||||
"typescript/no-empty-object-type": "error",
|
||||
"typescript/no-explicit-any": "off",
|
||||
"typescript/no-extra-non-null-assertion": "error",
|
||||
"typescript/no-misused-new": "error",
|
||||
"typescript/no-namespace": "error",
|
||||
"typescript/no-non-null-asserted-optional-chain": "error",
|
||||
"typescript/no-require-imports": "error",
|
||||
"typescript/no-this-alias": "error",
|
||||
"typescript/no-unnecessary-type-constraint": "error",
|
||||
"typescript/no-unsafe-declaration-merging": "error",
|
||||
"typescript/no-unsafe-function-type": "error",
|
||||
"typescript/no-wrapper-object-types": "error",
|
||||
"typescript/prefer-as-const": "error",
|
||||
"typescript/prefer-namespace-keyword": "error",
|
||||
"typescript/triple-slash-reference": "error",
|
||||
"import/default": "off",
|
||||
"import/no-named-as-default-member": "off"
|
||||
},
|
||||
"plugins": ["typescript"]
|
||||
},
|
||||
{
|
||||
"files": ["**/*.test.*", "**/__tests__/*"],
|
||||
"rules": {
|
||||
"testing-library/await-async-events": [
|
||||
"error",
|
||||
{
|
||||
"eventModule": "userEvent"
|
||||
}
|
||||
],
|
||||
"testing-library/await-async-queries": "error",
|
||||
"testing-library/await-async-utils": "error",
|
||||
"testing-library/no-await-sync-events": [
|
||||
"error",
|
||||
{
|
||||
"eventModules": ["fire-event"]
|
||||
}
|
||||
],
|
||||
"testing-library/no-await-sync-queries": "error",
|
||||
"testing-library/no-container": "error",
|
||||
"testing-library/no-debugging-utils": "warn",
|
||||
"testing-library/no-dom-import": ["error", "react"],
|
||||
"testing-library/no-global-regexp-flag-in-query": "error",
|
||||
"testing-library/no-manual-cleanup": "error",
|
||||
"testing-library/no-node-access": "error",
|
||||
"testing-library/no-promise-in-fire-event": "error",
|
||||
"testing-library/no-render-in-lifecycle": "error",
|
||||
"testing-library/no-unnecessary-act": "error",
|
||||
"testing-library/no-wait-for-multiple-assertions": "error",
|
||||
"testing-library/no-wait-for-side-effects": "error",
|
||||
"testing-library/no-wait-for-snapshot": "error",
|
||||
"testing-library/prefer-find-by": "error",
|
||||
"testing-library/prefer-presence-queries": "error",
|
||||
"testing-library/prefer-query-by-disappearance": "error",
|
||||
"testing-library/prefer-screen-queries": "error",
|
||||
"testing-library/render-result-naming-convention": "error",
|
||||
"vitest/expect-expect": "error",
|
||||
"vitest/no-commented-out-tests": "error",
|
||||
"vitest/no-disabled-tests": "warn",
|
||||
"vitest/no-focused-tests": "error",
|
||||
"vitest/no-identical-title": "error",
|
||||
"vitest/no-import-node-test": "error",
|
||||
"vitest/no-interpolation-in-snapshots": "error",
|
||||
"vitest/no-mocks-import": "error",
|
||||
"vitest/no-standalone-expect": "error",
|
||||
"vitest/no-unneeded-async-expect-function": "error",
|
||||
"vitest/prefer-called-exactly-once-with": "error",
|
||||
"vitest/require-local-test-context-for-concurrent-snapshots": "error",
|
||||
"vitest/valid-describe-callback": "error",
|
||||
"vitest/valid-expect": "error",
|
||||
"vitest/valid-expect-in-promise": "error",
|
||||
"vitest/valid-title": "error"
|
||||
},
|
||||
"jsPlugins": ["eslint-plugin-testing-library"],
|
||||
"plugins": ["vitest"]
|
||||
},
|
||||
{
|
||||
"files": ["aksel.nav.no/website/**"],
|
||||
"rules": {
|
||||
"react/no-unknown-property": [
|
||||
"error",
|
||||
{
|
||||
"ignore": ["jsx", "global"]
|
||||
}
|
||||
],
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"nextjs/google-font-display": "warn",
|
||||
"nextjs/google-font-preconnect": "warn",
|
||||
"nextjs/next-script-for-ga": "warn",
|
||||
"nextjs/no-async-client-component": "warn",
|
||||
"nextjs/no-before-interactive-script-outside-document": "warn",
|
||||
"nextjs/no-css-tags": "warn",
|
||||
"nextjs/no-head-element": "warn",
|
||||
"nextjs/no-img-element": "warn",
|
||||
"nextjs/no-page-custom-font": "warn",
|
||||
"nextjs/no-styled-jsx-in-document": "warn",
|
||||
"nextjs/no-sync-scripts": "warn",
|
||||
"nextjs/no-title-in-document-head": "warn",
|
||||
"nextjs/no-typos": "warn",
|
||||
"nextjs/no-unwanted-polyfillio": "warn",
|
||||
"nextjs/inline-script-id": "error",
|
||||
"nextjs/no-assign-module-variable": "error",
|
||||
"nextjs/no-document-import-in-page": "error",
|
||||
"nextjs/no-duplicate-head": "error",
|
||||
"nextjs/no-head-import-in-document": "error",
|
||||
"nextjs/no-script-component-in-head": "error"
|
||||
},
|
||||
"plugins": ["nextjs"]
|
||||
},
|
||||
{
|
||||
"files": ["aksel.nav.no/website/pages/eksempler/**"],
|
||||
"rules": {
|
||||
"nextjs/no-img-element": "off"
|
||||
},
|
||||
"plugins": ["nextjs"]
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"aksel.nav.no/website/pages/eksempler/**/*.tsx",
|
||||
"aksel.nav.no/website/pages/templates/**/*.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"aksel-local/args-check": ["error"],
|
||||
"aksel-local/comment-check": ["error"]
|
||||
},
|
||||
"jsPlugins": ["eslint-plugin-aksel-local"]
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"**/examples/__parts*/*.tsx",
|
||||
"**/pages/templates/**/*.tsx",
|
||||
"**/pages/eksempler/**/*.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"aksel-local/import-check": ["error"]
|
||||
},
|
||||
"jsPlugins": ["eslint-plugin-aksel-local"]
|
||||
},
|
||||
{
|
||||
"files": ["**/examples/__parts-inline/*.tsx"],
|
||||
"rules": {
|
||||
"arrow-body-style": ["error", "never"],
|
||||
"func-style": ["error", "expression"],
|
||||
"import/no-named-export": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["@navikt/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "react",
|
||||
"importNames": [
|
||||
"useId",
|
||||
"useInsertionEffect",
|
||||
"useSyncExternalStore",
|
||||
"useDeferredValue",
|
||||
"useTransition",
|
||||
"startTransition"
|
||||
],
|
||||
"message": "React 18+ API not allowed. We currently only support React-features accesible in React 17. To add new features, we will need to update peerDependencies (breaking change)."
|
||||
},
|
||||
{
|
||||
"name": "react",
|
||||
"importNames": [
|
||||
"useOptimistic",
|
||||
"useActionState",
|
||||
"use",
|
||||
"cache",
|
||||
"useEffectEvent",
|
||||
"ViewTransition",
|
||||
"addTransitionType"
|
||||
],
|
||||
"message": "React 19+ API not allowed. We currently only support React-features accesible in React 17. To add new features, we will need to update peerDependencies (breaking change)."
|
||||
},
|
||||
{
|
||||
"name": "react-dom/client",
|
||||
"importNames": ["createRoot", "hydrateRoot"],
|
||||
"message": "React 18+ API not allowed (targeting React 17)."
|
||||
},
|
||||
{
|
||||
"name": "react-dom",
|
||||
"importNames": ["browser"],
|
||||
"message": "React 19+ API not allowed (targeting React 17)."
|
||||
}
|
||||
],
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["fs", "path"],
|
||||
"message": "Use `node:` prefix when importing native node modules, e.g. `import path from \"node:path\"`."
|
||||
},
|
||||
{
|
||||
"group": ["**/esm/**", "**/cjs/**"],
|
||||
"message": "Do not import from build output directories. Import from source instead."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-restricted-properties": [
|
||||
"error",
|
||||
{
|
||||
"object": "React",
|
||||
"property": "useId",
|
||||
"message": "React 18+ API not allowed (targeting React 17)."
|
||||
},
|
||||
{
|
||||
"object": "React",
|
||||
"property": "useInsertionEffect",
|
||||
"message": "React 18+ API not allowed (targeting React 17)."
|
||||
},
|
||||
{
|
||||
"object": "React",
|
||||
"property": "useSyncExternalStore",
|
||||
"message": "React 18+ API not allowed (targeting React 17)."
|
||||
},
|
||||
{
|
||||
"object": "React",
|
||||
"property": "useDeferredValue",
|
||||
"message": "React 18+ API not allowed (targeting React 17)."
|
||||
},
|
||||
{
|
||||
"object": "React",
|
||||
"property": "useTransition",
|
||||
"message": "React 18+ API not allowed (targeting React 17)."
|
||||
},
|
||||
{
|
||||
"object": "React",
|
||||
"property": "startTransition",
|
||||
"message": "React 18+ API not allowed (targeting React 17)."
|
||||
},
|
||||
{
|
||||
"object": "React",
|
||||
"property": "useOptimistic",
|
||||
"message": "React 19+ API not allowed (targeting React 17)."
|
||||
},
|
||||
{
|
||||
"object": "React",
|
||||
"property": "useActionState",
|
||||
"message": "React 19+ API not allowed (targeting React 17)."
|
||||
},
|
||||
{
|
||||
"object": "React",
|
||||
"property": "use",
|
||||
"message": "React 19+ API not allowed (targeting React 17)."
|
||||
},
|
||||
{
|
||||
"object": "React",
|
||||
"property": "cache",
|
||||
"message": "React 19+ API not allowed (targeting React 17)."
|
||||
},
|
||||
{
|
||||
"object": "React",
|
||||
"property": "useEffectEvent",
|
||||
"message": "React 19+ API not allowed (targeting React 17)."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["@navikt/**/*.stories.tsx"],
|
||||
"rules": {
|
||||
"no-restricted-imports": "off",
|
||||
"no-restricted-properties": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
-1
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"oxc.oxc-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"stylelint.vscode-stylelint",
|
||||
"biomejs.biome",
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
# @navikt/aksel-icons
|
||||
|
||||
## 8.22.1
|
||||
|
||||
## 8.22.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Icons: New icons `Gift`, `ArrowsPointingIn` and `ArrowsPointingOut`. ([#5484](https://github.com/navikt/aksel/pull/5484))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ESM: Mark ESM output as side-effect free, so bundlers like webpack, Turbopack and esbuild can tree-shake unused components and icons. ([#5472](https://github.com/navikt/aksel/pull/5472))
|
||||
|
||||
## 8.21.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
## Foreslå nye ikoner
|
||||
|
||||
Om du ikke finner ikonet du leter etter kan du [lage et issue på GitHub](https://github.com/navikt/aksel/issues/new?template=new-icon.yaml).
|
||||
Om du ikke finner ikonet du leter etter kan du [lage et issue på GitHub](https://github.com/navikt/aksel/issues/new?labels=nytt+✨%2Cikoner+🖼%2Cforespørsel+🥰&template=new-icon.yaml&title=%5BNytt+ikon%5D%3A+).
|
||||
|
||||
## Oppdatere ikon
|
||||
|
||||
Hvis du tenker et ikon fortjener en oppdatering eller er utdatert kan [du foreslå det her](https://github.com/navikt/aksel/issues/new?template=update-icon.yml).
|
||||
Hvis du tenker et ikon fortjener en oppdatering eller er utdatert kan [du foreslå det her](https://github.com/navikt/aksel/issues/new?labels=forespørsel+🥰&template=update-icon.yml&title=%5BInnspill+til+ikon%5D%3A+).
|
||||
|
||||
## Design av nye ikoner
|
||||
|
||||
|
||||
@@ -59,37 +59,6 @@ declare module "*.svg" {
|
||||
}
|
||||
```
|
||||
|
||||
### CSS custom properties
|
||||
|
||||
All icons are available as CSS custom properties behind `/css`, one file per icon. Each file declares a `--aksel-icon-<kebab-case-name>` variable on `:root`, containing the icon as an SVG data URI.
|
||||
|
||||
```css
|
||||
@import "@navikt/aksel-icons/css/MagnifyingGlass.css";
|
||||
|
||||
.search-button::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
background-color: currentColor;
|
||||
mask: var(--aksel-icon-magnifying-glass) center / contain no-repeat;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.search-button::before {
|
||||
background-color: CanvasText;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To get every icon, import `@navikt/aksel-icons/css/index.css`. This file contains all ~1000 icons (≈700 kB, ≈135 kB gzipped), so prefer importing only the icons you use.
|
||||
|
||||
Import icon files once in a global stylesheet (e.g. your root layout or `globals.css`), not in CSS modules or per-component stylesheets. The variables are global, and the SVG is inlined in each file, so importing the same icon in several places duplicates it in every CSS chunk.
|
||||
|
||||
- Icons added with CSS are decorative only. Give the parent element an accessible name, e.g. with visible text or `aria-label`.
|
||||
- In forced colors mode (e.g. Windows High Contrast), `background-color` is forced to the page background, which hides the icon. Use `background-color: CanvasText` inside `@media (forced-colors: active)` as shown above, so the icon follows the user's text color.
|
||||
- If you use a Content Security Policy, `img-src` must allow `data:`.
|
||||
|
||||
## [Learn more](https://aksel.nav.no/ikoner)
|
||||
|
||||
## License
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import fastglob from "fast-glob";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
optimizeSvg,
|
||||
svgToDataUri,
|
||||
toCssVariableName,
|
||||
// @ts-expect-error -- css-icons is JavaScript without a declaration file.
|
||||
} from "../config/css-icons";
|
||||
|
||||
describe("toCssVariableName", () => {
|
||||
test.each([
|
||||
["MagnifyingGlass", "--aksel-icon-magnifying-glass"],
|
||||
["Buildings2Fill", "--aksel-icon-buildings-2-fill"],
|
||||
["ClipboardXMark", "--aksel-icon-clipboard-x-mark"],
|
||||
["XMark", "--aksel-icon-x-mark"],
|
||||
])("%s -> %s", (iconName, expected) => {
|
||||
expect(toCssVariableName(iconName)).toBe(expected);
|
||||
});
|
||||
|
||||
test("is unique for every icon", () => {
|
||||
const names = fastglob
|
||||
.sync("*.svg", { cwd: path.resolve(__dirname, "../icons") })
|
||||
.map((file) => toCssVariableName(path.basename(file, ".svg")));
|
||||
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("svgToDataUri", () => {
|
||||
const svg = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.5 3.25 4.75 10.5Z" fill="#202733"/>
|
||||
</svg>`;
|
||||
|
||||
test("keeps viewBox, uses currentColor and escapes unsafe characters", () => {
|
||||
const uri = svgToDataUri(optimizeSvg(svg));
|
||||
const data = uri.slice('url("'.length, -'")'.length);
|
||||
|
||||
expect(uri.startsWith('url("data:image/svg+xml,')).toBe(true);
|
||||
expect(data).toContain("viewBox='0 0 24 24'");
|
||||
expect(data).toContain("fill='currentColor'");
|
||||
expect(data).not.toMatch(/["#<>]|width=|height=/);
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
const fastglob = require("fast-glob");
|
||||
const kebabCase = require("lodash/kebabCase");
|
||||
const path = require("node:path");
|
||||
const {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} = require("node:fs");
|
||||
const { optimize } = require("svgo");
|
||||
|
||||
/**
|
||||
* @param {string} iconName e.g. "MagnifyingGlass"
|
||||
* @returns {string} e.g. "--aksel-icon-magnifying-glass"
|
||||
*/
|
||||
function toCssVariableName(iconName) {
|
||||
return `--aksel-icon-${kebabCase(iconName)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} svgString
|
||||
* @returns {string} Minified SVG with currentColor fill and preserved viewBox
|
||||
*/
|
||||
function optimizeSvg(svgString) {
|
||||
return optimize(svgString.replaceAll("#202733", "currentColor"), {
|
||||
multipass: true,
|
||||
plugins: [
|
||||
{
|
||||
name: "preset-default",
|
||||
params: { overrides: { removeViewBox: false } },
|
||||
},
|
||||
{ name: "removeAttrs", params: { attrs: "svg:(width|height)" } },
|
||||
],
|
||||
}).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes SVG for use in a CSS `url()`. Keeps readable characters unescaped for smaller output.
|
||||
* @param {string} svgString
|
||||
* @returns {string}
|
||||
*/
|
||||
function svgToDataUri(svgString) {
|
||||
const encoded = svgString
|
||||
.replaceAll('"', "'")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/[^\x20-\x7e]|[%#<>{}"\\]/g, encodeURIComponent);
|
||||
|
||||
return `url("data:image/svg+xml,${encoded}")`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} declarations
|
||||
* @returns {string}
|
||||
*/
|
||||
function createCssFile(declarations) {
|
||||
return `:root {\n${declarations.map((d) => ` ${d}`).join("\n")}\n}\n`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const basePath = path.resolve(__dirname, "../icons");
|
||||
const cssFolder = path.resolve(__dirname, "../dist/css");
|
||||
|
||||
const svgList = fastglob.sync("*.svg", { cwd: basePath }).sort();
|
||||
|
||||
if (existsSync(cssFolder)) {
|
||||
rmSync(cssFolder, { recursive: true, force: true });
|
||||
}
|
||||
mkdirSync(cssFolder, { recursive: true });
|
||||
|
||||
const declarations = [];
|
||||
|
||||
for (const svg of svgList) {
|
||||
const iconName = path.basename(svg, ".svg");
|
||||
const svgString = readFileSync(path.join(basePath, svg)).toString();
|
||||
const declaration = `${toCssVariableName(iconName)}: ${svgToDataUri(optimizeSvg(svgString))};`;
|
||||
|
||||
declarations.push(declaration);
|
||||
writeFileSync(
|
||||
path.join(cssFolder, `${iconName}.css`),
|
||||
createCssFile([declaration]),
|
||||
);
|
||||
}
|
||||
|
||||
writeFileSync(path.join(cssFolder, "index.css"), createCssFile(declarations));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createCssFile,
|
||||
main,
|
||||
optimizeSvg,
|
||||
svgToDataUri,
|
||||
toCssVariableName,
|
||||
};
|
||||
@@ -2,12 +2,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import * as AkselIcons from "@navikt/aksel-icons";
|
||||
import type { StarFillIcon } from "@navikt/aksel-icons";
|
||||
import "@navikt/aksel-icons/css/Airplane.css";
|
||||
import "@navikt/aksel-icons/css/Buildings2Fill.css";
|
||||
import "@navikt/aksel-icons/css/MagnifyingGlass.css";
|
||||
import "@navikt/aksel-icons/css/StarFill.css";
|
||||
import "@navikt/aksel-icons/css/XMarkOctagonFill.css";
|
||||
import StarIcon from "@navikt/aksel-icons/svg/Star.svg?no-inline";
|
||||
|
||||
const meta: Meta<typeof StarFillIcon> = {
|
||||
title: "aksel-icons/Icons",
|
||||
@@ -31,76 +25,3 @@ export const IconsInverted: Story = {
|
||||
render: Icons.render,
|
||||
globals: { theme: "dark" },
|
||||
};
|
||||
|
||||
const cssIconVariables = [
|
||||
"--aksel-icon-airplane",
|
||||
"--aksel-icon-buildings-2-fill",
|
||||
"--aksel-icon-magnifying-glass",
|
||||
"--aksel-icon-star-fill",
|
||||
"--aksel-icon-x-mark-octagon-fill",
|
||||
];
|
||||
|
||||
export const CssIcons: Story = {
|
||||
render: () => (
|
||||
<div
|
||||
style={{ display: "flex", gap: "1rem", color: "var(--ax-text-accent)" }}
|
||||
>
|
||||
<style>
|
||||
{`.css-icon {
|
||||
display: inline-block;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
background-color: currentColor;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
.css-icon { background-color: CanvasText; }
|
||||
}`}
|
||||
</style>
|
||||
{cssIconVariables.map((variable) => (
|
||||
<span
|
||||
key={variable}
|
||||
title={variable}
|
||||
className="css-icon"
|
||||
style={{ mask: `var(${variable}) center / contain no-repeat` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const CssIconsInverted: Story = {
|
||||
render: CssIcons.render,
|
||||
globals: { theme: "dark" },
|
||||
};
|
||||
|
||||
export const InlineSvgIcon: Story = {
|
||||
render: () => (
|
||||
<div style={{ color: "var(--ax-text-accent)" }}>
|
||||
<svg aria-hidden width="3rem" height="3rem">
|
||||
<use
|
||||
href={`${StarIcon}`}
|
||||
width="100%"
|
||||
height="100%"
|
||||
color="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const InlineSvgIconInverted: Story = {
|
||||
render: () => (
|
||||
<div style={{ color: "var(--ax-text-accent)" }}>
|
||||
<svg aria-hidden width="3rem" height="3rem">
|
||||
<use
|
||||
href={`${StarIcon}`}
|
||||
width="100%"
|
||||
height="100%"
|
||||
color="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
),
|
||||
globals: { theme: "dark" },
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel-icons",
|
||||
"version": "8.22.1",
|
||||
"version": "8.21.0",
|
||||
"author": "Aksel | Nav",
|
||||
"homepage": "https://aksel.nav.no/ikoner",
|
||||
"repository": {
|
||||
@@ -23,9 +23,7 @@
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
},
|
||||
"sideEffects": [
|
||||
"*.css"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"license": "MIT",
|
||||
"main": "./dist/react/cjs/index.js",
|
||||
"module": "./dist/react/esm/index.js",
|
||||
@@ -46,7 +44,6 @@
|
||||
"default": "./dist/metadata.js"
|
||||
},
|
||||
"./svg/*.svg": "./dist/svg/*.svg",
|
||||
"./css/*.css": "./dist/css/*.css",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"typesVersions": {
|
||||
@@ -65,11 +62,10 @@
|
||||
"create-icons": "svgr --no-prettier --silent --index-template config/index-template.js --out-dir src icons && yarn prettify-icons",
|
||||
"prettify-icons": "yarn prettier ./src --write --ignore-path nonexisting-path --log-level silent --no-config",
|
||||
"parse-svg": "node -e 'require(\"./config/parse-svg.js\").main()'",
|
||||
"css-icons": "node -e 'require(\"./config/css-icons.js\").main()'",
|
||||
"copy": "copyfiles util/* src",
|
||||
"update-metadata": "node config/metadata.js",
|
||||
"write-packagejson": "echo '{\"type\": \"module\", \"sideEffects\": false}' > dist/react/esm/package.json",
|
||||
"build": "yarn copy && yarn create-icons && concurrently \"tsc\" \"tsc -p tsconfig.esm.json && tsc-alias -p tsconfig.esm.json && yarn write-packagejson\" && node config/cleanTypes.js && yarn update-metadata && yarn parse-svg && yarn css-icons",
|
||||
"build": "yarn copy && yarn create-icons && concurrently \"tsc\" \"tsc -p tsconfig.esm.json && tsc-alias -p tsconfig.esm.json && yarn write-packagejson\" && node config/cleanTypes.js && yarn update-metadata && yarn parse-svg",
|
||||
"fetch-new:icons": "tsx --env-file=.env config/figma/index.ts",
|
||||
"zip:icons": "node config/zip.js",
|
||||
"test": "vitest run",
|
||||
@@ -91,7 +87,6 @@
|
||||
"prettier": "3.9.6",
|
||||
"react": "19.3.1",
|
||||
"rehype-parse": "9.0.1",
|
||||
"svgo": "3.3.5",
|
||||
"tsc-alias": "1.9.7",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "6.0.3",
|
||||
|
||||
File renamed without changes.
@@ -1,9 +1,5 @@
|
||||
# @navikt/aksel-stylelint
|
||||
|
||||
## 8.22.1
|
||||
|
||||
## 8.22.0
|
||||
|
||||
## 8.21.0
|
||||
|
||||
## 8.20.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel-stylelint",
|
||||
"version": "8.22.1",
|
||||
"version": "8.21.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
@@ -43,8 +43,8 @@
|
||||
"dev": "yarn watch:lint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@navikt/ds-css": "^8.22.1",
|
||||
"@navikt/ds-tokens": "^8.22.1",
|
||||
"@navikt/ds-css": "^8.21.0",
|
||||
"@navikt/ds-tokens": "^8.21.0",
|
||||
"concurrently": "10.0.6",
|
||||
"postcss-selector-parser": "^7.1.7",
|
||||
"postcss-value-parser": "^4.2.0",
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
# @navikt/aksel
|
||||
|
||||
## 8.22.1
|
||||
|
||||
## 8.22.0
|
||||
|
||||
## 8.21.0
|
||||
|
||||
## 8.20.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel",
|
||||
"version": "8.22.1",
|
||||
"version": "8.21.0",
|
||||
"description": "Aksel command line interface. Codemods and other utilities for Aksel users.",
|
||||
"author": "Aksel | Nav designsystem team",
|
||||
"license": "MIT",
|
||||
|
||||
File renamed without changes.
@@ -1,13 +1,5 @@
|
||||
# @navikt/ds-css
|
||||
|
||||
## 8.22.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- DataGrid: Column dividers in the table header are now hidden when column dividers are disabled, and only shown on hover, focus or while resizing. ([#5505](https://github.com/navikt/aksel/pull/5505))
|
||||
|
||||
## 8.22.0
|
||||
|
||||
## 8.21.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-css",
|
||||
"version": "8.22.1",
|
||||
"version": "8.21.0",
|
||||
"description": "CSS for Nav Designsystem",
|
||||
"author": "Aksel | Nav designsystem team",
|
||||
"keywords": [
|
||||
@@ -32,7 +32,7 @@
|
||||
"css:get-version": "node config/get-version.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@navikt/ds-tokens": "^8.22.1",
|
||||
"@navikt/ds-tokens": "^8.21.0",
|
||||
"browserslist": "^4.28.7",
|
||||
"esbuild": "^0.28.2",
|
||||
"fast-glob": "3.3.3",
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
.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;
|
||||
}
|
||||
@@ -208,20 +208,11 @@
|
||||
padding: 0;
|
||||
font-size: var(--__axc-data-table-text-size);
|
||||
|
||||
.aksel-data-table__thead &,
|
||||
.aksel-data-table[data-column-dividers="true"] & {
|
||||
box-shadow: inset 1px 1px 0 0 var(--ax-border-neutral-subtle);
|
||||
}
|
||||
|
||||
.aksel-data-table[data-column-dividers="false"] & {
|
||||
transition: box-shadow 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.aksel-data-table[data-column-dividers="false"]
|
||||
.aksel-data-table__thead:where(:hover, :has(:focus-visible, .aksel-data-table__th-resize-handle:active))
|
||||
& {
|
||||
box-shadow: inset 1px 1px 0 0 var(--ax-border-neutral-subtle);
|
||||
}
|
||||
|
||||
&:not(.aksel-data-table__column-header, [data-sticky]) {
|
||||
overflow: hidden; /* Needed for cases where left margin is added to cell-content (nested rows) */
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
@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);
|
||||
@@ -1,35 +1,5 @@
|
||||
# @navikt/ds-react
|
||||
|
||||
## 8.22.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@8.22.1
|
||||
- @navikt/aksel-icons@8.22.1
|
||||
|
||||
## 8.22.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Combobox: The list is no longer focusable ([#5490](https://github.com/navikt/aksel/pull/5490))
|
||||
|
||||
- Popover: Closed popovers no longer calculate their position on mount. This makes pages with many Dropdowns or HelpTexts render faster. ([#5474](https://github.com/navikt/aksel/pull/5474))
|
||||
|
||||
- ESM: Mark ESM output as side-effect free, so bundlers like webpack, Turbopack and esbuild can tree-shake unused components and icons. ([#5472](https://github.com/navikt/aksel/pull/5472))
|
||||
|
||||
- MonthPicker: Fixed issue where navigating to a year without any enabled months could lead to re-render loop in React ([#5503](https://github.com/navikt/aksel/pull/5503))
|
||||
|
||||
- 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. ([#5477](https://github.com/navikt/aksel/pull/5477))
|
||||
|
||||
- Date utilities: Import date-fns functions and locales from their own paths, so unbundled Node imports (SSR, Vitest/Jest) no longer load every date-fns locale. ([#5476](https://github.com/navikt/aksel/pull/5476))
|
||||
|
||||
- DataGrid: Fixed text selection inside details panels when keyboard navigation is enabled. ([`0bdad46`](https://github.com/navikt/aksel/commit/0bdad462664e04abd89c4121de515f6ae6d9aad1))
|
||||
|
||||
- Updated dependencies [[`16da3ce`](https://github.com/navikt/aksel/commit/16da3ce770041acc41589fcdd087717fab027126), [`b69c5fc`](https://github.com/navikt/aksel/commit/b69c5fca305f28ad99b48dd74e4f736fe4257d48)]:
|
||||
- @navikt/aksel-icons@8.22.0
|
||||
- @navikt/ds-tokens@8.22.0
|
||||
|
||||
## 8.21.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-react",
|
||||
"version": "8.22.1",
|
||||
"version": "8.21.0",
|
||||
"description": "React components from the Norwegian Labour and Welfare Administration.",
|
||||
"author": "Aksel, a team part of the Norwegian Labour and Welfare Administration.",
|
||||
"license": "MIT",
|
||||
@@ -169,6 +169,16 @@
|
||||
"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",
|
||||
@@ -779,17 +789,17 @@
|
||||
"clean": "rimraf cjs esm",
|
||||
"build": "yarn i18n-jsdoc && yarn metadata:generate-names && concurrently \"tsc -p tsconfig.build.json --module commonjs --outDir cjs --tsBuildInfoFile cjs/.tsbuildinfo\" \"tsc -p tsconfig.build.json --module esnext --outDir esm --tsBuildInfoFile esm/.tsbuildinfo && tsc-alias -p tsconfig.build.json --dir esm -f && yarn write-packagejson\" && yarn i18n-jsdoc --cleanup && yarn copy-types",
|
||||
"watch": "tsc --watch -p tsconfig.build.json --module esnext --outDir esm --tsBuildInfoFile esm/.tsbuildinfo",
|
||||
"test": "yarn metadata:generate-names && concurrently \"cross-env TZ=UTC vitest run -c tests/vitest.config.mts\" \"yarn test-sb-build\"",
|
||||
"test:bench": "cross-env TZ=UTC vitest bench -c tests/vitest.config.mts",
|
||||
"test": "yarn metadata:generate-names && concurrently \"cross-env TZ=UTC vitest run -c tests/vitest.config.ts\" \"yarn test-sb-build\"",
|
||||
"test:bench": "cross-env TZ=UTC vitest bench -c tests/vitest.config.ts",
|
||||
"test-sb-build": "tsc -p tsconfig.json",
|
||||
"test:watch": "TZ=UTC vitest watch -c tests/vitest.config.mts",
|
||||
"test:watch": "TZ=UTC vitest watch -c tests/vitest.config.ts",
|
||||
"copy-types": "copyfiles -f ./src/types/theme.d.ts ./esm/types"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "0.27.20",
|
||||
"@floating-ui/react-dom": "^2.1.9",
|
||||
"@navikt/aksel-icons": "^8.22.1",
|
||||
"@navikt/ds-tokens": "^8.22.1",
|
||||
"@navikt/aksel-icons": "^8.21.0",
|
||||
"@navikt/ds-tokens": "^8.21.0",
|
||||
"date-fns": "^4.0.0",
|
||||
"react-day-picker": "10.0.2"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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 };
|
||||
@@ -0,0 +1,552 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,449 @@
|
||||
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>"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,931 @@
|
||||
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" });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
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 };
|
||||
@@ -0,0 +1,31 @@
|
||||
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 };
|
||||
@@ -0,0 +1,33 @@
|
||||
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 };
|
||||
@@ -0,0 +1,48 @@
|
||||
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 };
|
||||
@@ -0,0 +1,34 @@
|
||||
"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";
|
||||
@@ -0,0 +1,124 @@
|
||||
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 };
|
||||
@@ -0,0 +1,55 @@
|
||||
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 };
|
||||
@@ -0,0 +1,57 @@
|
||||
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 };
|
||||
@@ -0,0 +1,511 @@
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
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 };
|
||||
@@ -0,0 +1,46 @@
|
||||
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 };
|
||||
@@ -0,0 +1,46 @@
|
||||
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 };
|
||||
@@ -0,0 +1,38 @@
|
||||
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 };
|
||||
@@ -0,0 +1,39 @@
|
||||
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 };
|
||||
@@ -0,0 +1,182 @@
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
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 };
|
||||
@@ -84,9 +84,6 @@ export const Attachments: Story = {
|
||||
options: ["none", "x", "y", "both"],
|
||||
},
|
||||
},
|
||||
args: {
|
||||
overflow: "none",
|
||||
},
|
||||
};
|
||||
|
||||
export const Chromatic = renderStoriesForChromatic({
|
||||
|
||||
@@ -16,7 +16,7 @@ export default {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
/* TODO: Temp disabled CI-tests */
|
||||
tags: ["skip-test"],
|
||||
tags: ["!play-fn", "skip-test"],
|
||||
} satisfies Meta<typeof DatePicker>;
|
||||
|
||||
type Story = StoryObj<typeof DatePicker>;
|
||||
@@ -104,7 +104,7 @@ export const DropdownCaptionNavigation: Story = {
|
||||
};
|
||||
|
||||
export const ControlledMonthPreservesFocus: Story = {
|
||||
tags: ["!skip-test"],
|
||||
tags: ["play-fn", "!skip-test"],
|
||||
render: () => {
|
||||
const [month, setMonth] = React.useState(new Date(2026, 2, 15));
|
||||
const [selected, setSelected] = React.useState<Date | undefined>(
|
||||
|
||||
@@ -11,7 +11,7 @@ export default {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
/* TODO: Temp disabled CI-tests */
|
||||
tags: ["skip-test"],
|
||||
tags: ["!play-fn", "skip-test"],
|
||||
} satisfies Meta<typeof UNSAFE_Combobox>;
|
||||
|
||||
type StoryObject = StoryObj<typeof UNSAFE_Combobox>;
|
||||
|
||||
@@ -272,7 +272,6 @@ export const Composition = () => {
|
||||
: [...prev, option.value],
|
||||
);
|
||||
},
|
||||
multiple: true,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -126,7 +126,6 @@ export const OptionMemoization = () => {
|
||||
</div>
|
||||
<ComboboxRoot
|
||||
defaultOpen
|
||||
multiple
|
||||
options={groupedOptions}
|
||||
value={value}
|
||||
onToggleOption={(option) => {
|
||||
|
||||
@@ -38,7 +38,7 @@ export const ComboboxFilter = (props: ComboboxFilterProps) => {
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (["ArrowLeft", "ArrowRight"].includes(event.key)) {
|
||||
setVirtuallyFocusedOptionValue(""); // Assume you want to edit the input, so remove virtual focus
|
||||
setVirtuallyFocusedOptionValue("");
|
||||
}
|
||||
}}
|
||||
aria-autocomplete="list"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import { Listbox } from "../../../utils/components/Listbox/root/ListboxRoot";
|
||||
import { useDeferredValue, useEventCallback } from "../../../utils/hooks";
|
||||
import { useI18n } from "../../../utils/i18n/i18n.hooks";
|
||||
@@ -51,16 +51,24 @@ function ComboboxListContent<
|
||||
>({ children }: ComboboxListProps<T>) {
|
||||
const { virtuallyFocusedOptionValue, filterString } =
|
||||
useComboboxPopupContext();
|
||||
const {
|
||||
options,
|
||||
value, // Selected options
|
||||
onToggleOption: originalOnToggleOption,
|
||||
} = useComboboxRootContext();
|
||||
const onToggleOption = useEventCallback(originalOnToggleOption);
|
||||
const { setOpen, options, value, onToggleOption, multiple, triggerRef } =
|
||||
useComboboxRootContext();
|
||||
const memoizedOnToggleOption = useEventCallback(onToggleOption);
|
||||
const deferredFilterString = useDeferredValue(filterString);
|
||||
const maybeAnnouceHits = useHitsAnnouncer(deferredFilterString);
|
||||
const translate = useI18n("Combobox2");
|
||||
|
||||
const localOnToggleOption = useCallback(
|
||||
(option: ComboboxOptionData, isSelected: boolean) => {
|
||||
memoizedOnToggleOption(option, isSelected);
|
||||
if (!multiple) {
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
},
|
||||
[memoizedOnToggleOption, multiple, triggerRef, setOpen],
|
||||
);
|
||||
|
||||
if (children && typeof children !== "function") {
|
||||
return children;
|
||||
}
|
||||
@@ -89,7 +97,7 @@ function ComboboxListContent<
|
||||
<ComboboxOption
|
||||
key={option.value}
|
||||
option={option}
|
||||
onToggleOption={onToggleOption}
|
||||
onToggleOption={localOnToggleOption}
|
||||
isSelected={value.includes(option.value)}
|
||||
hasVirtualFocus={virtuallyFocusedOptionValue === option.value}
|
||||
highlightText={deferredFilterString}
|
||||
@@ -102,7 +110,7 @@ function ComboboxListContent<
|
||||
<ComboboxOption
|
||||
key={optOrGroup.value}
|
||||
option={optOrGroup}
|
||||
onToggleOption={onToggleOption}
|
||||
onToggleOption={localOnToggleOption}
|
||||
isSelected={value.includes(optOrGroup.value)}
|
||||
hasVirtualFocus={virtuallyFocusedOptionValue === optOrGroup.value}
|
||||
highlightText={deferredFilterString} // TODO: Vurder å kunne slå av dette for bedre ytelse
|
||||
@@ -118,27 +126,26 @@ function filterOptions<T extends ComboboxOptionData>(
|
||||
filterString: string,
|
||||
): (T | ComboboxGroupData<T>)[] {
|
||||
const filterStringLowerCase = filterString.toLocaleLowerCase();
|
||||
const results: (T | ComboboxGroupData<T>)[] = [];
|
||||
|
||||
for (const optOrGroup of options) {
|
||||
if ("options" in optOrGroup) {
|
||||
const matchingOptions: T[] = [];
|
||||
for (const option of optOrGroup.options) {
|
||||
if (option.label.toLocaleLowerCase().includes(filterStringLowerCase)) {
|
||||
matchingOptions.push(option);
|
||||
return options
|
||||
.map((optOrGroup) => {
|
||||
if ("options" in optOrGroup) {
|
||||
const matchingOptions = optOrGroup.options.filter((option) =>
|
||||
option.label.toLocaleLowerCase().includes(filterStringLowerCase),
|
||||
);
|
||||
if (matchingOptions.length > 0) {
|
||||
return { ...optOrGroup, options: matchingOptions };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (matchingOptions.length > 0) {
|
||||
results.push({ ...optOrGroup, options: matchingOptions });
|
||||
if (
|
||||
optOrGroup.label.toLocaleLowerCase().includes(filterStringLowerCase)
|
||||
) {
|
||||
return optOrGroup;
|
||||
}
|
||||
} else if (
|
||||
optOrGroup.label.toLocaleLowerCase().includes(filterStringLowerCase)
|
||||
) {
|
||||
results.push(optOrGroup);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
return null;
|
||||
})
|
||||
.filter((option): option is T | ComboboxGroupData<T> => option !== null);
|
||||
}
|
||||
|
||||
export type { ComboboxListProps };
|
||||
|
||||
@@ -91,7 +91,7 @@ function announce(message: string, politeness: Politeness = "polite") {
|
||||
}
|
||||
|
||||
/**
|
||||
* Announces a message through a persistently-mounted, visually-hidden live region.
|
||||
* Speaks a message through a persistently-mounted, visually-hidden live region.
|
||||
*
|
||||
* The polite and assertive regions are created once and kept mounted.
|
||||
*
|
||||
|
||||
@@ -19,7 +19,16 @@ function ComboboxOverlay({ children, mobileHeader }: ComboboxOverlayProps) {
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<ComboboxModal open={rootContext.open} onOpenChange={rootContext.setOpen}>
|
||||
<ComboboxModal
|
||||
open={rootContext.open}
|
||||
onOpenChange={(open) => {
|
||||
rootContext.setOpen(open);
|
||||
!open && rootContext.triggerRef.current?.focus(); // This does not work when closing with button
|
||||
}}
|
||||
onOpenChangeComplete={(open) =>
|
||||
!open && rootContext.triggerRef.current?.focus()
|
||||
}
|
||||
>
|
||||
{mobileHeader}
|
||||
{children}
|
||||
</ComboboxModal>
|
||||
@@ -33,6 +42,7 @@ function ComboboxOverlay({ children, mobileHeader }: ComboboxOverlayProps) {
|
||||
return (
|
||||
<DismissableLayer
|
||||
asChild
|
||||
//safeZone={{ anchor: rootContext.triggerRef.current }}
|
||||
onDismiss={(event) => {
|
||||
// We don't have trigger as `safeZone` because we want to close when trigger is focused.
|
||||
// To avoid immediately re-opening the popup when trigger is clicked (blur closes, click opens),
|
||||
@@ -45,6 +55,7 @@ function ComboboxOverlay({ children, mobileHeader }: ComboboxOverlayProps) {
|
||||
return;
|
||||
}
|
||||
rootContext.setOpen(false);
|
||||
rootContext.triggerRef.current?.focus();
|
||||
}}
|
||||
enabled={rootContext.open}
|
||||
>
|
||||
@@ -66,6 +77,7 @@ type ComboboxModalProps = {
|
||||
children: React.ReactNode;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onOpenChangeComplete: (open: boolean) => void;
|
||||
};
|
||||
|
||||
function subscribeToResize(callback: () => void) {
|
||||
@@ -77,7 +89,12 @@ function getVisualViewportHeight() {
|
||||
return visualViewport?.height; // Takes into account virtual keyboard etc.
|
||||
}
|
||||
|
||||
function ComboboxModal({ children, open, onOpenChange }: ComboboxModalProps) {
|
||||
function ComboboxModal({
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
onOpenChangeComplete,
|
||||
}: ComboboxModalProps) {
|
||||
const visualViewportHeight = useSyncExternalStore(
|
||||
subscribeToResize,
|
||||
getVisualViewportHeight,
|
||||
@@ -95,7 +112,12 @@ function ComboboxModal({ children, open, onOpenChange }: ComboboxModalProps) {
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog size="small" open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog
|
||||
size="small"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onOpenChangeComplete={onOpenChangeComplete}
|
||||
>
|
||||
<Dialog.Popup className="aksel-combobox2__modal" position="fullscreen">
|
||||
<div
|
||||
className="aksel-combobox2__modal-inner"
|
||||
|
||||
@@ -62,7 +62,6 @@ export const Default = () => {
|
||||
return (
|
||||
<ComboboxRoot
|
||||
defaultOpen
|
||||
multiple
|
||||
options={options}
|
||||
value={value}
|
||||
onToggleOption={(option, newSelected) => {
|
||||
@@ -174,7 +173,6 @@ export const CustomOptionRendering = () => {
|
||||
return (
|
||||
<ComboboxRoot
|
||||
defaultOpen
|
||||
multiple
|
||||
options={options}
|
||||
value={value}
|
||||
onToggleOption={(option, isSelected) => {
|
||||
@@ -261,7 +259,6 @@ export const ControlledInput = () => {
|
||||
return (
|
||||
<ComboboxRoot
|
||||
defaultOpen
|
||||
multiple
|
||||
options={filteredOptions}
|
||||
value={value}
|
||||
onToggleOption={(option) => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import { useClientLayoutEffect } from "../../../utils-external";
|
||||
import React, { useLayoutEffect, useRef, useState } from "react";
|
||||
import { Listbox } from "../../../utils/components/Listbox/root/ListboxRoot";
|
||||
import { createStrictContext } from "../../../utils/helpers";
|
||||
import { useComboboxRootContext } from "../root/ComboboxRoot";
|
||||
@@ -30,7 +29,7 @@ export const ComboboxPopup = ({ children }: ComboboxPopupProps) => {
|
||||
const rootContext = useComboboxRootContext();
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useClientLayoutEffect(
|
||||
useLayoutEffect(
|
||||
function onOpen() {
|
||||
const canHover = window.matchMedia("(hover: hover)").matches;
|
||||
|
||||
@@ -86,7 +85,7 @@ export const ComboboxPopup = ({ children }: ComboboxPopupProps) => {
|
||||
filterString={filterString}
|
||||
setFilterString={(newString) => {
|
||||
setFilterString(newString);
|
||||
setVirtuallyFocusedOptionValue(""); // Remove virtual focus when filter string changes
|
||||
setVirtuallyFocusedOptionValue("");
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Floating } from "../../../utils/components/floating/Floating";
|
||||
import { cl, createStrictContext } from "../../../utils/helpers";
|
||||
import type { FormFieldProps } from "../../useFormField";
|
||||
@@ -74,8 +74,9 @@ export interface ComboboxRootProps<
|
||||
defaultOpen?: boolean;
|
||||
/**
|
||||
* Whether multiple options can be selected.
|
||||
* @default true
|
||||
*/
|
||||
multiple?: boolean;
|
||||
multiple?: boolean; // TODO: Hva skal være default?
|
||||
}
|
||||
|
||||
export interface ComboboxRootContextProps {
|
||||
@@ -83,10 +84,9 @@ export interface ComboboxRootContextProps {
|
||||
setOpen: (newOpen: boolean) => void;
|
||||
triggerRef: React.RefObject<HTMLDivElement | null>;
|
||||
options: ComboboxRootProps["options"];
|
||||
/** Values of selected options */
|
||||
value: ComboboxRootProps["value"];
|
||||
/** Consumer callback */
|
||||
onToggleOption: ComboboxRootProps["onToggleOption"];
|
||||
multiple: boolean;
|
||||
size: Exclude<ComboboxRootProps["size"], undefined>;
|
||||
disabled: ComboboxRootProps["disabled"];
|
||||
}
|
||||
@@ -107,7 +107,7 @@ export function ComboboxRoot<
|
||||
value,
|
||||
onToggleOption,
|
||||
defaultOpen = false,
|
||||
multiple = false,
|
||||
multiple = true,
|
||||
size = "medium",
|
||||
disabled,
|
||||
className,
|
||||
@@ -125,25 +125,14 @@ export function ComboboxRoot<
|
||||
>
|
||||
<ComboboxRootContextProvider
|
||||
open={open}
|
||||
setOpen={useCallback((newOpen) => {
|
||||
setOpen(newOpen);
|
||||
if (!newOpen) {
|
||||
// queueMicrotask is needed when closing with close button in Dialog
|
||||
queueMicrotask(() => triggerRef.current?.focus());
|
||||
}
|
||||
}, [])}
|
||||
setOpen={setOpen}
|
||||
triggerRef={triggerRef}
|
||||
options={options}
|
||||
value={value}
|
||||
onToggleOption={useCallback(
|
||||
(...args) => {
|
||||
(onToggleOption as ComboboxRootProps["onToggleOption"])(...args);
|
||||
if (!multiple) {
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[onToggleOption, multiple],
|
||||
)}
|
||||
onToggleOption={
|
||||
onToggleOption as ComboboxRootContextProps["onToggleOption"]
|
||||
}
|
||||
multiple={multiple}
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
||||
@@ -48,7 +48,7 @@ export const Default: Story = {
|
||||
|
||||
args: {
|
||||
children: "Id elit esse enim reprehenderit enim nisi veniam nostrud.",
|
||||
title: "GlobalAlert title",
|
||||
title: "GlobalAlert Title",
|
||||
size: "medium",
|
||||
status: "announcement",
|
||||
centered: true,
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 {
|
||||
|
||||
@@ -41,7 +41,7 @@ export const Default: Story = {
|
||||
|
||||
args: {
|
||||
children: "Id elit esse enim reprehenderit enim nisi veniam nostrud.",
|
||||
title: "InfoCard title",
|
||||
title: "InfoCard Title",
|
||||
size: "medium",
|
||||
"data-color": "info",
|
||||
},
|
||||
|
||||
@@ -42,7 +42,7 @@ export const Default: Story = {
|
||||
|
||||
args: {
|
||||
children: "Id elit esse enim reprehenderit enim nisi veniam nostrud.",
|
||||
title: "LocalAlert title",
|
||||
title: "LocalAlert Title",
|
||||
size: "medium",
|
||||
status: "announcement",
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ export default {
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
tags: ["play-fn"],
|
||||
} satisfies Meta<typeof Lookup>;
|
||||
|
||||
type Story = StoryObj<typeof Lookup>;
|
||||
|
||||
@@ -83,17 +83,6 @@ export const DisabledMonths = () => (
|
||||
/>
|
||||
);
|
||||
|
||||
export const EverythingDisabledBesidesOneMonth = () => (
|
||||
<div>
|
||||
<MonthPicker.Standalone
|
||||
defaultSelected={new Date(2026, 5, 1)}
|
||||
disabled={[
|
||||
(date) => !(date.getFullYear() === year && date.getMonth() === 5),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Only June is enabled. ArrowRight/ArrowLeft on June keeps focus on June instead
|
||||
* of searching forever. Keyboard search stops at the
|
||||
|
||||
@@ -12,7 +12,7 @@ export default {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
/* TODO: Temp disabled CI-tests */
|
||||
tags: ["skip-test"],
|
||||
tags: ["!play-fn", "skip-test"],
|
||||
} satisfies Meta<typeof MonthPicker>;
|
||||
|
||||
type Story = StoryObj<typeof MonthPicker>;
|
||||
@@ -246,45 +246,6 @@ export const DropdownCaptionView: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate that the month grid keeps a tab stop after a month is focused with the mouse
|
||||
* and focus then moves to an outside element.
|
||||
*/
|
||||
export const TabStopAfterMouseFocus: Story = {
|
||||
// Overrides the file-level skip so this regression test runs in CI
|
||||
tags: ["!skip-test"],
|
||||
render: () => (
|
||||
<>
|
||||
<MonthPicker.Standalone year={new Date("2025")} />
|
||||
<button type="button">Outside</button>
|
||||
</>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const monthButtons = () =>
|
||||
canvas
|
||||
.getAllByRole("button")
|
||||
.filter((b) => b.classList.contains("aksel-date__month-button"));
|
||||
|
||||
expect(monthButtons().filter((b) => b.tabIndex === 0)).toHaveLength(1);
|
||||
|
||||
const febButton = canvas.getByRole("button", {
|
||||
name: format(new Date("Feb 01 2025"), "LLLL", { locale: nb }),
|
||||
});
|
||||
await userEvent.click(febButton);
|
||||
expect(febButton).toHaveFocus();
|
||||
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Outside" }));
|
||||
|
||||
const tabStops = monthButtons().filter((b) => b.tabIndex === 0);
|
||||
expect(tabStops).toHaveLength(1);
|
||||
expect(tabStops[0]).toBe(febButton);
|
||||
|
||||
await userEvent.tab({ shift: true });
|
||||
expect(febButton).toHaveFocus();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate that the monthpicker handles `dropdownCaption` prop correctly
|
||||
*/
|
||||
|
||||
@@ -106,7 +106,7 @@ const MonthPickerButtonInternal = ({
|
||||
setTabRoot(next);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setTabRoot(month);
|
||||
setTabRoot(focus);
|
||||
}}
|
||||
>
|
||||
<div className="rdp-day__inner">
|
||||
|
||||
@@ -49,15 +49,8 @@ const MonthPickerTable = () => {
|
||||
hasSelected ? selected : getRootFallback(),
|
||||
);
|
||||
|
||||
// Track the year instead of comparing tabRoot, since tabRoot is undefined
|
||||
// when every month in the year is disabled and would re-trigger forever.
|
||||
const [tabRootYear, setTabRootYear] = useState(year.getFullYear());
|
||||
|
||||
if (tabRootYear !== year.getFullYear()) {
|
||||
setTabRootYear(year.getFullYear());
|
||||
if (tabRoot?.getFullYear() !== year.getFullYear()) {
|
||||
setTabRoot(hasSelected ? selected : getRootFallback());
|
||||
}
|
||||
if (tabRoot?.getFullYear() !== year.getFullYear()) {
|
||||
setTabRoot(hasSelected ? selected : getRootFallback());
|
||||
}
|
||||
|
||||
const tableMonths = [
|
||||
|
||||
@@ -179,3 +179,4 @@ export const Chromatic = renderStoriesForChromatic({
|
||||
AsLink,
|
||||
ColorRole,
|
||||
});
|
||||
Chromatic.args = { count: 8, siblingCount: 1, boundaryCount: 1 };
|
||||
@@ -7,6 +7,7 @@ export default {
|
||||
title: "ds-react/Timeline/Tests",
|
||||
component: Timeline,
|
||||
parameters: { chromatic: { disable: true } },
|
||||
tags: ["play-fn"],
|
||||
};
|
||||
|
||||
type Story = StoryObj<typeof Timeline>;
|
||||
|
||||
@@ -19,6 +19,12 @@ export default {
|
||||
labelSuffix: "delete",
|
||||
},
|
||||
},
|
||||
Coach: {
|
||||
currentStep: "{current} of {total}",
|
||||
next: "Next",
|
||||
previous: "Previous",
|
||||
close: "Close",
|
||||
},
|
||||
Combobox: {
|
||||
addOption: "Add",
|
||||
noMatches: "No search hits",
|
||||
|
||||
@@ -24,6 +24,12 @@ 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,6 +19,12 @@ export default {
|
||||
labelSuffix: "slett",
|
||||
},
|
||||
},
|
||||
Coach: {
|
||||
currentStep: "{current} av {total}",
|
||||
next: "Neste",
|
||||
previous: "Førre",
|
||||
close: "Lukk",
|
||||
},
|
||||
Combobox: {
|
||||
addOption: "Legg til",
|
||||
noMatches: "Ingen søketreff",
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import type { Args, StoryObj } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import type { Renderer, StoryContext } from "storybook/internal/types";
|
||||
|
||||
type StoryFnOrObj =
|
||||
| { render?: (...args: any[]) => React.ReactNode; args?: Args }
|
||||
| (React.FunctionComponent & { args?: Args });
|
||||
| React.FunctionComponent;
|
||||
|
||||
export function renderStoriesForChromatic(
|
||||
stories: Record<string, StoryFnOrObj>,
|
||||
Component?: React.FunctionComponent<any>,
|
||||
): StoryObj {
|
||||
return {
|
||||
render: () => (
|
||||
render: (...args: [Args, StoryContext<Renderer, Args>]) => (
|
||||
<>
|
||||
{Object.entries(stories).map(([storyName, Story]) => (
|
||||
<div key={storyName}>
|
||||
<h2 className="storyheading">{storyName}</h2>
|
||||
{resolveComponent(Story, Component)}
|
||||
{resolveComponent(Story, args, Component)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
@@ -29,13 +30,14 @@ export function renderStoriesForChromatic(
|
||||
|
||||
function resolveComponent(
|
||||
Story: StoryFnOrObj,
|
||||
args: [Args, StoryContext<Renderer, Args>],
|
||||
Component?: React.FunctionComponent,
|
||||
) {
|
||||
if (typeof Story === "function") {
|
||||
return <Story {...(Story.args || {})} />;
|
||||
return <Story />;
|
||||
}
|
||||
if (Story.render) {
|
||||
return Story.render(Story.args || {});
|
||||
return Story.render(...args);
|
||||
}
|
||||
if (Story.args && Component) {
|
||||
return <Component {...Story.args} />;
|
||||
|
||||
File renamed without changes.
@@ -1,9 +1,5 @@
|
||||
# @navikt/ds-tailwind
|
||||
|
||||
## 8.22.1
|
||||
|
||||
## 8.22.0
|
||||
|
||||
## 8.21.0
|
||||
|
||||
## 8.20.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-tailwind",
|
||||
"version": "8.22.1",
|
||||
"version": "8.21.0",
|
||||
"description": "Tailwind config based on @navikt/ds-tokens",
|
||||
"author": "Aksel | Nav designsystem team",
|
||||
"keywords": [
|
||||
@@ -28,7 +28,7 @@
|
||||
},
|
||||
"homepage": "https://aksel.nav.no/grunnleggende/kode/tailwind/",
|
||||
"devDependencies": {
|
||||
"@navikt/ds-tokens": "^8.22.1",
|
||||
"@navikt/ds-tokens": "^8.21.0",
|
||||
"@types/node": "^24.10.9",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.23.15",
|
||||
|
||||
File renamed without changes.
@@ -1,9 +1,5 @@
|
||||
# @navikt/ds-tokens
|
||||
|
||||
## 8.22.1
|
||||
|
||||
## 8.22.0
|
||||
|
||||
## 8.21.0
|
||||
|
||||
## 8.20.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-tokens",
|
||||
"version": "8.22.1",
|
||||
"version": "8.21.0",
|
||||
"description": "Design-tokens for Nav designsystem",
|
||||
"author": "Aksel | Nav designsystem team",
|
||||
"keywords": [
|
||||
|
||||
File renamed without changes.
@@ -1,35 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 8.22.1
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
- DataGrid: Column dividers in the table header are now hidden when column dividers are disabled, and only shown on hover, focus or while resizing. ([#5505](https://github.com/navikt/aksel/pull/5505))
|
||||
|
||||
## 8.22.0
|
||||
|
||||
### @navikt/aksel-icons
|
||||
|
||||
- Icons: New icons `Gift`, `ArrowsPointingIn` and `ArrowsPointingOut`. ([#5484](https://github.com/navikt/aksel/pull/5484))
|
||||
|
||||
* ESM: Mark ESM output as side-effect free, so bundlers like webpack, Turbopack and esbuild can tree-shake unused components and icons. ([#5472](https://github.com/navikt/aksel/pull/5472))
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- Combobox: The list is no longer focusable ([#5490](https://github.com/navikt/aksel/pull/5490))
|
||||
|
||||
- Popover: Closed popovers no longer calculate their position on mount. This makes pages with many Dropdowns or HelpTexts render faster. ([#5474](https://github.com/navikt/aksel/pull/5474))
|
||||
|
||||
- ESM: Mark ESM output as side-effect free, so bundlers like webpack, Turbopack and esbuild can tree-shake unused components and icons. ([#5472](https://github.com/navikt/aksel/pull/5472))
|
||||
|
||||
- MonthPicker: Fixed issue where navigating to a year without any enabled months could lead to re-render loop in React ([#5503](https://github.com/navikt/aksel/pull/5503))
|
||||
|
||||
- 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. ([#5477](https://github.com/navikt/aksel/pull/5477))
|
||||
|
||||
- Date utilities: Import date-fns functions and locales from their own paths, so unbundled Node imports (SSR, Vitest/Jest) no longer load every date-fns locale. ([#5476](https://github.com/navikt/aksel/pull/5476))
|
||||
|
||||
- DataGrid: Fixed text selection inside details panels when keyboard navigation is enabled. ([`0bdad46`](https://github.com/navikt/aksel/commit/0bdad462664e04abd89c4121de515f6ae6d9aad1))
|
||||
|
||||
## 8.21.0
|
||||
|
||||
### @navikt/aksel-icons
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
"sanity:update": "yarn workspace website update:sanity"
|
||||
},
|
||||
"dependencies": {
|
||||
"@navikt/aksel": "^8.22.1",
|
||||
"@navikt/aksel-icons": "^8.22.1",
|
||||
"@navikt/ds-css": "^8.22.1",
|
||||
"@navikt/ds-react": "^8.22.1",
|
||||
"@navikt/ds-tailwind": "^8.22.1",
|
||||
"@navikt/ds-tokens": "^8.22.1"
|
||||
"@navikt/aksel": "^8.21.0",
|
||||
"@navikt/aksel-icons": "^8.21.0",
|
||||
"@navikt/ds-css": "^8.21.0",
|
||||
"@navikt/ds-react": "^8.21.0",
|
||||
"@navikt/ds-tailwind": "^8.21.0",
|
||||
"@navikt/ds-tokens": "^8.21.0"
|
||||
}
|
||||
}
|
||||
File renamed without changes.
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user