Compare commits

...
Author SHA1 Message Date
Ken 230f05edb2 fix(DataTableDetailsPanelRow): enable text selection in details panels with keyboard navigation 2026-11-04 12:01:05 +01:00
Halvor Haugan ab28d90022 docs(DataGrid): remove warning about breaking changes in JSDoc 2026-11-04 12:01:05 +01:00
Halvor Haugan c202c15ee6 docs(Listbox): add comment on ListboxInputSlot 2026-11-04 12:01:05 +01:00
ab413d54d4 ⬆️ Bump jsdom from 29.1.1 to 30.1.3 (#5498)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Ken <ken.aleksander@gmail.com>
2026-11-04 12:01:05 +01:00
Ken ebb7e708d4 perf(Popover): Only calculate position when open (#5474) 2026-11-04 12:01:05 +01:00
KenandHalvor Haugan 62f56475c5 fix(monthpicker): No longer freezed page in rare cases (#5477)
Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2026-11-04 12:01:05 +01:00
dependabot[bot] 9153a32aec ⬆️ Bump @figma/plugin-typings from 1.135.0 to 1.142.0 (#5501)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-04 12:01:05 +01:00
dependabot[bot] 883641a886 ⬆️ Bump tailwindcss from 3.4.19 to 4.3.3 (#5502)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-04 12:01:04 +01:00
d1e69518b2 ⬆️ Bump the vitest group across 1 directory with 5 updates (#5482)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: KenAJoh <26967723+KenAJoh@users.noreply.github.com>
Co-authored-by: Ken <ken.aleksander@gmail.com>
2026-11-04 12:01:04 +01:00
dependabot[bot] 9a1d837cd1 ⬆️ Bump globals from 17.11.0 to 17.13.0 (#5500)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-04 12:01:04 +01:00
dependabot[bot]andKen 9ee6e4534e ⬆️ Bump react-responsive-masonry from 2.7.2 to 2.9.0 (#5499)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ken <ken.aleksander@gmail.com>
2026-11-04 12:01:04 +01:00
dependabot[bot] ca8ee1848f ⬆️ Bump stylelint-test-rule-node from 1.2.0 to 1.3.0 (#5496)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-04 12:01:04 +01:00
dependabot[bot] 314391d3d0 ⬆️ Bump pino from 10.3.1 to 10.4.0 (#5497)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-04 12:01:04 +01:00
Halvor Haugan f26d80f771 fix(website): use new next analyze script and remove old package (#5493) 2026-11-04 12:01:04 +01:00
Ken e0bcee33fe feat: New icons Gift, ArrowsPointingIn and ArrowsPointingOut (#5484) 2026-11-04 12:01:04 +01:00
Halvor Haugan edb90ee9c9 fix(Combobox): prevent navigation in play test (#5492) 2026-11-04 12:01:04 +01:00
KenandHalvor Haugan dfe69dac56 perf(website): Skip RSS and playroom build locally (#5467)
Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2026-11-04 12:01:04 +01:00
Halvor Haugan 50067c66b5 fix(Combobox): make listbox unfocusable (#5490) 2026-11-04 12:01:04 +01:00
Anniken Granberg d8c6add6f8 fixes 2026-11-04 11:55:09 +01:00
Anniken Granberg ec54ed60ee fix 2026-11-03 18:05:29 +01:00
Anniken Granberg 10b3e90bd4 default aria-label and type button 2026-11-03 18:05:17 +01:00
Anniken Granberg df3b86ac13 fix new padding based on size 2026-11-03 18:04:47 +01:00
Anniken Granberg 0fceb75679 fix 2026-11-03 17:12:30 +01:00
Anniken Granberg decb602e04 spellling 2026-11-03 17:12:02 +01:00
Anniken Granberg 025ec06d9b meta 2026-11-03 17:11:13 +01:00
Anniken Granberg d6866d71f9 fix width on smaller screens 2026-11-03 17:10:40 +01:00
Anniken Granberg 2152987be0 changeset 2026-11-03 17:10:23 +01:00
Anniken Granberg 4e249beb2b cleanup 2026-11-02 18:32:24 +01:00
Anniken Granberg 1274086697 Merge branch 'main' into new-component-coachmark 2026-11-02 17:26:44 +01:00
Anniken Granberg d3304525a3 Warning if anchorRef is missing 2026-11-02 17:25:54 +01:00
Anniken Granberg 206724e5e0 Fix new padding 2026-11-02 14:10:09 +01:00
Anniken Granberg 39edbd7a46 Merge branch 'main' into new-component-coachmark 2026-10-29 12:36:22 +01:00
Anniken Granberg 7eb447981f remove placement prop on mark 2026-10-29 12:33:05 +01:00
Anniken Granberg 2747af8d53 fix tests and examples after changes 2026-10-29 09:50:27 +01:00
Anniken Granberg fd53f1a23d Border and open from context 2026-10-29 09:49:58 +01:00
Anniken Granberg 29413d4a04 Add support for mark being child of coach and disable stsate 2026-10-29 09:46:44 +01:00
Anniken Granberg 4554928e49 Merge branch 'main' into new-component-coachmark 2026-10-26 15:48:10 +01:00
Anniken Granberg d08b98aa46 move close button to right 2026-10-26 15:40:54 +01:00
Anniken Granberg 56145483b0 Fix button and scroll 2026-10-26 15:27:20 +01:00
Anniken Granberg 86285e1488 Merge branch 'main' into new-component-coachmark 2026-10-22 15:21:28 +02:00
Anniken Granberg 49d756e890 white border for testing 2026-10-22 15:19:10 +02:00
Anniken Granberg b099154b97 fix 2026-10-22 15:13:28 +02:00
Anniken Granberg a5e399ded4 Add placement and offset as props to Mark after discussion 2026-10-22 15:03:56 +02:00
Anniken Granberg dca4cd2e5d some stories 2026-10-21 10:57:04 +02:00
Anniken Granberg 8307dcb464 examples 2026-10-20 17:37:12 +02:00
Anniken Granberg d89c7fe86e add step count 2026-10-20 17:13:14 +02:00
Anniken Granberg 6ebe5bc39e meta 2026-10-20 12:31:36 +02:00
Anniken Granberg 2fa0b86536 testssss 2026-10-20 12:03:08 +02:00
Anniken Granberg 77e77cfff5 Merge branch 'main' into new-component-coachmark 2026-10-20 10:24:00 +02:00
Anniken Granberg 54690ad51f Merge branch 'main' into new-component-coachmark 2026-10-14 18:00:43 +02:00
Anniken Granberg fd84d6c4a2 fix warnings 2026-10-14 18:00:03 +02:00
Anniken Granberg ac91378e4c Padding for small and medium sizes 2026-10-13 12:13:06 +02:00
Anniken Granberg fd25decd17 Renamed to showTour, changed type name, added size value, added title id to set aria-labelledby 2026-10-13 12:12:53 +02:00
Anniken Granberg 1c2bdae5d1 single coach story 2026-10-13 12:12:03 +02:00
Anniken Granberg 709f13cd63 rename to showtour 2026-10-13 12:11:47 +02:00
Anniken Granberg f41188c688 add trigger translations 2026-10-13 12:11:23 +02:00
Anniken Granberg 6e5902a350 change to heading 2026-10-13 12:10:27 +02:00
Anniken Granberg 49a6e4ec27 renamed to step 2026-10-13 12:09:20 +02:00
Anniken Granberg 97dd6f13ac add warnings if missing triggers 2026-10-13 12:08:16 +02:00
Anniken Granberg bf8262da22 rename to step 2026-10-13 12:07:59 +02:00
Anniken Granberg 53d1c1a919 moved types 2026-10-13 12:07:47 +02:00
Anniken Granberg 18a88be454 Change prop name to showTour, add support for single coach, translations, moved types, add width and size, add labelledby, change component API, 2026-10-13 12:07:27 +02:00
Anniken Granberg 0d4e7d7c8c Add width for anchor step 2026-10-13 11:55:27 +02:00
Anniken Granberg b688d8ab48 Merge branch 'main' into new-component-coachmark 2026-10-12 18:48:56 +02:00
Anniken Granberg dd59358edd Choose animation 2026-09-28 14:32:21 +02:00
Anniken Granberg 310933f923 Change name from dot to mark 2026-09-28 13:36:37 +02:00
Anniken Granberg 56a281ae98 Rename Coachmark to Coach 2026-09-28 13:27:08 +02:00
Anniken Granberg 595826d326 Merge branch 'main' into new-component-coachmark 2026-09-28 11:49:19 +02:00
Anniken Granberg d8e9eb97fd Fix styling for image 2026-09-28 11:48:54 +02:00
Anniken Granberg 3dd23b46ab colro and some uu for dot 2026-09-23 15:41:47 +02:00
Anniken Granberg 695f334a76 add translations support for progress 2026-09-23 15:41:28 +02:00
Anniken Granberg 90b127a5f6 cleanup 2026-09-23 08:51:43 +02:00
Anniken Granberg cac5373cd0 new animations 2026-09-22 14:04:45 +02:00
Anniken Granberg e914bfe970 Merge branch 'main' into new-component-coachmark 2026-09-21 18:12:03 +02:00
Anniken Granberg 8441d31783 fix 2026-09-21 18:11:35 +02:00
Anniken Granberg c4597ef90f add animation duration controls for testing 2026-09-21 18:10:55 +02:00
Anniken Granberg 09b36ea169 Merge branch 'main' into new-component-coachmark 2026-09-18 13:46:16 +02:00
Anniken Granberg ab6e0a7e40 new 2026-09-18 13:44:30 +02:00
Anniken Granberg 58719c6025 new animation 2026-09-18 13:34:53 +02:00
Anniken Granberg 1405bbc880 smaller 2026-09-18 11:06:18 +02:00
Anniken Granberg 3fd7e79b59 another one 2026-09-18 11:04:57 +02:00
Anniken Granberg a261edd03e new animation 2026-09-18 10:38:06 +02:00
Anniken Granberg 1d4874476f move context 2026-09-18 09:19:25 +02:00
Anniken Granberg 6ceacad822 Merge branch 'main' into new-component-coachmark 2026-09-18 08:51:06 +02:00
Anniken Granberg 11956016c0 styling 2026-09-18 08:39:21 +02:00
Anniken Granberg d9967c5a6b dot 2026-09-18 08:39:13 +02:00
Anniken Granberg 2a294632e4 coach dot 2026-09-18 08:38:31 +02:00
Anniken Granberg 47ec045d5c remove unused code 2026-09-15 21:39:43 +02:00
Anniken Granberg 007554cf97 Scroll to anchor, keyboard navigation 2026-09-15 21:37:48 +02:00
Anniken Granberg 969cf2ff64 styling dependent on top button 2026-09-15 21:37:16 +02:00
Anniken Granberg 076d8f57d2 rename file 2026-09-15 21:35:55 +02:00
Anniken Granberg e5d72d08fc Merge branch 'main' into new-component-coachmark 2026-09-15 18:21:14 +02:00
Anniken Granberg 4eae9a00ff all components 2026-09-15 18:20:33 +02:00
Anniken Granberg 20b915e60a stories 2026-09-15 18:20:22 +02:00
Anniken Granberg 2e9623e6ae delete old file 2026-09-15 18:20:17 +02:00
Anniken Granberg 50df220467 export stuff 2026-09-15 18:20:05 +02:00
Anniken Granberg 3092a47caf styling 2026-09-15 18:19:42 +02:00
Anniken Granberg 9548ee4dfe Merge branch 'main' into new-component-coachmark 2026-09-12 18:35:04 +02:00
Halvor Haugan eca4d8998c feat(Combobox2): fullscreen on mobile 2026-09-08 20:01:00 +02:00
Ken 45c2f97bc7 [Website] Update cache and logger (#5244) 2026-09-08 20:01:00 +02:00
Halvor Haugan 0c44eddf32 feat(Combobox2): uncontrolled, hidden input, stories, extend HTMLAttributes 2026-09-08 20:01:00 +02:00
dependabot[bot] 0a5541291f ⬆️ Bump lint-staged from 17.0.8 to 17.4.1 (#5248)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 20:01:00 +02:00
dependabot[bot] e45ea82cb1 ⬆️ Bump @testing-library/react from 16.3.2 to 16.3.3 (#5250)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 20:01:00 +02:00
Halvor Haugan a718b9b2f1 fix(Combobox2): fix for long labels, refactor id 2026-09-08 20:00:59 +02:00
Halvor Haugan dd10d8826f feat(website): improve examples of Chips (#5245) 2026-09-08 20:00:59 +02:00
Halvor Haugan 147d250bb1 fix(website): fix Storybook example index regex 2026-09-08 20:00:59 +02:00
Halvor Haugan 61ec4392f2 feat(Listbox): support PageUp and PageDown (#5212) 2026-09-08 20:00:59 +02:00
Ken 6b9a4961f2 fix(fuse.js): Lock to 7.4.2 to avoid breaking update in 7.5.0 2026-09-08 20:00:59 +02:00
Halvor Haugan 4e6df60bea feat(Combobox2): add props disabled and error 2026-09-08 20:00:59 +02:00
Halvor Haugan 6b2060bc28 feat(Listbox): improved scrolling when using arrow keys and there are groups (#5243) 2026-09-08 20:00:59 +02:00
dependabot[bot]andKen a12f2ab821 ⬆️ Bump changesets/action from 1 to 2 in the actions group across 1 directory (#5195)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ken <ken.aleksander@gmail.com>
2026-09-08 20:00:59 +02:00
KenandHalvor Haugan 4b44b82af4 fix(core): add asChild guard to overridableComponent API (#5238)
Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2026-09-08 20:00:59 +02:00
Ken 4d676fb851 refactor(core): removed redundant role presentation from icons and svgs (#5240) 2026-09-08 20:00:59 +02:00
Ken 6f479078bd chore(core): FloatingContentProps JSDoc 2026-09-08 20:00:59 +02:00
Ken 7ac6e2f493 refactor(core): rename and move baseprimitve to hook (#5237) 2026-09-08 20:00:58 +02:00
Ken 3a6e91ab67 chore(core): lookup comment on why we use span 2026-09-08 20:00:58 +02:00
Ken ff5ed0d7d3 fix(core): removed bench-files from react-build 2026-09-08 20:00:58 +02:00
Ken fc743c5d6c chore(core): Slot comment 2026-09-08 20:00:58 +02:00
dependabot[bot] 588d68d2e7 ⬆️ Bump js-yaml from 5.4.0 to 5.4.1 (#5239)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 20:00:58 +02:00
Halvor Haugan bb6c4f1e4e chore(deps): upgrade yarn and fix playroom (#5235) 2026-09-08 20:00:58 +02:00
Halvor Haugan e88d0c3769 fix(website): remove data-color="accent" on WebsiteList 2026-09-08 20:00:58 +02:00
dependabot[bot] 8f5bba7f67 ⬆️ Bump @github-ui/storybook-addon-performance-panel from 1.1.4 to 1.2.0 in the storybook group (#5227)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 20:00:58 +02:00
dependabot[bot] baafa77279 ⬆️ Bump js-yaml from 5.3.0 to 5.4.0 (#5230)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 20:00:58 +02:00
dependabot[bot] 07b243c7a3 ⬆️ Bump postcss-selector-parser from 7.1.4 to 7.1.5 (#5231)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 20:00:58 +02:00
dependabot[bot] 0a7fa1c18c ⬆️ Bump sharp from 0.35.3 to 0.35.4 (#5234)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 20:00:58 +02:00
dependabot[bot] bcfd40d45c ⬆️ Bump body-parser from 2.2.2 to 2.3.0 (#5233)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 20:00:57 +02:00
Halvor Haugan ba20806ee0 perf(core): optimize writeComponentNames() (#5191) 2026-09-08 20:00:57 +02:00
github-actions[bot] 6604dcb972 Version Packages (#5224)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-08 20:00:57 +02:00
dependabot[bot] b007538123 ⬆️ Bump esbuild from 0.28.0 to 0.28.2 (#5216)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 20:00:57 +02:00
Ken bb2a3e0e9f refactor(apps): Handle links better for bug-reports from website 2026-09-08 20:00:57 +02:00
Ken f0b9fcaf57 fix(apps): Forvalter text now a heading 2026-09-08 20:00:57 +02:00
Ken 7058f715a3 feat(apps): added endringslogg to TOC 2026-09-08 20:00:57 +02:00
Ken b33b3c9ce3 fix(apps): Reduce gap to endringslogg-table 2026-09-08 20:00:57 +02:00
Ken b5361473b5 fix(apps): reduced gap between title and content for maler and grunnleggende 2026-09-08 20:00:57 +02:00
Ken aae2254fde chore(apps): removed styled-components from website deps 2026-09-08 20:00:57 +02:00
Halvor Haugan 9355cfcc49 chore(deps): remove unused dep babel-plugin-preval (#5222) 2026-09-08 20:00:57 +02:00
Dave HonnefferandKen 0e7c731cc0 fix(css): browserslist was bringing in old browsers (#5213)
Co-authored-by: Ken <ken.aleksander@gmail.com>
2026-09-08 20:00:56 +02:00
dependabot[bot] 5d2de11b8c ⬆️ Bump colorjs.io from 0.7.0 to 0.7.1 (#5218)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 20:00:56 +02:00
Halvor Haugan fda63d6265 feat(Sanity): limit length of external link tooltip (#5223) 2026-09-08 20:00:56 +02:00
Ken 1b21182e3e refactor(website): Renamed react.d.ts file to something more fitting (#5221) 2026-09-08 20:00:56 +02:00
Halvor Haugan a733f4e35c fix typo in @navikt/aksel/README.md 2026-09-08 20:00:56 +02:00
dependabot[bot] 4a62aac9d7 ⬆️ Bump @modelcontextprotocol/sdk from 1.29.0 to 1.30.0 (#5217)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 20:00:56 +02:00
Halvor Haugan 156e1b7a91 fix(LinkCard): add default value to JSDoc desc of data-color prop (#5220) 2026-09-08 20:00:56 +02:00
dependabot[bot] 2df9885977 ⬆️ Bump the storybook group with 8 updates (#5214)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 20:00:56 +02:00
Halvor Haugan 9626a24a83 refactor(i18n): simplify get function (#5181) 2026-09-08 20:00:56 +02:00
dependabot[bot]andHalvor Haugan 1546c86286 ⬆️ Bump react-rx from 4.2.3 to 5.1.1 (#5200)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Halvor Haugan <halvor.haugan@nav.no>
2026-09-08 20:00:56 +02:00
Halvor Haugan cace69f9d1 feat(Combobox2): small sizing for trigger, margin for desc, hideLabel, readOnly, stories cleanup 2026-09-08 20:00:56 +02:00
Halvor Haugan 6fefd933cb chore(deps): bump storybook to 10.4.9 2026-09-08 20:00:55 +02:00
dependabot[bot] e2d7e2f019 ⬆️ Bump dompurify from 3.4.12 to 3.4.13 (#5209)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 20:00:55 +02:00
dependabot[bot] e69638ad6d ⬆️ Bump js-yaml from 5.2.3 to 5.3.0 (#5208)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 20:00:55 +02:00
Halvor Haugan 61786beb69 chore(deps): bump @slack/web-api to 8.0.0 (#5210) 2026-09-08 20:00:55 +02:00
Halvor Haugan fbf5561aaf chore(deps): bump @google-cloud/bigquery to 9.0.2 2026-09-08 20:00:55 +02:00
Halvor Haugan 562e73fda5 refactor(Combobox2): Item -> Option 2026-09-08 20:00:55 +02:00
Anniken Granberg 4bf686b670 Merge branch 'main' into new-component-coachmark 2026-08-25 13:55:55 +02:00
Anniken Granberg d49b3d5167 Merge branch 'main' into new-component-coachmark 2026-08-25 11:35:34 +02:00
Anniken Granberg 71326cdaac inital 2026-06-22 13:47:38 +02:00
106 changed files with 5057 additions and 865 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@navikt/aksel-icons": minor
---
Icons: New icons `Gift`, `ArrowsPointingIn` and `ArrowsPointingOut`.
+5
View File
@@ -0,0 +1,5 @@
---
"@navikt/ds-react": patch
---
Combobox: The list is no longer focusable
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"@navikt/ds-react": patch
---
DataGrid: Fixed text selection inside details panels when keyboard navigation is enabled.
+6
View File
@@ -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
+7 -1
View File
@@ -61,8 +61,14 @@ runs:
SANITY_AUTH_TOKEN: ${{ inputs.sanity_deploy_studio }}
shell: bash
- name: Generate RSS feed
run: yarn workspace website update:rss
env:
SANITY_READ_NO_DRAFTS: ${{ inputs.sanity_read_no_drafts_token }}
shell: bash
- name: Build Next.js website
run: yarn build:next
run: yarn workspace website build:with-playroom
env:
SANITY_READ: ${{ inputs.sanity_read_token }}
SANITY_READ_NO_DRAFTS: ${{ inputs.sanity_read_no_drafts_token }}
-2
View File
@@ -19,7 +19,6 @@ updates:
applies-to: version-updates
patterns:
- "@next/eslint-plugin-next"
- "@next/bundle-analyzer"
- "next"
sanity:
applies-to: version-updates
@@ -53,7 +52,6 @@ updates:
applies-to: security-updates
patterns:
- "@next/eslint-plugin-next"
- "@next/bundle-analyzer"
- "next"
sanity-security:
applies-to: security-updates
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.0001 13.2501C10.4142 13.2501 10.7501 13.5859 10.7501 14.0001V18.0001C10.75 18.4142 10.4142 18.75 10.0001 18.7501C9.58586 18.7501 9.25009 18.4142 9.25006 18.0001V15.8106L5.03033 20.0303C4.73746 20.3232 4.26268 20.3232 3.96978 20.0303C3.67689 19.7374 3.67689 19.2627 3.96978 18.9698L8.18951 14.7501H6.00006C5.58586 14.7501 5.25009 14.4142 5.25006 14.0001C5.25006 13.5858 5.58584 13.2501 6.00006 13.2501H10.0001ZM18.0001 13.2501C18.4142 13.2501 18.7501 13.5859 18.7501 14.0001C18.75 14.4142 18.4142 14.75 18.0001 14.7501H15.8106L20.0303 18.9698C20.3232 19.2627 20.3232 19.7375 20.0303 20.0303C19.7375 20.3232 19.2627 20.3232 18.9698 20.0303L14.7501 15.8106V18.0001C14.75 18.4142 14.4142 18.75 14.0001 18.7501C13.5859 18.7501 13.2501 18.4142 13.2501 18.0001V14.0001C13.2501 13.8011 13.3291 13.6104 13.4698 13.4698C13.6104 13.3291 13.8011 13.2501 14.0001 13.2501H18.0001ZM3.96978 3.96978C4.26268 3.67689 4.73744 3.67689 5.03033 3.96978L9.25006 8.18951V6.00005C9.25006 5.58584 9.58584 5.25005 10.0001 5.25005C10.4142 5.25008 10.7501 5.58586 10.7501 6.00005V10.0001C10.75 10.4142 10.4142 10.75 10.0001 10.7501H6.00006C5.58586 10.7501 5.25009 10.4142 5.25006 10.0001C5.25006 9.58584 5.58584 9.25005 6.00006 9.25005H8.18951L3.96978 5.03033C3.67689 4.73743 3.67689 4.26267 3.96978 3.96978ZM18.9698 3.96978C19.2627 3.67689 19.7374 3.67689 20.0303 3.96978C20.3232 4.26268 20.3232 4.73745 20.0303 5.03033L15.8106 9.25005H18.0001C18.4142 9.25008 18.7501 9.58586 18.7501 10.0001C18.75 10.4142 18.4142 10.75 18.0001 10.7501H14.0001C13.5859 10.7501 13.2501 10.4142 13.2501 10.0001V6.00005C13.2501 5.58584 13.5858 5.25005 14.0001 5.25005C14.4142 5.25008 14.7501 5.58586 14.7501 6.00005V8.18951L18.9698 3.96978Z" fill="#202733"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

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

After

Width:  |  Height:  |  Size: 1.5 KiB

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

After

Width:  |  Height:  |  Size: 1.1 KiB

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

After

Width:  |  Height:  |  Size: 1.3 KiB

+8
View File
@@ -0,0 +1,8 @@
name: GiftFill
category: Home
sub_category: Toys
keywords:
- gave
- presang
variant: Fill
created_at: 31.10.2026
+1 -1
View File
@@ -2,6 +2,6 @@ name: Keyboard
category: Interface
sub_category: Devices
keywords:
- Keyboard
- Tastatur
variant: Stroke
created_at: 26.10.2026
+1 -1
View File
@@ -2,6 +2,6 @@ name: KeyboardFill
category: Interface
sub_category: Devices
keywords:
- KeyboardFill
- Tastatur
variant: Fill
created_at: 26.10.2026
@@ -2,6 +2,9 @@ name: TableCellsMerge
category: Interface
sub_category: Table
keywords:
- TableCellsMerge
- celle
- slå sammen
- tabell
- datagrid
variant: Stroke
created_at: 26.10.2026
@@ -2,6 +2,9 @@ name: TableCellsMergeFill
category: Interface
sub_category: Table
keywords:
- TableCellsMergeFill
- celle
- slå sammen
- tabell
- datagrid
variant: Fill
created_at: 26.10.2026
@@ -2,6 +2,9 @@ name: TableCellsSplit
category: Interface
sub_category: Table
keywords:
- TableCellsSplit
- celle
- splitt
- tabell
- datagrid
variant: Stroke
created_at: 26.10.2026
@@ -2,6 +2,9 @@ name: TableCellsSplitFill
category: Interface
sub_category: Table
keywords:
- TableCellsSplitFill
- celle
- splitt
- tabell
- datagrid
variant: Fill
created_at: 26.10.2026
+4 -1
View File
@@ -2,6 +2,9 @@ name: TableCross
category: Interface
sub_category: Table
keywords:
- TableCross
- tabell
- datagrid
- fjern
- slett
variant: Stroke
created_at: 26.10.2026
+4 -1
View File
@@ -2,6 +2,9 @@ name: TableCrossFill
category: Interface
sub_category: Table
keywords:
- TableCrossFill
- tabell
- datagrid
- fjern
- slett
variant: Fill
created_at: 26.10.2026
@@ -2,6 +2,9 @@ name: TableDeleteColumn
category: Interface
sub_category: Table
keywords:
- TableDeleteColumn
- slett kolonne
- tabell
- datagrid
- fjern
variant: Stroke
created_at: 26.10.2026
@@ -2,6 +2,9 @@ name: TableDeleteColumnFill
category: Interface
sub_category: Table
keywords:
- TableDeleteColumnFill
- slett kolonne
- tabell
- datagrid
- fjern
variant: Fill
created_at: 26.10.2026
+4 -1
View File
@@ -2,6 +2,9 @@ name: TableDeleteRow
category: Interface
sub_category: Table
keywords:
- TableDeleteRow
- slett rad
- tabell
- datagrid
- fjern
variant: Stroke
created_at: 26.10.2026
@@ -2,6 +2,9 @@ name: TableDeleteRowFill
category: Interface
sub_category: Table
keywords:
- TableDeleteRowFill
- slett rad
- tabell
- datagrid
- fjern
variant: Fill
created_at: 26.10.2026
+2 -1
View File
@@ -2,6 +2,7 @@ name: TableGrid
category: Interface
sub_category: Table
keywords:
- TableGrid
- tabell
- datagrid
variant: Stroke
created_at: 26.10.2026
+2 -1
View File
@@ -2,6 +2,7 @@ name: TableGridFill
category: Interface
sub_category: Table
keywords:
- TableGridFill
- tabell
- datagrid
variant: Fill
created_at: 26.10.2026
@@ -2,6 +2,9 @@ name: TableInsertColumnLeft
category: Interface
sub_category: Table
keywords:
- TableInsertColumnLeft
- ny kolonne
- venstre
- tabell
- datagrid
variant: Stroke
created_at: 26.10.2026
@@ -2,6 +2,9 @@ name: TableInsertColumnLeftFill
category: Interface
sub_category: Table
keywords:
- TableInsertColumnLeftFill
- ny kolonne
- venstre
- tabell
- datagrid
variant: Fill
created_at: 26.10.2026
@@ -2,6 +2,9 @@ name: TableInsertColumnRight
category: Interface
sub_category: Table
keywords:
- TableInsertColumnRight
- ny kolonne
- høyre
- tabell
- datagrid
variant: Stroke
created_at: 26.10.2026
@@ -2,6 +2,9 @@ name: TableInsertColumnRightFill
category: Interface
sub_category: Table
keywords:
- TableInsertColumnRightFill
- ny kolonne
- høyre
- tabell
- datagrid
variant: Fill
created_at: 26.10.2026
@@ -2,6 +2,9 @@ name: TableInsertRowAbove
category: Interface
sub_category: Table
keywords:
- TableInsertRowAbove
- ny rad
- over
- tabell
- datagrid
variant: Stroke
created_at: 26.10.2026
@@ -2,6 +2,9 @@ name: TableInsertRowAboveFill
category: Interface
sub_category: Table
keywords:
- TableInsertRowAboveFill
- ny rad
- over
- tabell
- datagrid
variant: Fill
created_at: 26.10.2026
@@ -2,6 +2,9 @@ name: TableInsertRowBelow
category: Interface
sub_category: Table
keywords:
- TableInsertRowBelow
- ny rad
- under
- tabell
- datagrid
variant: Stroke
created_at: 26.10.2026
@@ -2,6 +2,9 @@ name: TableInsertRowBelowFill
category: Interface
sub_category: Table
keywords:
- TableInsertRowBelowFill
- ny rad
- under
- tabell
- datagrid
variant: Fill
created_at: 26.10.2026
+4 -1
View File
@@ -2,6 +2,9 @@ name: TablePlus
category: Interface
sub_category: Table
keywords:
- TablePlus
- tabell
- legg til
- ny
- datagrid
variant: Stroke
created_at: 26.10.2026
+4 -1
View File
@@ -2,6 +2,9 @@ name: TablePlusFill
category: Interface
sub_category: Table
keywords:
- TablePlusFill
- tabell
- legg til
- ny
- datagrid
variant: Fill
created_at: 26.10.2026
+1 -1
View File
@@ -91,6 +91,6 @@
"tsx": "^4.23.15",
"typescript": "6.0.3",
"unified": "11.0.5",
"vitest": "4.1.11"
"vitest": "5.0.5"
}
}
+1 -1
View File
@@ -49,7 +49,7 @@
"postcss-selector-parser": "^7.1.7",
"postcss-value-parser": "^4.2.0",
"stylelint": "^17.15.0",
"stylelint-test-rule-node": "^1.2.0",
"stylelint-test-rule-node": "^1.3.0",
"tsx": "^4.23.15",
"typescript": "6.0.3"
}
+1 -1
View File
@@ -58,7 +58,7 @@
"@types/lodash": "4.17.25",
"rimraf": "6.1.3",
"typescript": "6.0.3",
"vitest": "4.1.11"
"vitest": "5.0.5"
},
"sideEffects": false,
"engines": {
+156
View File
@@ -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;
}
+1
View File
@@ -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);
+12 -2
View File
@@ -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",
@@ -806,7 +816,7 @@
"copyfiles": "^2.4.1",
"cross-env": "^10.1.0",
"jscodeshift": "17.4.0",
"jsdom": "29.1.1",
"jsdom": "30.1.3",
"react": "19.3.1",
"react-dom": "19.3.1",
"rimraf": "6.1.3",
@@ -814,7 +824,7 @@
"tsc-alias": "1.9.7",
"tsx": "^4.23.15",
"typescript": "6.0.3",
"vitest": "4.1.11"
"vitest": "5.0.5"
},
"peerDependencies": {
"@types/react": "^17.0.30 || ^18 || ^19",
@@ -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>
);
};
+449
View File
@@ -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 };
+34
View File
@@ -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 };
+182
View File
@@ -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,
};
+52
View File
@@ -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 };
@@ -82,8 +82,6 @@ interface DataGridComponent {
/**
* Component for displaying tabular data.
*
* **WARNING: This component is in active development and may receive breaking changes outside major releases!**
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/datagrid)
* @see 🏷️ {@link DataGridProps}
*
@@ -12,7 +12,7 @@ function DataTableDetailsPanelRow<T>({
rowId: string;
rowData: T;
}) {
const { tableId, totalColSpan } = useDataTableContext();
const { tableId, totalColSpan, withKeyboardNav } = useDataTableContext();
const {
enableDetailsPanel,
isExpanded,
@@ -46,6 +46,7 @@ function DataTableDetailsPanelRow<T>({
<td
id={expansionId}
colSpan={totalColSpan}
tabIndex={withKeyboardNav ? -1 : undefined}
className="aksel-data-table__details-panel-row-cell"
>
<div style={style}>{content}</div>
@@ -343,18 +343,21 @@ export const EnterNotSubmittingForm: StoryObj<{
args: {
onSubmit: fn(),
},
render: ({ onSubmit }) => {
return (
<form action="https://www.aksel.nav.no" method="get" onSubmit={onSubmit}>
<UNSAFE_Combobox
options={options}
label="Hva er dine favorittfrukter?"
isMultiSelect
allowNewValues
/>
</form>
);
},
render: ({ onSubmit }) => (
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit(event);
}}
>
<UNSAFE_Combobox
options={options}
label="Hva er dine favorittfrukter?"
isMultiSelect
allowNewValues
/>
</form>
),
play: async ({ canvasElement, args }) => {
args.onSubmit.mockClear();
const canvas = within(canvasElement);
@@ -115,6 +115,7 @@ const FilteredOptions = () => {
ref={setFilteredOptionsRef}
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: The <ul> element acts as a listbox in the combobox.
role="listbox"
tabIndex={-1}
className="aksel-combobox__list-options"
>
{isValueNew && !maxSelected.isLimitReached && allowNewValues && (
+1
View File
@@ -3,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 {
@@ -1,6 +1,6 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import React, { useState } from "react";
import { describe, expect, test } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { Button } from "../../button";
import { Modal } from "../index";
@@ -18,6 +18,10 @@ const Test = () => {
};
describe("Modal", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("should be visible", async () => {
render(<Test />);
expect(await screen.findByText("Foobar")).toBeVisible();
@@ -29,25 +33,37 @@ describe("Modal", () => {
expect(screen.getByText("Foobar")).not.toBeVisible();
});
test("should toggle scroll lock", async () => {
render(<Test />);
test.each([false, true])(
"should toggle scroll lock (scrollbar-gutter support: %s)",
async (supportsScrollbarGutter) => {
vi.spyOn(CSS, "supports").mockReturnValue(supportsScrollbarGutter);
render(<Test />);
await waitFor(() => {
expect(document.documentElement.style.overflowX).toBe("hidden");
});
await waitFor(() => {
expect(document.documentElement.style.overflowY).toBe("hidden");
});
await waitFor(() => {
expect(document.documentElement.style.scrollBehavior).toBe("unset");
});
await waitFor(() => {
expect(document.documentElement.style.scrollbarGutter).toBe("stable");
});
const lockedElement = supportsScrollbarGutter
? document.body
: document.documentElement;
fireEvent.click(screen.getByText("Close"));
await waitFor(() => {
expect(document.documentElement.style.cssText).toBe("");
});
});
await waitFor(() => {
expect(lockedElement.style.overflowX).toBe("hidden");
});
await waitFor(() => {
expect(lockedElement.style.overflowY).toBe("hidden");
});
await waitFor(() => {
expect(document.documentElement.style.scrollBehavior).toBe(
supportsScrollbarGutter ? "" : "unset",
);
});
await waitFor(() => {
expect(document.documentElement.style.scrollbarGutter).toBe("stable");
});
fireEvent.click(screen.getByText("Close"));
await waitFor(() => {
expect(document.documentElement.style.cssText).toBe("");
expect(lockedElement.style.overflowX).toBe("");
expect(lockedElement.style.overflowY).toBe("");
});
},
);
});
@@ -83,6 +83,25 @@ export const DisabledMonths = () => (
/>
);
/**
* Only June is enabled. ArrowRight/ArrowLeft on June keeps focus on June instead
* of searching forever. Keyboard search stops at the
* fromDate/toDate years, or after 100 years when no bounds are set.
*/
export const KeyboardNoEnabledMonth = () => (
<div>
<p>Focus June and press ArrowRight or ArrowLeft. Focus stays on June.</p>
<MonthPicker.Standalone
defaultSelected={new Date(year, 5, 1)}
disabled={[
(date) => !(date.getFullYear() === year && date.getMonth() === 5),
]}
fromDate={new Date(year, 0, 1)}
toDate={new Date(year, 11, 1)}
/>
</div>
);
export const Standalone = () => {
return <MonthPicker.Standalone defaultSelected={new Date(`Jan 1 ${year}`)} />;
};
@@ -0,0 +1,108 @@
import { isSameMonth } from "date-fns/isSameMonth";
import { describe, expect, test, vi } from "vitest";
import { nextEnabled } from "./MonthPicker.util";
const months = Array.from({ length: 12 }, (_, i) => new Date(2024, i, 1));
const june2024 = new Date(2024, 5, 1);
/* Disables every month except `enabled`. Throws instead of hanging if the search becomes unbounded. */
const onlyEnabled = (...enabled: Date[]) => {
let calls = 0;
return (date: Date) => {
calls += 1;
if (calls > 5000) {
throw new Error("Month search did not terminate");
}
return !enabled.some((month) => isSameMonth(month, date));
};
};
const search = (
key: string,
disabled: (date: Date) => boolean,
options: { dropdownCaption?: boolean; fromDate?: Date; toDate?: Date } = {},
) => {
const setYearState = vi.fn();
const result = nextEnabled(
months,
key,
[disabled],
june2024,
setYearState,
june2024,
options.dropdownCaption ?? false,
options.fromDate,
options.toDate,
);
return { result, setYearState };
};
describe("nextEnabled", () => {
test.each(["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp"])(
"keeps focus when no month is enabled in direction (%s, no bounds)",
(key) => {
const { result, setYearState } = search(key, onlyEnabled(june2024));
expect(result).toEqual(june2024);
expect(setYearState).not.toHaveBeenCalled();
},
);
test.each([false, true])(
"stops at fromDate/toDate years (dropdownCaption: %s)",
(dropdownCaption) => {
const bounds = {
dropdownCaption,
fromDate: new Date(2023, 0, 1),
toDate: new Date(2025, 11, 1),
};
const disabled = onlyEnabled(
june2024,
new Date(2022, 5, 1),
new Date(2026, 5, 1),
);
for (const key of ["ArrowRight", "ArrowLeft"]) {
const { result, setYearState } = search(key, disabled, bounds);
expect(result).toEqual(june2024);
expect(setYearState).not.toHaveBeenCalled();
}
},
);
test("skips disabled months/years and moves to the next enabled year", () => {
const target = new Date(2027, 2, 1);
const { result, setYearState } = search(
"ArrowRight",
onlyEnabled(june2024, target),
);
expect(result).toEqual(target);
expect(setYearState).toHaveBeenCalledTimes(1);
expect(setYearState.mock.calls[0][0].getFullYear()).toBe(2027);
});
test("finds previous enabled month within bounds", () => {
const target = new Date(2023, 10, 1);
const { result, setYearState } = search(
"ArrowLeft",
onlyEnabled(june2024, target),
{ fromDate: new Date(2023, 0, 1) },
);
expect(result).toEqual(target);
expect(setYearState.mock.calls[0][0].getFullYear()).toBe(2023);
});
test("searches at most 100 years without bounds", () => {
const within = new Date(2124, 0, 1);
expect(search("ArrowRight", onlyEnabled(june2024, within)).result).toEqual(
within,
);
const beyond = new Date(2125, 0, 1);
expect(search("ArrowRight", onlyEnabled(june2024, beyond)).result).toEqual(
june2024,
);
});
});
@@ -56,14 +56,14 @@ export const nextEnabled = (
}
}
if (key === "ArrowRight") {
const nextMonth = loopForward(
const nextMonth = searchEnabledMonth(
1,
currentIndex,
months,
disabled,
yearState,
setYearState,
disabled,
false,
dropdownCaption,
fromDate,
toDate,
);
@@ -72,14 +72,14 @@ export const nextEnabled = (
}
}
if (key === "ArrowLeft") {
const prevMonth = loopBack(
currentIndex - 1,
const prevMonth = searchEnabledMonth(
-1,
currentIndex,
months,
disabled,
yearState,
setYearState,
false,
dropdownCaption,
fromDate,
toDate,
);
@@ -98,14 +98,14 @@ export const nextEnabled = (
return setYear(months[currentIndex + 4], yearState.getFullYear());
}
const fallbackNext = loopForward(
const fallbackNext = searchEnabledMonth(
1,
currentIndex,
months,
disabled,
yearState,
setYearState,
disabled,
true,
dropdownCaption,
fromDate,
toDate,
);
@@ -127,14 +127,14 @@ export const nextEnabled = (
)
)
return setYear(months[currentIndex - 4], Number(yearState.getFullYear()));
const fallbackPrev = loopBack(
const fallbackPrev = searchEnabledMonth(
-1,
currentIndex,
months,
disabled,
yearState,
setYearState,
true,
dropdownCaption,
fromDate,
toDate,
);
@@ -144,90 +144,59 @@ export const nextEnabled = (
return currentMonth;
};
const loopBack = (
currentIndex: number,
months: Date[],
disabled: Matcher[],
yearState: Date,
setYearState: (date: Date) => void,
rowCheck: boolean,
dropdownCaption: boolean,
fromDate?: Date,
toDate?: Date,
): { index: number; year: number } | undefined => {
let currentYear = setYear(yearState, Number(yearState.getFullYear()));
for (let i = currentIndex; i >= -1; i--) {
if (i === -1) {
if (
isOutOfRange(
dropdownCaption,
setYear(currentYear, Number(currentYear.getFullYear() - 1)),
fromDate,
toDate,
)
)
return;
currentYear = setYear(currentYear, Number(currentYear.getFullYear() - 1));
setYearState(currentYear);
i = 11;
}
const month = months[i];
const isDisabled = !isMatch(
setYear(month, Number(currentYear.getFullYear())),
disabled,
);
if (rowCheck) {
if (isDisabled && getRow(i) !== getRow(currentIndex)) {
return { index: i, year: Number(currentYear.getFullYear()) };
}
} else {
if (isDisabled) {
return { index: i, year: Number(currentYear.getFullYear()) };
}
}
}
};
/**
* Keyboard search for an enabled month never crosses fromDate/toDate years and
* gives up after this many years when no bounds are set, so a matcher that
* disables every remaining month cannot hang the page.
*/
const MAX_SEARCH_YEARS = 100;
const loopForward = (
const searchEnabledMonth = (
direction: 1 | -1,
currentIndex: number,
months: Date[],
disabled: Matcher[],
yearState: Date,
setYearState: (date: Date) => void,
disabled: Matcher[],
rowCheck: boolean,
dropdownCaption: boolean,
fromDate?: Date,
toDate?: Date,
): { index: number; year: number } | undefined => {
let currentYear = setYear(yearState, Number(yearState.getFullYear()));
for (let i = currentIndex + 1; i < months.length + 1; i++) {
if (i === 12) {
if (
isOutOfRange(
dropdownCaption,
setYear(currentYear, Number(currentYear.getFullYear() + 1)),
fromDate,
toDate,
)
)
const currentYear = yearState.getFullYear();
if (Number.isNaN(currentYear)) {
return;
}
const boundYear =
direction === 1 ? toDate?.getFullYear() : fromDate?.getFullYear();
const horizonYear = currentYear + direction * MAX_SEARCH_YEARS;
const lastYear = !boundYear
? horizonYear
: direction === 1
? Math.min(boundYear, horizonYear)
: Math.max(boundYear, horizonYear);
let nextYear = currentYear;
let monthIndex = currentIndex;
while (true) {
monthIndex += direction;
if (monthIndex < 0 || monthIndex > 11) {
if (nextYear === lastYear || (nextYear - lastYear) * direction > 0) {
return;
currentYear = setYear(currentYear, Number(currentYear.getFullYear() + 1));
setYearState(currentYear);
i = 0;
}
nextYear += direction;
monthIndex = direction === 1 ? 0 : 11;
}
const month = months[i];
const isDisabled = !isMatch(
setYear(month, Number(currentYear.getFullYear())),
disabled,
);
if (rowCheck) {
if (isDisabled && getRow(i) !== getRow(currentIndex)) {
return { index: i, year: Number(currentYear.getFullYear()) };
}
} else {
if (isDisabled) {
return { index: i, year: Number(currentYear.getFullYear()) };
const isEnabled = !isMatch(setYear(months[monthIndex], nextYear), disabled);
if (
isEnabled &&
(!rowCheck || getRow(monthIndex) !== getRow(currentIndex))
) {
if (nextYear !== currentYear) {
setYearState(setYear(yearState, nextYear));
}
return { index: monthIndex, year: nextYear };
}
}
};
@@ -238,25 +207,6 @@ const getRow = (index: number): number => {
return 3;
};
const isOutOfRange = (
dropdownCaption: boolean,
year: Date,
fromDate?: Date,
toDate?: Date,
): boolean => {
if (
dropdownCaption &&
fromDate &&
toDate &&
(year.getFullYear() < fromDate?.getFullYear() ||
year.getFullYear() > toDate?.getFullYear())
) {
return true;
}
return false;
};
const nextOnRow = (
currentIndex: number,
months: Date[],
@@ -123,9 +123,13 @@ const PopoverRoot = forwardRef<HTMLDivElement, PopoverProps>(
],
});
/**
* Only give Floating UI a reference while open. Otherwise every closed instance
* computes (and commits) a position
*/
useClientLayoutEffect(() => {
refs.setReference(anchorEl);
}, [anchorEl, refs]);
refs.setReference(open ? anchorEl : null);
}, [anchorEl, open, refs]);
const floatingRef = useMergeRefs(refs.setFloating, ref);
@@ -1,6 +1,6 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { bench, describe } from "vitest";
import { describe, test } from "vitest";
import { HStack, VStack } from "../stack";
import { Box } from "./Box";
@@ -15,82 +15,86 @@ import { Box } from "./Box";
*/
describe("Box – single element", () => {
bench("bare (no props)", () => {
renderToStaticMarkup(<Box>Hei</Box>);
});
test("render throughput", async ({ bench }) => {
await bench("bare (no props)", () => {
renderToStaticMarkup(<Box>Hei</Box>);
}).run();
bench("styling props only (background/border/radius/shadow)", () => {
renderToStaticMarkup(
<Box
background="neutral-soft"
borderColor="neutral-subtle"
borderWidth="1"
borderRadius="12"
shadow="dialog"
>
Hei
</Box>,
);
});
await bench("styling props only (background/border/radius/shadow)", () => {
renderToStaticMarkup(
<Box
background="neutral-soft"
borderColor="neutral-subtle"
borderWidth="1"
borderRadius="12"
shadow="dialog"
>
Hei
</Box>,
);
}).run();
bench("single responsive/primitive prop (padding)", () => {
renderToStaticMarkup(<Box padding="space-16">Hei</Box>);
});
await bench("single responsive/primitive prop (padding)", () => {
renderToStaticMarkup(<Box padding="space-16">Hei</Box>);
}).run();
bench("many primitive props (padding/margin/width/position/inset)", () => {
renderToStaticMarkup(
<Box
padding="space-16"
margin="space-8"
width="200px"
maxWidth="400px"
position="relative"
inset="space-4"
>
Hei
</Box>,
);
});
await bench("many primitive props (padding/margin/width/position/inset)", () => {
renderToStaticMarkup(
<Box
padding="space-16"
margin="space-8"
width="200px"
maxWidth="400px"
position="relative"
inset="space-4"
>
Hei
</Box>,
);
}).run();
bench("responsive object prop (padding per breakpoint)", () => {
renderToStaticMarkup(
<Box
padding={{
xs: "space-8",
sm: "space-12",
md: "space-16",
lg: "space-20",
xl: "space-24",
}}
>
Hei
</Box>,
);
});
await bench("responsive object prop (padding per breakpoint)", () => {
renderToStaticMarkup(
<Box
padding={{
xs: "space-8",
sm: "space-12",
md: "space-16",
lg: "space-20",
xl: "space-24",
}}
>
Hei
</Box>,
);
}).run();
bench("asChild (Slot merge path)", () => {
renderToStaticMarkup(
<Box padding="space-16" asChild>
<section>Hei</section>
</Box>,
);
await bench("asChild (Slot merge path)", () => {
renderToStaticMarkup(
<Box padding="space-16" asChild>
<section>Hei</section>
</Box>,
);
}).run();
});
});
describe("Box – realistic tree (24 children)", () => {
const children = Array.from({ length: 24 }, (_, i) => i);
bench("VStack > Box grid of cards", () => {
renderToStaticMarkup(
<VStack gap="space-16">
{children.map((i) => (
<HStack key={i} gap="space-8" align="center">
<Box padding="space-16" background="neutral-soft" borderRadius="8">
Card {i}
</Box>
</HStack>
))}
</VStack>,
);
test("render throughput", async ({ bench }) => {
await bench("VStack > Box grid of cards", () => {
renderToStaticMarkup(
<VStack gap="space-16">
{children.map((i) => (
<HStack key={i} gap="space-8" align="center">
<Box padding="space-16" background="neutral-soft" borderRadius="8">
Card {i}
</Box>
</HStack>
))}
</VStack>,
);
}).run();
});
});
@@ -8,6 +8,9 @@ interface ListboxInputSlotProps {
virtuallyFocusedOptionId: string;
}
/**
* Remember that role=combobox requires aria-expanded
*/
const ListboxInputSlot = ({
children,
virtuallyFocusedOptionId,
@@ -1,6 +1,6 @@
import { renderHook } from "@testing-library/react";
import React from "react";
import { bench, describe } from "vitest";
import { describe, test } from "vitest";
import { Provider } from "../../provider";
import { get } from "./get";
import { useI18n } from "./i18n.hooks";
@@ -8,14 +8,16 @@ import type { ComponentTranslation } from "./i18n.types";
import { nb } from "./locales";
describe("translate function throughput", () => {
const { result } = renderHook(() => useI18n("FileUpload"));
test("translation lookups", async ({ bench }) => {
const { result } = renderHook(() => useI18n("FileUpload"));
bench("simple key lookup (no replacements)", () => {
result.current("item.uploading");
});
await bench("simple key lookup (no replacements)", () => {
result.current("item.uploading");
}).run();
bench("key lookup with replacements", () => {
result.current("item.uploading", { name: "Test", count: 5 } as never);
await bench("key lookup with replacements", () => {
result.current("item.uploading", { name: "Test", count: 5 } as never);
}).run();
});
});
@@ -23,10 +25,13 @@ describe("translate with local overrides", () => {
const local = {
item: { uploading: "Hello {name}, you have {count} files" },
};
const { result } = renderHook(() => useI18n("FileUpload", local));
bench("local override with replacements", () => {
result.current("item.uploading", { name: "Test", count: 5 });
test("local override lookup", async ({ bench }) => {
const { result } = renderHook(() => useI18n("FileUpload", local));
await bench("local override with replacements", () => {
result.current("item.uploading", { name: "Test", count: 5 });
}).run();
});
});
@@ -38,18 +43,21 @@ describe("translate with multiple context objects", () => {
{children}
</Provider>
);
const { result } = renderHook(() => useI18n("FileUpload"), { wrapper });
bench("hit in first context object", () => {
result.current("dropzone.button");
});
test("context lookups", async ({ bench }) => {
const { result } = renderHook(() => useI18n("FileUpload"), { wrapper });
bench("hit in second context object", () => {
result.current("item.uploading");
});
await bench("hit in first context object", () => {
result.current("dropzone.button");
}).run();
bench("fallback to locale default", () => {
result.current("item.downloading");
await bench("hit in second context object", () => {
result.current("item.uploading");
}).run();
await bench("fallback to locale default", () => {
result.current("item.downloading");
}).run();
});
});
@@ -61,19 +69,21 @@ describe("get() function throughput", () => {
nb.FileUpload,
];
bench("shallow key hit in first object", () => {
get("item.uploading", objects);
});
test("translation lookup", async ({ bench }) => {
await bench("shallow key hit in first object", () => {
get("item.uploading", objects);
}).run();
bench("key hit in second object (skip first)", () => {
get("item.downloading", objects);
});
await bench("key hit in second object (skip first)", () => {
get("item.downloading", objects);
}).run();
bench("deep fallback to last object", () => {
get("dropzone.button", objects);
});
await bench("deep fallback to last object", () => {
get("dropzone.button", objects);
}).run();
bench("long key dot notation parsing", () => {
get("dropzone.dragAndDropMultiple", [nb.FileUpload]);
await bench("long key dot notation parsing", () => {
get("dropzone.dragAndDropMultiple", [nb.FileUpload]);
}).run();
});
});
@@ -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",
@@ -6,6 +6,7 @@ export default defineConfig({
globals: true,
setupFiles: "./tests/setup.ts",
environment: "jsdom",
pool: "vmThreads",
typecheck: {
enabled: true,
},
+2 -2
View File
@@ -30,10 +30,10 @@
"devDependencies": {
"@navikt/ds-tokens": "^8.21.0",
"@types/node": "^24.10.9",
"tailwindcss": "^3.3.3",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.15",
"typescript": "6.0.3",
"vitest": "4.1.11"
"vitest": "5.0.5"
},
"publishConfig": {
"access": "public",
+2 -2
View File
@@ -34,7 +34,7 @@
},
"homepage": "https://aksel.nav.no/grunnleggende/styling/design-tokens/",
"devDependencies": {
"@figma/plugin-typings": "^1.131.0",
"@figma/plugin-typings": "^1.142.0",
"@types/lodash": "4.17.25",
"colorjs.io": "^0.7.1",
"esbuild": "^0.28.2",
@@ -43,7 +43,7 @@
"style-dictionary": "^5.5.5",
"tsx": "^4.23.15",
"typescript": "6.0.3",
"vitest": "4.1.11"
"vitest": "5.0.5"
},
"publishConfig": {
"access": "public",
+8
View File
@@ -37,6 +37,14 @@ yarn build:next
yarn serve:next
```
`yarn build:next` genererer ikke RSS-feeden og bygger ikke Playroom. Kjør dette for å bygge alt:
```bash
yarn workspace website update:rss
yarn workspace website build:with-playroom
yarn serve:next
```
**Rettigheter**
Sanity-datasettet er privat, noe som betyr du må ha tilgang til Sanity-applikasjonen og følgende tokens:
+1 -1
View File
@@ -46,7 +46,7 @@
"@types/react": "19.2.14",
"tsx": "^4.23.15",
"typescript": "6.0.3",
"vitest": "4.1.11"
"vitest": "5.0.5"
},
"exports": {
"./schema": {
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Masonry from "react-responsive-masonry";
import { Masonry } from "react-responsive-masonry";
import { Box, HGrid, Heading } from "@navikt/ds-react";
import { useMedia } from "@/app/_ui/utils/hooks/useMedia";
import Card, { type ArticleT } from "./FrontpageMasonryCard";
+1 -3
View File
@@ -1,4 +1,3 @@
import BundleAnalyzer from "@next/bundle-analyzer";
import type { NextConfig } from "next";
import { sanity } from "next-sanity/live/cache-life";
import path from "node:path";
@@ -173,5 +172,4 @@ const nextConfig: NextConfig = {
serverExternalPackages: ["@navikt/next-logger", "next-logger", "pino"],
};
module.exports =
process.env.ANALYZE === "true" ? BundleAnalyzer()(nextConfig) : nextConfig;
module.exports = nextConfig;
+9 -8
View File
@@ -6,7 +6,8 @@
"author": "Aksel | Nav designsystem team",
"scripts": {
"dev": "next | pino-pretty --messageKey=message",
"build": "concurrently \"yarn workspace aksel-playroom build:playroom\" \"yarn next build\" \"yarn update:rss\" && yarn cp:assets",
"build": "yarn next build && yarn cp:assets",
"build:with-playroom": "concurrently \"yarn workspace aksel-playroom build:playroom\" \"yarn next build\" && yarn cp:assets:with-playroom",
"start": "cd .next/standalone/aksel.nav.no/website && PORT=3000 HOSTNAME=0.0.0.0 node server.js",
"e2e:dev": "yarn generate-testpaths && playwright test --ui",
"e2e:docker": "yarn generate-testpaths && playwright test",
@@ -17,7 +18,7 @@
"update:changelog": "yarn tsx --env-file-if-exists=.env ./scripts/changelog",
"update:stats": "yarn tsx --env-file-if-exists=.env ./scripts/update-stats/update-stats",
"update:sanity": "yarn update:metadata && yarn update:eksempler && yarn update:changelog && yarn update:stats",
"analyze": "cross-env ANALYZE=true yarn build",
"analyze": "next analyze",
"test": "vitest run -c config/vitest.config.ts",
"test:watch": "vitest watch -c config/vitest.config.ts",
"playwright": "playwright test",
@@ -26,7 +27,8 @@
"cp:playroom": "copyfiles -E -u 5 '../../apps/playroom/dist/sandbox/**' public/",
"cp:static": "copyfiles -E -u 1 '.next/static/**' .next/standalone/aksel.nav.no/website/.next/",
"cp:public": "copyfiles -E 'public/**' .next/standalone/aksel.nav.no/website/",
"cp:assets": "yarn cp:playroom && yarn cp:public && yarn cp:static"
"cp:assets": "yarn cp:public && yarn cp:static",
"cp:assets:with-playroom": "yarn cp:playroom && yarn cp:assets"
},
"dependencies": {
"@navikt/analytics-types": "0.0.9",
@@ -48,13 +50,13 @@
"next-logger": "^5.0.2",
"next-sanity": "13.2.3",
"next-themes": "^0.4.6",
"pino": "^10.3.1",
"pino": "^10.4.0",
"prism-react-renderer": "^2.0.0",
"react": "19.3.1",
"react-dom": "19.3.1",
"react-hook-form": "^7.90.0",
"react-markdown": "^9.0.1",
"react-responsive-masonry": "2.7.2",
"react-responsive-masonry": "2.9.0",
"react-snowfall": "^2.4.0",
"remark-gfm": "^4.0.0",
"rxjs": "^7.8.2",
@@ -72,7 +74,6 @@
"@babel/parser": "^7.27.5",
"@babel/traverse": "^7.27.4",
"@google-cloud/bigquery": "^9.5.0",
"@next/bundle-analyzer": "16.4.3",
"@next/eslint-plugin-next": "16.4.3",
"@playwright/test": "1.61.1",
"@portabletext/block-tools": "^6.0.0",
@@ -90,7 +91,7 @@
"concurrently": "10.0.6",
"copyfiles": "^2.4.1",
"cross-env": "^10.1.0",
"jsdom": "29.1.1",
"jsdom": "30.1.3",
"json5": "2.2.3",
"pino-pretty": "^13.1.3",
"rss": "^1.2.2",
@@ -98,6 +99,6 @@
"sitemapper": "^4.1.6",
"tsx": "^4.23.15",
"typescript": "6.0.3",
"vitest": "4.1.11"
"vitest": "5.0.5"
}
}
@@ -0,0 +1,70 @@
import { useRef } from "react";
import { Button, Coach } from "@navikt/ds-react";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
const buttonRef = useRef<HTMLButtonElement>(null);
return (
<Coach
steps={[
{
id: "step-1",
type: "dialog",
title: "Lukkbar dialog",
allowToEndTour: true,
description:
"Denne dialogen kan avslutte turen ved å trykke på krysset øverst til høyre.",
},
{
id: "step-2",
type: "dialog",
title: "Ikke lukkbar dialog",
description:
"Denne dialogen har ikke knapp øverst til høyre og kan ikke avslutte turen.",
},
{
id: "step-3",
type: "anchor",
anchorRef: buttonRef,
title: "Lukkbar anchor",
allowToEndTour: true,
description:
"Denne anchoren kan avslutte Coachen ved å trykke på krysset øverst til høyre.",
},
{
id: "step-4",
type: "anchor",
anchorRef: buttonRef,
title: "Ikke lukkbar anchor",
description:
"Denne anchoren har ikke knapp øverst til høyre og kan ikke avslutte turen.",
},
{
id: "step-5",
type: "anchor",
anchorRef: buttonRef,
title: "Siste steg",
description:
"Dette er siste steg i turen og kan dermed alltid avslutte turen.",
},
]}
>
<Coach.Mark>
<Button ref={buttonRef}>Opprett oppgave</Button>
</Coach.Mark>
</Coach>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example);
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 3,
};
@@ -0,0 +1,53 @@
import { useRef, useState } from "react";
import { Button, Coach } from "@navikt/ds-react";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
const opprettRef = useRef<HTMLButtonElement>(null);
const [showTour, setShowTour] = useState(false);
return (
<>
<Button onClick={() => setShowTour(true)} ref={opprettRef}>
Opprett oppgave
</Button>
<Coach
showTour={showTour}
onShowTourChange={setShowTour}
steps={[
{
id: "velkommen",
type: "dialog",
allowToEndTour: true,
title: "Velkommen til oppgavelisten!",
description:
"Her får du oversikt over oppgavene dine. Vi viser deg hvordan du legger til oppgaver og finner dem som gjenstår.",
},
{
id: "opprett",
type: "anchor",
anchorRef: opprettRef,
placement: "bottom-end",
allowToEndTour: true,
title: "Legg til en oppgave",
description:
"Bruk denne knappen når du har noe nytt å gjøre. Etter omvisningen kan du prøve å legge til en oppgave selv.",
},
]}
/>
</>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example, { variant: "full" });
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 7,
desc: "Du kan kontrollere åpen-tilstanden ved å bruke `showTour`- og `onShowTourChange`-props på Coach-komponenten. Dette er nyttig når du må åpne coachen programmatisk, eller triggeren for å starte omvisningen er utenfor Coach-komponenten.",
};
@@ -0,0 +1,98 @@
import { useRef } from "react";
import { Button, Coach, Table, VStack } from "@navikt/ds-react";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
const opprettRef = useRef<HTMLButtonElement>(null);
const statusRef = useRef<HTMLButtonElement>(null);
return (
<VStack gap="space-16">
<Coach
steps={[
{
id: "velkommen",
type: "dialog",
allowToEndTour: true,
title: "Velkommen til oppgavelisten!",
description:
"Her får du oversikt over oppgavene dine. Vi viser deg hvordan du legger til oppgaver og finner dem som gjenstår.",
},
{
id: "opprett",
type: "anchor",
anchorRef: opprettRef,
placement: "bottom-end",
allowToEndTour: true,
title: "Legg til en oppgave",
description:
"Bruk denne knappen når du har noe nytt å gjøre. Etter omvisningen kan du prøve å legge til en oppgave selv.",
},
{
id: "filtrer",
type: "anchor",
anchorRef: statusRef,
placement: "bottom-start",
allowToEndTour: true,
title: "Se status på oppgavene",
description:
"I denne kolonnen kan du se statusen på oppgavene dine.",
},
{
id: "ferdig",
type: "dialog",
allowToEndTour: true,
title: "Nå er du klar!",
description: "Prøv å legge til en oppgave.",
},
]}
>
<Coach.Mark>
<Button ref={opprettRef}>Opprett oppgave</Button>
</Coach.Mark>
</Coach>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell scope="col">Navn</Table.HeaderCell>
<Table.HeaderCell scope="col">Fødselsnr.</Table.HeaderCell>
<Table.HeaderCell scope="col">Status</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row key="1">
<Table.HeaderCell scope="row">Scott, Michael</Table.HeaderCell>
<Table.DataCell>03999265463</Table.DataCell>
<Table.DataCell>
<span ref={statusRef}>Fullført</span>
</Table.DataCell>
</Table.Row>
<Table.Row key="2">
<Table.HeaderCell scope="row">Malone, Kevin</Table.HeaderCell>
<Table.DataCell>02334545234</Table.DataCell>
<Table.DataCell>Pågående</Table.DataCell>
</Table.Row>
<Table.Row key="3">
<Table.HeaderCell scope="row">Schrute, Dwight</Table.HeaderCell>
<Table.DataCell>34324234234</Table.DataCell>
<Table.DataCell>Pågående</Table.DataCell>
</Table.Row>
</Table.Body>
</Table>
</VStack>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example, { variant: "full" });
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 0,
title: "Omvisning i en oppgaveliste",
desc: "En omvisning med dialogsteg, forankrede steg og et egendefinert avslutningssteg. Coach håndterer aktivt steg.",
};
@@ -0,0 +1,50 @@
import { useRef } from "react";
import { Button, Coach } from "@navikt/ds-react";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
const opprettRef = useRef<HTMLButtonElement>(null);
return (
<Coach
disableTour={true}
steps={[
{
id: "velkommen",
type: "dialog",
allowToEndTour: true,
title: "Velkommen til oppgavelisten!",
description:
"Her får du oversikt over oppgavene dine. Vi viser deg hvordan du legger til oppgaver og finner dem som gjenstår.",
},
{
id: "opprett",
type: "anchor",
anchorRef: opprettRef,
placement: "bottom-end",
allowToEndTour: true,
title: "Legg til en oppgave",
description:
"Bruk denne knappen når du har noe nytt å gjøre. Etter omvisningen kan du prøve å legge til en oppgave selv.",
},
]}
>
<Coach.Mark>
<Button ref={opprettRef}>Opprett oppgave</Button>
</Coach.Mark>
</Coach>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example, { variant: "full" });
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 8,
desc: "Når `disableTour` er satt til `true`, vil `Coach.Mark` ikke vises og omvisningen vil være deaktivert.",
};
@@ -0,0 +1,73 @@
import { useRef } from "react";
import { Button, Coach } from "@navikt/ds-react";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
const buttonRef = useRef<HTMLButtonElement>(null);
return (
<Coach
steps={[
{
id: "step-dialog-with-image",
type: "dialog",
title: "Dialog med bilde",
description: "Denne dialogen har et bilde.",
image: (
<img
src="/images/og/blogg/image-1.png"
alt="Alt-tekst for bilde"
width="700"
/>
),
},
{
id: "step-anchor-with-image-and-close",
type: "anchor",
anchorRef: buttonRef,
allowToEndTour: true,
title: "Anchor med bilde",
description:
"Dette anchoret har et bilde, og en lukkeknapp øverst til høyre.",
image: (
<img
src="/images/og/blogg/image-1.png"
alt="Alt-tekst for bilde"
width="700"
/>
),
},
{
id: "step-anchor-with-image",
type: "anchor",
anchorRef: buttonRef,
title: "Anchor med bilde",
description: "Dette anchoret har et bilde.",
image: (
<img
src="/images/og/blogg/image-2.png"
alt="Alt-tekst for bilde"
width="700"
/>
),
},
]}
>
<Coach.Mark>
<Button ref={buttonRef}>Opprett oppgave</Button>
</Coach.Mark>
</Coach>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example);
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 6,
};
@@ -0,0 +1,86 @@
import { useRef } from "react";
import { Button, Coach } from "@navikt/ds-react";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
const buttonRef = useRef<HTMLButtonElement>(null);
return (
<Coach
renderStep={(step, context) => {
if (context.index === context.totalSteps - 1) {
return context.defaultContent;
}
return (
<Coach.Step>
<Coach.Title>{step.title}</Coach.Title>
<Coach.Image>{step.image}</Coach.Image>
<Coach.Progress />
<Coach.Description>{step.description}</Coach.Description>
<Coach.Footer>
{context.index > 0 && (
<Coach.PreviousTrigger>
<Button variant="secondary">Forrige</Button>
</Coach.PreviousTrigger>
)}
{context.index === context.totalSteps - 1 ? (
<Coach.CloseTrigger>
<Button>Lukk</Button>
</Coach.CloseTrigger>
) : (
<Coach.NextTrigger>
<Button data-color="danger">Neste</Button>
</Coach.NextTrigger>
)}
</Coach.Footer>
</Coach.Step>
);
}}
steps={[
{
id: "step-custom-format-dialog",
type: "dialog",
title: "Ulikt format",
description: "Denne dialogen har ulikt format fra standard.",
},
{
id: "step-custom-format-anchor",
type: "anchor",
anchorRef: buttonRef,
image: (
<img
src="/images/og/blogg/image-1.png"
alt="Alt-tekst for bilde"
width="700"
/>
),
title: "Ulikt format",
description: "Denne anchor-steget har ulikt format fra standard.",
},
{
id: "step-standard-format",
type: "anchor",
anchorRef: buttonRef,
title: "Standard format",
description: "Denne anchor-steget har standard format.",
},
]}
>
<Coach.Mark>
<Button ref={buttonRef}>Lorem ipsum</Button>
</Coach.Mark>
</Coach>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example);
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 5,
};
@@ -0,0 +1,32 @@
import { useRef } from "react";
import { Button, Coach } from "@navikt/ds-react";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
const buttonRef = useRef<HTMLButtonElement>(null);
return (
<Coach
type="anchor"
anchorRef={buttonRef}
title="Et stegs coach"
description="Denne coachen har kun ett steg."
>
<Coach.Mark>
<Button ref={buttonRef}>Opprett oppgave</Button>
</Coach.Mark>
</Coach>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example);
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 4,
};
@@ -0,0 +1,73 @@
import { useRef } from "react";
import { Button, Coach, HStack } from "@navikt/ds-react";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
const mediumRef = useRef<HTMLButtonElement>(null);
const smallRef = useRef<HTMLButtonElement>(null);
return (
<HStack gap="space-16">
<Coach
steps={[
{
id: "step-1",
type: "dialog",
title: "Medium størrelse",
description:
"Dette er standard størrelse for dialogen i en Coach med normal padding og tekststørrelse.",
},
{
id: "step-2",
type: "anchor",
anchorRef: mediumRef,
title: "Medium størrelse",
description:
"Dette er standard størrelse for anchor i en Coach med normal padding og tekststørrelse.",
},
]}
>
<Coach.Mark>
<Button ref={mediumRef}>Medium (standard)</Button>
</Coach.Mark>
</Coach>
<Coach
steps={[
{
id: "step-1",
type: "dialog",
size: "small",
title: "Liten størrelse",
description:
"Dette er liten størrelse for dialogen i en Coach med mindre padding og tekststørrelse.",
},
{
id: "step-2",
type: "anchor",
size: "small",
anchorRef: smallRef,
title: "Liten størrelse",
description:
"Dette er liten størrelse for anchor i en Coach med mindre padding og tekststørrelse.",
},
]}
>
<Coach.Mark>
<Button ref={smallRef}>Small</Button>
</Coach.Mark>
</Coach>
</HStack>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example);
/* Storybook story */
export const Demo = {
render: Example,
};
export const args: ExampleArgsT = {
index: 1,
};
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more