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
128 changed files with 4423 additions and 1341 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.
+6
View File
@@ -0,0 +1,6 @@
---
"@navikt/ds-react": patch
"@navikt/aksel-icons": patch
---
ESM: Mark ESM output as side-effect free, so bundlers like webpack, Turbopack and esbuild can tree-shake unused components and icons.
+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
---
Date utilities: Import date-fns functions and locales from their own paths, so unbundled Node imports (SSR, Vitest/Jest) no longer load every date-fns locale.
+5
View File
@@ -0,0 +1,5 @@
---
"@navikt/ds-react": patch
---
DataGrid: Fixed text selection inside details panels when keyboard navigation is enabled.
-5
View File
@@ -1,5 +0,0 @@
---
"@navikt/aksel-icons": minor
---
Icons: All icons are now available as CSS custom properties. Import `@navikt/aksel-icons/css/<IconName>.css` for a single icon (e.g. `--aksel-icon-magnifying-glass`), or `@navikt/aksel-icons/css/index.css` for every icon.
+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
+5 -5
View File
@@ -1,7 +1,7 @@
---
name: Bug Report
about: Meld inn bugs eller feil
title: "[BUG] "
title: "[BUG]"
labels: "bug \U0001F41B"
assignees: ""
---
@@ -21,7 +21,7 @@ assignees: ""
## Steg for å reprodusere problemet (valgfri)
<!--
1. Steg én
1. Steg en
2. Steg to
3. ....
-->
@@ -31,10 +31,10 @@ assignees: ""
## Eksempel/Tilleggsinformasjon (valgfri)
<!--
Screenshots, kodesnippets eller sandbox-lenke.
Legg gjerne ved info som nettleser eller desktop/mobil hvis det er relevant.
Screenshots eller kodesnippets
Legg gjerne ved ekstrainfo som nettleser eller desktop/mobil hvis det er relevant.
-->
```
Hvis mulig, legg gjerne til kode her, ev. lenke til kode i repo.
Hvis mulig, legg gjerne til kode her, evt lenke til kode i repo
```
+2 -2
View File
@@ -1,8 +1,8 @@
---
name: Feature Request
about: Foreslå noe nytt!
title: "[NEW] "
labels: nytt ✨
title: "[NEW]"
labels: new ✨
assignees: ""
---
+1 -1
View File
@@ -1,5 +1,5 @@
name: Eksempel på bruk
description: Send et eksempel på ting fra Aksel som er brukt i det fri (tokens, komponenter, maler, etc.)
description: Send et eksempel på ting fra Aksel som er brukt i det fri (tokens, komponenter, maler, etc)
title: "[Eksempel på bruk]: "
labels: ["dokumentasjon 📖"]
body:
+1 -1
View File
@@ -1,5 +1,5 @@
name: Ny komponent
description: Send et ønske om én eller flere nye komponenter
description: Send et ønske om en eller flere nye komponenter
title: "[Ny komponent]: "
labels: ["nytt ✨", "forespørsel 🥰", "komponenter 🧩"]
body:
+9
View File
@@ -0,0 +1,9 @@
---
name: Bug Report
about: Meld inn bugs eller feil
title: "[BUG]"
labels: "bug \U0001F41B"
assignees: ""
---
## Hva forventet du å finne for dette søket?
+2 -2
View File
@@ -1,5 +1,5 @@
name: Innspill til komponent
description: Send inn tilbakemelding på komponent
description: Send inn innspill til komponent
title: "[Innspill til komponent]: "
labels: ["forespørsel 🥰", "komponenter 🧩", "nytt ✨", "beta 🧪"]
body:
@@ -8,7 +8,7 @@ body:
value: |
Takk for at du bidrar inn i Aksel!
- type: textarea
id: feedback
id: feeback
attributes:
label: Tilbakemelding
description: Beskriv hvilke tilbakemeldinger du har til komponenten
+3 -3
View File
@@ -1,6 +1,6 @@
name: Innspill til ikon
description: Send inn tilbakemelding på ikon
title: "[Innspill til ikon]: "
description: Send inn innspill til ikon
title: "[Tilbakemelding på ikon]: "
labels: ["forespørsel 🥰", "ikoner 🖼️"]
body:
- type: markdown
@@ -8,7 +8,7 @@ body:
value: |
Takk for at du bidrar inn i Aksel!
- type: textarea
id: feedback
id: feeback
attributes:
label: Tilbakemelding
description: Beskriv hvilke tilbakemeldinger du har til ikonet
+4 -4
View File
@@ -21,7 +21,7 @@
## Repo summary
- Nav's design system. Yarn 4 workspaces monorepo: published packages under `@navikt/` + docs site `aksel.nav.no/` + `apps/` + `tooling/`.
- Stack: TypeScript 6, React 19 (dev), Next.js 16, Storybook 10, Vitest 4, Oxlint 1 + ESLint 9, Stylelint 17, Biome 2 (lint only), Prettier 3 (formatting). Node 22+, Yarn 4.18.0.
- Stack: TypeScript 6, React 19 (dev), Next.js 16, Storybook 10, Vitest 4, ESLint 9, Stylelint 17, Biome 2 (lint only), Prettier 3 (formatting). Node 22+, Yarn 4.18.0.
- Published `@navikt/*` packages must stay React 17 compatible (peer `react ^17 || ^18 || ^19`).
## Start and validate
@@ -44,12 +44,12 @@
## Commands
- `corepack yarn oxlint --deny-warnings <files...>` / `corepack yarn eslint --max-warnings=0 --no-warn-ignored <files...>` (both: oxlint runs most rules, ESLint the rest) / `corepack yarn stylelint <files...>` / `corepack yarn biome lint --no-errors-on-unmatched <files...>`
- `corepack yarn eslint --max-warnings=0 --no-warn-ignored <files...>` / `corepack yarn stylelint <files...>` / `corepack yarn biome lint --no-errors-on-unmatched <files...>`
- `corepack yarn tsc --noEmit --incremental false --project <tsconfig>` - targeted type-check
- `corepack yarn workspace <name> <script>` - e.g. `@navikt/ds-react test` (Vitest + tsc), `website test`
- `corepack yarn lint` / `corepack yarn test` / `corepack yarn boot` - root suites (slow; cross-workspace changes only)
- `corepack yarn storybook` (:6006) / `corepack yarn storybook:aksel` (website examples, :6007, no secrets) / `corepack yarn storybook:test` (play tests, Playwright Firefox; Chromium when `COPILOT_CLI` is set) / `corepack yarn dev` (website, :3000, needs Sanity token)
- Targeted play tests: `corepack yarn storybook:test <path/to/X.tests.stories.tsx>` (repo root). Run after changing play tests. Under Copilot CLI this runs Chromium; if the browser can't launch, report tests as not run with the exact command.
- Targeted play tests: `corepack yarn vitest run --config ./vitest.config.ts --project=storybook <path/to/X.tests.stories.tsx>` (repo root). Run after changing play tests. Under Copilot CLI this runs Chromium; if the browser can't launch, report tests as not run with the exact command.
## Where to edit
@@ -63,7 +63,7 @@ Path-specific rules load automatically from `.github/instructions/` (see each `a
- `@navikt/aksel/src/` - CLI and codemods
- `aksel.nav.no/website/` - Next.js docs site · `aksel.nav.no/sanity-studio/` - CMS schema
- `apps/playroom/`, `apps/mcp-server/`, `apps/figma-icon-plugin/` - apps · `tooling/` - ESLint plugin, scripts, changelog, analyzer
- `.changeset/` - release notes · Root config: `.oxlintrc.json`, `eslint.config.js`, `stylelint.config.mjs`, `biome.json`, `vitest.config.mts`, `.storybook/`
- `.changeset/` - release notes · Root config: `eslint.config.js`, `stylelint.config.mjs`, `biome.json`, `vitest.config.ts`, `.storybook/`
## Skills and agents
+2 -2
View File
@@ -6,7 +6,7 @@ applyTo: "**/*.test.ts,**/*.test.tsx,**/*.stories.tsx"
# Tests and stories
- User flows: use a story `play` function in `<Component>.tests.stories.tsx`. Pure logic and hooks: use Vitest `*.test.ts(x)` next to the source file.
- `corepack yarn storybook:test` runs every story that has a `play` function (Storybook auto-tags them `play-fn`, see root `vitest.config.mts`). To opt out, use `tags: ["skip-test"]` with a reason.
- `corepack yarn storybook:test` runs every story that has a `play` function (Storybook auto-tags them `play-fn`, see root `vitest.config.ts`). To opt out, use `tags: ["!play-fn", "skip-test"]` with a reason.
- Play-test files set `parameters: { chromatic: { disable: true } }`. Visual stories feed Chromatic; use `renderStoriesForChromatic` from `utils/` when adding visual variants.
- Import from `@storybook/react-vite` and `storybook/test`, not `@storybook/react` or `@storybook/test`.
- Query by role and accessible name (`getByRole("button", { name })`). Avoid snapshots unless nearby tests already use them.
@@ -17,4 +17,4 @@ applyTo: "**/*.test.ts,**/*.test.tsx,**/*.stories.tsx"
- Before removing or consolidating tests, map each meaningful old scenario/assertion to its replacement story or test. Keep coverage that still represents a supported behavior; explain when a scenario is intentionally obsolete.
- For state, accessibility, keyboard or focus behavior, use the [component-contract skill](../skills/component-contract/SKILL.md) to identify the contract and evidence to preserve.
- ds-react unit tests run with `TZ=UTC`. Date logic must not depend on the local timezone.
- Browser tests need Playwright Firefox (Chromium under Copilot CLI, see `vitest.config.mts`). Yarn has `enableScripts: false`, so if the browser is missing, report it instead of installing globally.
- Browser tests need Playwright Firefox (Chromium under Copilot CLI, see `vitest.config.ts`). Yarn has `enableScripts: false`, so if the browser is missing, report it instead of installing globally.
@@ -57,7 +57,6 @@ Read the current manifest before running a table command. A manifest is source o
Group only matching files:
```sh
corepack yarn oxlint --deny-warnings <changed .ts/.tsx/.js/.jsx files>
corepack yarn eslint --max-warnings=0 --no-warn-ignored <changed .ts/.tsx/.js/.jsx files>
corepack yarn stylelint <changed .css files>
corepack yarn biome lint --no-errors-on-unmatched <changed supported source/config files>
-1
View File
@@ -54,7 +54,6 @@ Hard rules (enforced by `tooling/eslint` rules `aksel-local/*`):
## 3. Validate
```sh
corepack yarn oxlint --deny-warnings <file>
corepack yarn eslint --max-warnings=0 --no-warn-ignored <file>
corepack yarn workspace website validate:eksempler
corepack yarn storybook:aksel # visual check at :6007, needs no secrets
+1 -1
View File
@@ -17,7 +17,7 @@ permissions:
jobs:
lint:
name: Lint (Oxlint, ESLint, Stylelint etc.)
name: Lint (ESLint, Stylelint etc.)
runs-on: ubuntu-latest
steps:
- name: Checkout code
-467
View File
@@ -1,467 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "import"],
"categories": {
"correctness": "off"
},
"settings": {
"react": {
"version": "19.3.1"
}
},
"env": {
"builtin": true,
"es2026": true,
"browser": true,
"node": true
},
"ignorePatterns": [
"**/public",
"@navikt/aksel/**/*.input.*",
"@navikt/aksel/**/*.output.*",
"**/codemod/**/*.js",
"**/tokens/**/plugin.js",
"**/query-types.ts",
".yarn",
"**/next-env.d.ts",
"@navikt/aksel-icons/src/**",
"@navikt/aksel/src/version.ts",
"@navikt/core/tailwind/{src/tokens.ts,tailwind.config.js,tailwind.darkside.config.js}",
"@navikt/core/tokens/token_docs.js",
"apps/playroom/{playroom.config.js,src/auto-generated-imports.ts}",
"**/.build",
"**/storybook-static",
"**/test-results",
"tooling/analyzer/temp",
"apps/figma-icon-plugin/src/ui/vite-env.d.ts"
],
"rules": {
"constructor-super": "error",
"for-direction": "error",
"getter-return": "error",
"no-async-promise-executor": "error",
"no-case-declarations": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "error",
"no-const-assign": "error",
"no-constant-binary-expression": "error",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-dupe-class-members": "error",
"no-dupe-else-if": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
"no-global-assign": "error",
"no-import-assign": "error",
"no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-misleading-character-class": "error",
"no-new-native-nonconstructor": "error",
"no-nonoctal-decimal-escape": "error",
"no-obj-calls": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-self-assign": "error",
"no-setter-return": "error",
"no-shadow-restricted-names": "error",
"no-sparse-arrays": "error",
"no-this-before-super": "error",
"no-unassigned-vars": "error",
"no-unexpected-multiline": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unsafe-optional-chaining": "error",
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"no-unused-vars": "error",
"no-useless-backreference": "error",
"no-useless-catch": "error",
"no-useless-escape": "error",
"no-with": "error",
"preserve-caught-error": "error",
"require-yield": "error",
"use-isnan": "error",
"valid-typeof": "error",
"react/jsx-key": "error",
"react/jsx-no-comment-textnodes": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-target-blank": "error",
"react/jsx-no-undef": "error",
"react/no-children-prop": "error",
"react/no-danger-with-children": "error",
"react/no-direct-mutation-state": "error",
"react/no-find-dom-node": "error",
"react/no-is-mounted": "error",
"react/no-render-return-value": "error",
"react/no-string-refs": "error",
"react/no-unescaped-entities": "error",
"react/no-unknown-property": "error",
"react/react-in-jsx-scope": "error",
"import/default": "error",
"import/no-named-as-default-member": "warn",
"import/no-duplicates": "warn",
"react/jsx-curly-brace-presence": [
"error",
{
"propElementValues": "always"
}
],
"react/rules-of-hooks": "error",
"react/exhaustive-deps": [
"warn",
{
"additionalHooks": "(useClientLayoutEffect)"
}
]
},
"overrides": [
{
"files": ["aksel.nav.no/sanity-studio/**"],
"rules": {
"react/react-in-jsx-scope": "off"
}
},
{
"files": [
"**/*.stories.{ts,tsx,js,jsx,mjs,cjs}",
"**/*.story.{ts,tsx,js,jsx,mjs,cjs}"
],
"rules": {
"storybook/await-interactions": "error",
"storybook/context-in-play-function": "error",
"storybook/default-exports": "error",
"storybook/hierarchy-separator": "warn",
"storybook/no-redundant-story-name": "warn",
"storybook/no-renderer-packages": "error",
"storybook/prefer-pascal-case": "warn",
"storybook/story-exports": "error",
"storybook/use-storybook-expect": "error",
"storybook/use-storybook-testing-library": "error",
"import/no-anonymous-default-export": "off",
"react/rules-of-hooks": "off"
},
"jsPlugins": ["eslint-plugin-storybook"]
},
{
"files": ["**/.storybook/main.{js,cjs,mjs,ts}"],
"rules": {
"storybook/no-uninstalled-addons": "error"
},
"jsPlugins": ["eslint-plugin-storybook"]
},
{
"files": ["**/*.{ts,tsx}"],
"rules": {
"constructor-super": "off",
"getter-return": "off",
"no-class-assign": "off",
"no-const-assign": "off",
"no-dupe-class-members": "off",
"no-dupe-keys": "off",
"no-func-assign": "off",
"no-import-assign": "off",
"no-new-native-nonconstructor": "off",
"no-obj-calls": "off",
"no-redeclare": "off",
"no-setter-return": "off",
"no-this-before-super": "off",
"no-unreachable": "off",
"no-unsafe-negation": "off",
"no-with": "off",
"no-var": "error",
"prefer-const": "error",
"prefer-rest-params": "error",
"prefer-spread": "error",
"no-array-constructor": "error",
"no-unused-expressions": [
"error",
{
"allowShortCircuit": true,
"allowTernary": true
}
],
"typescript/ban-ts-comment": "error",
"typescript/no-duplicate-enum-values": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-explicit-any": "off",
"typescript/no-extra-non-null-assertion": "error",
"typescript/no-misused-new": "error",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "error",
"typescript/no-require-imports": "error",
"typescript/no-this-alias": "error",
"typescript/no-unnecessary-type-constraint": "error",
"typescript/no-unsafe-declaration-merging": "error",
"typescript/no-unsafe-function-type": "error",
"typescript/no-wrapper-object-types": "error",
"typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error",
"typescript/triple-slash-reference": "error",
"import/default": "off",
"import/no-named-as-default-member": "off"
},
"plugins": ["typescript"]
},
{
"files": ["**/*.test.*", "**/__tests__/*"],
"rules": {
"testing-library/await-async-events": [
"error",
{
"eventModule": "userEvent"
}
],
"testing-library/await-async-queries": "error",
"testing-library/await-async-utils": "error",
"testing-library/no-await-sync-events": [
"error",
{
"eventModules": ["fire-event"]
}
],
"testing-library/no-await-sync-queries": "error",
"testing-library/no-container": "error",
"testing-library/no-debugging-utils": "warn",
"testing-library/no-dom-import": ["error", "react"],
"testing-library/no-global-regexp-flag-in-query": "error",
"testing-library/no-manual-cleanup": "error",
"testing-library/no-node-access": "error",
"testing-library/no-promise-in-fire-event": "error",
"testing-library/no-render-in-lifecycle": "error",
"testing-library/no-unnecessary-act": "error",
"testing-library/no-wait-for-multiple-assertions": "error",
"testing-library/no-wait-for-side-effects": "error",
"testing-library/no-wait-for-snapshot": "error",
"testing-library/prefer-find-by": "error",
"testing-library/prefer-presence-queries": "error",
"testing-library/prefer-query-by-disappearance": "error",
"testing-library/prefer-screen-queries": "error",
"testing-library/render-result-naming-convention": "error",
"vitest/expect-expect": "error",
"vitest/no-commented-out-tests": "error",
"vitest/no-disabled-tests": "warn",
"vitest/no-focused-tests": "error",
"vitest/no-identical-title": "error",
"vitest/no-import-node-test": "error",
"vitest/no-interpolation-in-snapshots": "error",
"vitest/no-mocks-import": "error",
"vitest/no-standalone-expect": "error",
"vitest/no-unneeded-async-expect-function": "error",
"vitest/prefer-called-exactly-once-with": "error",
"vitest/require-local-test-context-for-concurrent-snapshots": "error",
"vitest/valid-describe-callback": "error",
"vitest/valid-expect": "error",
"vitest/valid-expect-in-promise": "error",
"vitest/valid-title": "error"
},
"jsPlugins": ["eslint-plugin-testing-library"],
"plugins": ["vitest"]
},
{
"files": ["aksel.nav.no/website/**"],
"rules": {
"react/no-unknown-property": [
"error",
{
"ignore": ["jsx", "global"]
}
],
"react/react-in-jsx-scope": "off",
"nextjs/google-font-display": "warn",
"nextjs/google-font-preconnect": "warn",
"nextjs/next-script-for-ga": "warn",
"nextjs/no-async-client-component": "warn",
"nextjs/no-before-interactive-script-outside-document": "warn",
"nextjs/no-css-tags": "warn",
"nextjs/no-head-element": "warn",
"nextjs/no-img-element": "warn",
"nextjs/no-page-custom-font": "warn",
"nextjs/no-styled-jsx-in-document": "warn",
"nextjs/no-sync-scripts": "warn",
"nextjs/no-title-in-document-head": "warn",
"nextjs/no-typos": "warn",
"nextjs/no-unwanted-polyfillio": "warn",
"nextjs/inline-script-id": "error",
"nextjs/no-assign-module-variable": "error",
"nextjs/no-document-import-in-page": "error",
"nextjs/no-duplicate-head": "error",
"nextjs/no-head-import-in-document": "error",
"nextjs/no-script-component-in-head": "error"
},
"plugins": ["nextjs"]
},
{
"files": ["aksel.nav.no/website/pages/eksempler/**"],
"rules": {
"nextjs/no-img-element": "off"
},
"plugins": ["nextjs"]
},
{
"files": [
"aksel.nav.no/website/pages/eksempler/**/*.tsx",
"aksel.nav.no/website/pages/templates/**/*.tsx"
],
"rules": {
"aksel-local/args-check": ["error"],
"aksel-local/comment-check": ["error"]
},
"jsPlugins": ["eslint-plugin-aksel-local"]
},
{
"files": [
"**/examples/__parts*/*.tsx",
"**/pages/templates/**/*.tsx",
"**/pages/eksempler/**/*.tsx"
],
"rules": {
"aksel-local/import-check": ["error"]
},
"jsPlugins": ["eslint-plugin-aksel-local"]
},
{
"files": ["**/examples/__parts-inline/*.tsx"],
"rules": {
"arrow-body-style": ["error", "never"],
"func-style": ["error", "expression"],
"import/no-named-export": "error"
}
},
{
"files": ["@navikt/**"],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "react",
"importNames": [
"useId",
"useInsertionEffect",
"useSyncExternalStore",
"useDeferredValue",
"useTransition",
"startTransition"
],
"message": "React 18+ API not allowed. We currently only support React-features accesible in React 17. To add new features, we will need to update peerDependencies (breaking change)."
},
{
"name": "react",
"importNames": [
"useOptimistic",
"useActionState",
"use",
"cache",
"useEffectEvent",
"ViewTransition",
"addTransitionType"
],
"message": "React 19+ API not allowed. We currently only support React-features accesible in React 17. To add new features, we will need to update peerDependencies (breaking change)."
},
{
"name": "react-dom/client",
"importNames": ["createRoot", "hydrateRoot"],
"message": "React 18+ API not allowed (targeting React 17)."
},
{
"name": "react-dom",
"importNames": ["browser"],
"message": "React 19+ API not allowed (targeting React 17)."
}
],
"patterns": [
{
"group": ["fs", "path"],
"message": "Use `node:` prefix when importing native node modules, e.g. `import path from \"node:path\"`."
},
{
"group": ["**/esm/**", "**/cjs/**"],
"message": "Do not import from build output directories. Import from source instead."
}
]
}
],
"no-restricted-properties": [
"error",
{
"object": "React",
"property": "useId",
"message": "React 18+ API not allowed (targeting React 17)."
},
{
"object": "React",
"property": "useInsertionEffect",
"message": "React 18+ API not allowed (targeting React 17)."
},
{
"object": "React",
"property": "useSyncExternalStore",
"message": "React 18+ API not allowed (targeting React 17)."
},
{
"object": "React",
"property": "useDeferredValue",
"message": "React 18+ API not allowed (targeting React 17)."
},
{
"object": "React",
"property": "useTransition",
"message": "React 18+ API not allowed (targeting React 17)."
},
{
"object": "React",
"property": "startTransition",
"message": "React 18+ API not allowed (targeting React 17)."
},
{
"object": "React",
"property": "useOptimistic",
"message": "React 19+ API not allowed (targeting React 17)."
},
{
"object": "React",
"property": "useActionState",
"message": "React 19+ API not allowed (targeting React 17)."
},
{
"object": "React",
"property": "use",
"message": "React 19+ API not allowed (targeting React 17)."
},
{
"object": "React",
"property": "cache",
"message": "React 19+ API not allowed (targeting React 17)."
},
{
"object": "React",
"property": "useEffectEvent",
"message": "React 19+ API not allowed (targeting React 17)."
}
]
}
},
{
"files": ["@navikt/**/*.stories.tsx"],
"rules": {
"no-restricted-imports": "off",
"no-restricted-properties": "off"
}
}
]
}
-1
View File
@@ -1,6 +1,5 @@
{
"recommendations": [
"oxc.oxc-vscode",
"dbaeumer.vscode-eslint",
"stylelint.vscode-stylelint",
"biomejs.biome",
-12
View File
@@ -1,17 +1,5 @@
# @navikt/aksel-icons
## 8.22.1
## 8.22.0
### Minor Changes
- Icons: New icons `Gift`, `ArrowsPointingIn` and `ArrowsPointingOut`. ([#5484](https://github.com/navikt/aksel/pull/5484))
### Patch Changes
- ESM: Mark ESM output as side-effect free, so bundlers like webpack, Turbopack and esbuild can tree-shake unused components and icons. ([#5472](https://github.com/navikt/aksel/pull/5472))
## 8.21.0
### Minor Changes
+2 -2
View File
@@ -2,11 +2,11 @@
## Foreslå nye ikoner
Om du ikke finner ikonet du leter etter kan du [lage et issue på GitHub](https://github.com/navikt/aksel/issues/new?template=new-icon.yaml).
Om du ikke finner ikonet du leter etter kan du [lage et issue på GitHub](https://github.com/navikt/aksel/issues/new?labels=nytt+✨%2Cikoner+🖼%2Cforespørsel+🥰&template=new-icon.yaml&title=%5BNytt+ikon%5D%3A+).
## Oppdatere ikon
Hvis du tenker et ikon fortjener en oppdatering eller er utdatert kan [du foreslå det her](https://github.com/navikt/aksel/issues/new?template=update-icon.yml).
Hvis du tenker et ikon fortjener en oppdatering eller er utdatert kan [du foreslå det her](https://github.com/navikt/aksel/issues/new?labels=forespørsel+🥰&template=update-icon.yml&title=%5BInnspill+til+ikon%5D%3A+).
## Design av nye ikoner
-31
View File
@@ -59,37 +59,6 @@ declare module "*.svg" {
}
```
### CSS custom properties
All icons are available as CSS custom properties behind `/css`, one file per icon. Each file declares a `--aksel-icon-<kebab-case-name>` variable on `:root`, containing the icon as an SVG data URI.
```css
@import "@navikt/aksel-icons/css/MagnifyingGlass.css";
.search-button::before {
content: "";
display: inline-block;
width: 1.5rem;
height: 1.5rem;
background-color: currentColor;
mask: var(--aksel-icon-magnifying-glass) center / contain no-repeat;
}
@media (forced-colors: active) {
.search-button::before {
background-color: CanvasText;
}
}
```
To get every icon, import `@navikt/aksel-icons/css/index.css`. This file contains all ~1000 icons (≈700 kB, ≈135 kB gzipped), so prefer importing only the icons you use.
Import icon files once in a global stylesheet (e.g. your root layout or `globals.css`), not in CSS modules or per-component stylesheets. The variables are global, and the SVG is inlined in each file, so importing the same icon in several places duplicates it in every CSS chunk.
- Icons added with CSS are decorative only. Give the parent element an accessible name, e.g. with visible text or `aria-label`.
- In forced colors mode (e.g. Windows High Contrast), `background-color` is forced to the page background, which hides the icon. Use `background-color: CanvasText` inside `@media (forced-colors: active)` as shown above, so the icon follows the user's text color.
- If you use a Content Security Policy, `img-src` must allow `data:`.
## [Learn more](https://aksel.nav.no/ikoner)
## License
@@ -1,44 +0,0 @@
import fastglob from "fast-glob";
import path from "node:path";
import { describe, expect, test } from "vitest";
import {
optimizeSvg,
svgToDataUri,
toCssVariableName,
// @ts-expect-error -- css-icons is JavaScript without a declaration file.
} from "../config/css-icons";
describe("toCssVariableName", () => {
test.each([
["MagnifyingGlass", "--aksel-icon-magnifying-glass"],
["Buildings2Fill", "--aksel-icon-buildings-2-fill"],
["ClipboardXMark", "--aksel-icon-clipboard-x-mark"],
["XMark", "--aksel-icon-x-mark"],
])("%s -> %s", (iconName, expected) => {
expect(toCssVariableName(iconName)).toBe(expected);
});
test("is unique for every icon", () => {
const names = fastglob
.sync("*.svg", { cwd: path.resolve(__dirname, "../icons") })
.map((file) => toCssVariableName(path.basename(file, ".svg")));
expect(new Set(names).size).toBe(names.length);
});
});
describe("svgToDataUri", () => {
const svg = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.5 3.25 4.75 10.5Z" fill="#202733"/>
</svg>`;
test("keeps viewBox, uses currentColor and escapes unsafe characters", () => {
const uri = svgToDataUri(optimizeSvg(svg));
const data = uri.slice('url("'.length, -'")'.length);
expect(uri.startsWith('url("data:image/svg+xml,')).toBe(true);
expect(data).toContain("viewBox='0 0 24 24'");
expect(data).toContain("fill='currentColor'");
expect(data).not.toMatch(/["#<>]|width=|height=/);
});
});
-95
View File
@@ -1,95 +0,0 @@
const fastglob = require("fast-glob");
const kebabCase = require("lodash/kebabCase");
const path = require("node:path");
const {
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} = require("node:fs");
const { optimize } = require("svgo");
/**
* @param {string} iconName e.g. "MagnifyingGlass"
* @returns {string} e.g. "--aksel-icon-magnifying-glass"
*/
function toCssVariableName(iconName) {
return `--aksel-icon-${kebabCase(iconName)}`;
}
/**
* @param {string} svgString
* @returns {string} Minified SVG with currentColor fill and preserved viewBox
*/
function optimizeSvg(svgString) {
return optimize(svgString.replaceAll("#202733", "currentColor"), {
multipass: true,
plugins: [
{
name: "preset-default",
params: { overrides: { removeViewBox: false } },
},
{ name: "removeAttrs", params: { attrs: "svg:(width|height)" } },
],
}).data;
}
/**
* Encodes SVG for use in a CSS `url()`. Keeps readable characters unescaped for smaller output.
* @param {string} svgString
* @returns {string}
*/
function svgToDataUri(svgString) {
const encoded = svgString
.replaceAll('"', "'")
.replace(/\s+/g, " ")
.trim()
.replace(/[^\x20-\x7e]|[%#<>{}"\\]/g, encodeURIComponent);
return `url("data:image/svg+xml,${encoded}")`;
}
/**
* @param {string[]} declarations
* @returns {string}
*/
function createCssFile(declarations) {
return `:root {\n${declarations.map((d) => ` ${d}`).join("\n")}\n}\n`;
}
function main() {
const basePath = path.resolve(__dirname, "../icons");
const cssFolder = path.resolve(__dirname, "../dist/css");
const svgList = fastglob.sync("*.svg", { cwd: basePath }).sort();
if (existsSync(cssFolder)) {
rmSync(cssFolder, { recursive: true, force: true });
}
mkdirSync(cssFolder, { recursive: true });
const declarations = [];
for (const svg of svgList) {
const iconName = path.basename(svg, ".svg");
const svgString = readFileSync(path.join(basePath, svg)).toString();
const declaration = `${toCssVariableName(iconName)}: ${svgToDataUri(optimizeSvg(svgString))};`;
declarations.push(declaration);
writeFileSync(
path.join(cssFolder, `${iconName}.css`),
createCssFile([declaration]),
);
}
writeFileSync(path.join(cssFolder, "index.css"), createCssFile(declarations));
}
module.exports = {
createCssFile,
main,
optimizeSvg,
svgToDataUri,
toCssVariableName,
};
-79
View File
@@ -2,12 +2,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import React from "react";
import * as AkselIcons from "@navikt/aksel-icons";
import type { StarFillIcon } from "@navikt/aksel-icons";
import "@navikt/aksel-icons/css/Airplane.css";
import "@navikt/aksel-icons/css/Buildings2Fill.css";
import "@navikt/aksel-icons/css/MagnifyingGlass.css";
import "@navikt/aksel-icons/css/StarFill.css";
import "@navikt/aksel-icons/css/XMarkOctagonFill.css";
import StarIcon from "@navikt/aksel-icons/svg/Star.svg?no-inline";
const meta: Meta<typeof StarFillIcon> = {
title: "aksel-icons/Icons",
@@ -31,76 +25,3 @@ export const IconsInverted: Story = {
render: Icons.render,
globals: { theme: "dark" },
};
const cssIconVariables = [
"--aksel-icon-airplane",
"--aksel-icon-buildings-2-fill",
"--aksel-icon-magnifying-glass",
"--aksel-icon-star-fill",
"--aksel-icon-x-mark-octagon-fill",
];
export const CssIcons: Story = {
render: () => (
<div
style={{ display: "flex", gap: "1rem", color: "var(--ax-text-accent)" }}
>
<style>
{`.css-icon {
display: inline-block;
width: 3rem;
height: 3rem;
background-color: currentColor;
print-color-adjust: exact;
}
@media (forced-colors: active) {
.css-icon { background-color: CanvasText; }
}`}
</style>
{cssIconVariables.map((variable) => (
<span
key={variable}
title={variable}
className="css-icon"
style={{ mask: `var(${variable}) center / contain no-repeat` }}
/>
))}
</div>
),
};
export const CssIconsInverted: Story = {
render: CssIcons.render,
globals: { theme: "dark" },
};
export const InlineSvgIcon: Story = {
render: () => (
<div style={{ color: "var(--ax-text-accent)" }}>
<svg aria-hidden width="3rem" height="3rem">
<use
href={`${StarIcon}`}
width="100%"
height="100%"
color="currentColor"
/>
</svg>
</div>
),
};
export const InlineSvgIconInverted: Story = {
render: () => (
<div style={{ color: "var(--ax-text-accent)" }}>
<svg aria-hidden width="3rem" height="3rem">
<use
href={`${StarIcon}`}
width="100%"
height="100%"
color="currentColor"
/>
</svg>
</div>
),
globals: { theme: "dark" },
};
+3 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-icons",
"version": "8.22.1",
"version": "8.21.0",
"author": "Aksel | Nav",
"homepage": "https://aksel.nav.no/ikoner",
"repository": {
@@ -23,9 +23,7 @@
"access": "public",
"provenance": true
},
"sideEffects": [
"*.css"
],
"sideEffects": false,
"license": "MIT",
"main": "./dist/react/cjs/index.js",
"module": "./dist/react/esm/index.js",
@@ -46,7 +44,6 @@
"default": "./dist/metadata.js"
},
"./svg/*.svg": "./dist/svg/*.svg",
"./css/*.css": "./dist/css/*.css",
"./package.json": "./package.json"
},
"typesVersions": {
@@ -65,11 +62,10 @@
"create-icons": "svgr --no-prettier --silent --index-template config/index-template.js --out-dir src icons && yarn prettify-icons",
"prettify-icons": "yarn prettier ./src --write --ignore-path nonexisting-path --log-level silent --no-config",
"parse-svg": "node -e 'require(\"./config/parse-svg.js\").main()'",
"css-icons": "node -e 'require(\"./config/css-icons.js\").main()'",
"copy": "copyfiles util/* src",
"update-metadata": "node config/metadata.js",
"write-packagejson": "echo '{\"type\": \"module\", \"sideEffects\": false}' > dist/react/esm/package.json",
"build": "yarn copy && yarn create-icons && concurrently \"tsc\" \"tsc -p tsconfig.esm.json && tsc-alias -p tsconfig.esm.json && yarn write-packagejson\" && node config/cleanTypes.js && yarn update-metadata && yarn parse-svg && yarn css-icons",
"build": "yarn copy && yarn create-icons && concurrently \"tsc\" \"tsc -p tsconfig.esm.json && tsc-alias -p tsconfig.esm.json && yarn write-packagejson\" && node config/cleanTypes.js && yarn update-metadata && yarn parse-svg",
"fetch-new:icons": "tsx --env-file=.env config/figma/index.ts",
"zip:icons": "node config/zip.js",
"test": "vitest run",
@@ -91,7 +87,6 @@
"prettier": "3.9.6",
"react": "19.3.1",
"rehype-parse": "9.0.1",
"svgo": "3.3.5",
"tsc-alias": "1.9.7",
"tsx": "^4.23.15",
"typescript": "6.0.3",
File renamed without changes.
-4
View File
@@ -1,9 +1,5 @@
# @navikt/aksel-stylelint
## 8.22.1
## 8.22.0
## 8.21.0
## 8.20.1
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-stylelint",
"version": "8.22.1",
"version": "8.21.0",
"type": "module",
"engines": {
"node": ">=18.12.0"
@@ -43,8 +43,8 @@
"dev": "yarn watch:lint"
},
"devDependencies": {
"@navikt/ds-css": "^8.22.1",
"@navikt/ds-tokens": "^8.22.1",
"@navikt/ds-css": "^8.21.0",
"@navikt/ds-tokens": "^8.21.0",
"concurrently": "10.0.6",
"postcss-selector-parser": "^7.1.7",
"postcss-value-parser": "^4.2.0",
-4
View File
@@ -1,9 +1,5 @@
# @navikt/aksel
## 8.22.1
## 8.22.0
## 8.21.0
## 8.20.1
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel",
"version": "8.22.1",
"version": "8.21.0",
"description": "Aksel command line interface. Codemods and other utilities for Aksel users.",
"author": "Aksel | Nav designsystem team",
"license": "MIT",
File renamed without changes.
-8
View File
@@ -1,13 +1,5 @@
# @navikt/ds-css
## 8.22.1
### Patch Changes
- DataGrid: Column dividers in the table header are now hidden when column dividers are disabled, and only shown on hover, focus or while resizing. ([#5505](https://github.com/navikt/aksel/pull/5505))
## 8.22.0
## 8.21.0
### Minor Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-css",
"version": "8.22.1",
"version": "8.21.0",
"description": "CSS for Nav Designsystem",
"author": "Aksel | Nav designsystem team",
"keywords": [
@@ -32,7 +32,7 @@
"css:get-version": "node config/get-version.js"
},
"devDependencies": {
"@navikt/ds-tokens": "^8.22.1",
"@navikt/ds-tokens": "^8.21.0",
"browserslist": "^4.28.7",
"esbuild": "^0.28.2",
"fast-glob": "3.3.3",
+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 -10
View File
@@ -208,20 +208,11 @@
padding: 0;
font-size: var(--__axc-data-table-text-size);
.aksel-data-table__thead &,
.aksel-data-table[data-column-dividers="true"] & {
box-shadow: inset 1px 1px 0 0 var(--ax-border-neutral-subtle);
}
.aksel-data-table[data-column-dividers="false"] & {
transition: box-shadow 0.1s ease-in-out;
}
.aksel-data-table[data-column-dividers="false"]
.aksel-data-table__thead:where(:hover, :has(:focus-visible, .aksel-data-table__th-resize-handle:active))
& {
box-shadow: inset 1px 1px 0 0 var(--ax-border-neutral-subtle);
}
&:not(.aksel-data-table__column-header, [data-sticky]) {
overflow: hidden; /* Needed for cases where left margin is added to cell-content (nested rows) */
}
+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);
-30
View File
@@ -1,35 +1,5 @@
# @navikt/ds-react
## 8.22.1
### Patch Changes
- Updated dependencies []:
- @navikt/ds-tokens@8.22.1
- @navikt/aksel-icons@8.22.1
## 8.22.0
### Patch Changes
- Combobox: The list is no longer focusable ([#5490](https://github.com/navikt/aksel/pull/5490))
- Popover: Closed popovers no longer calculate their position on mount. This makes pages with many Dropdowns or HelpTexts render faster. ([#5474](https://github.com/navikt/aksel/pull/5474))
- ESM: Mark ESM output as side-effect free, so bundlers like webpack, Turbopack and esbuild can tree-shake unused components and icons. ([#5472](https://github.com/navikt/aksel/pull/5472))
- MonthPicker: Fixed issue where navigating to a year without any enabled months could lead to re-render loop in React ([#5503](https://github.com/navikt/aksel/pull/5503))
- MonthPicker: Arrow-key navigation no longer freezes the page when no later or earlier month is enabled. Keyboard search now respects `fromDate`/`toDate` with both caption types and looks at most 100 years ahead or back. ([#5477](https://github.com/navikt/aksel/pull/5477))
- Date utilities: Import date-fns functions and locales from their own paths, so unbundled Node imports (SSR, Vitest/Jest) no longer load every date-fns locale. ([#5476](https://github.com/navikt/aksel/pull/5476))
- DataGrid: Fixed text selection inside details panels when keyboard navigation is enabled. ([`0bdad46`](https://github.com/navikt/aksel/commit/0bdad462664e04abd89c4121de515f6ae6d9aad1))
- Updated dependencies [[`16da3ce`](https://github.com/navikt/aksel/commit/16da3ce770041acc41589fcdd087717fab027126), [`b69c5fc`](https://github.com/navikt/aksel/commit/b69c5fca305f28ad99b48dd74e4f736fe4257d48)]:
- @navikt/aksel-icons@8.22.0
- @navikt/ds-tokens@8.22.0
## 8.21.0
### Minor Changes
+16 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-react",
"version": "8.22.1",
"version": "8.21.0",
"description": "React components from the Norwegian Labour and Welfare Administration.",
"author": "Aksel, a team part of the Norwegian Labour and Welfare Administration.",
"license": "MIT",
@@ -169,6 +169,16 @@
"default": "./cjs/chat/index.js"
}
},
"./Coach": {
"import": {
"types": "./esm/coach/index.d.ts",
"default": "./esm/coach/index.js"
},
"require": {
"types": "./cjs/coach/index.d.ts",
"default": "./cjs/coach/index.js"
}
},
"./Chips": {
"import": {
"types": "./esm/chips/index.d.ts",
@@ -779,17 +789,17 @@
"clean": "rimraf cjs esm",
"build": "yarn i18n-jsdoc && yarn metadata:generate-names && concurrently \"tsc -p tsconfig.build.json --module commonjs --outDir cjs --tsBuildInfoFile cjs/.tsbuildinfo\" \"tsc -p tsconfig.build.json --module esnext --outDir esm --tsBuildInfoFile esm/.tsbuildinfo && tsc-alias -p tsconfig.build.json --dir esm -f && yarn write-packagejson\" && yarn i18n-jsdoc --cleanup && yarn copy-types",
"watch": "tsc --watch -p tsconfig.build.json --module esnext --outDir esm --tsBuildInfoFile esm/.tsbuildinfo",
"test": "yarn metadata:generate-names && concurrently \"cross-env TZ=UTC vitest run -c tests/vitest.config.mts\" \"yarn test-sb-build\"",
"test:bench": "cross-env TZ=UTC vitest bench -c tests/vitest.config.mts",
"test": "yarn metadata:generate-names && concurrently \"cross-env TZ=UTC vitest run -c tests/vitest.config.ts\" \"yarn test-sb-build\"",
"test:bench": "cross-env TZ=UTC vitest bench -c tests/vitest.config.ts",
"test-sb-build": "tsc -p tsconfig.json",
"test:watch": "TZ=UTC vitest watch -c tests/vitest.config.mts",
"test:watch": "TZ=UTC vitest watch -c tests/vitest.config.ts",
"copy-types": "copyfiles -f ./src/types/theme.d.ts ./esm/types"
},
"dependencies": {
"@floating-ui/react": "0.27.20",
"@floating-ui/react-dom": "^2.1.9",
"@navikt/aksel-icons": "^8.22.1",
"@navikt/ds-tokens": "^8.22.1",
"@navikt/aksel-icons": "^8.21.0",
"@navikt/ds-tokens": "^8.21.0",
"date-fns": "^4.0.0",
"react-day-picker": "10.0.2"
},
@@ -0,0 +1,33 @@
import type { ComponentMetadata } from "../utils/types/metadata";
import { Coach } from "./index";
const metadata: ComponentMetadata = {
name: "Coach",
components: {
Coach,
"Coach.Step": Coach.Step,
"Coach.Mark": Coach.Mark,
"Coach.Progress": Coach.Progress,
"Coach.Title": Coach.Title,
"Coach.Description": Coach.Description,
"Coach.Footer": Coach.Footer,
"Coach.NextTrigger": Coach.NextTrigger,
"Coach.PreviousTrigger": Coach.PreviousTrigger,
"Coach.CloseTrigger": Coach.CloseTrigger,
"Coach.Image": Coach.Image,
},
keywords: [
"coach",
"dialog",
"popup",
"step",
"guide",
"tutorial",
"walkthrough",
"onboarding",
"tour",
"coachmark",
],
};
export { metadata };
@@ -0,0 +1,552 @@
import type { Meta, StoryFn, StoryObj } from "@storybook/react-vite";
import React, { useRef, useState } from "react";
import {
EnvelopeClosedIcon,
EnvelopeOpenIcon,
PaperplaneIcon,
} from "@navikt/aksel-icons";
import { Button } from "../button";
import { Chips } from "../chips";
import { ExpansionCard } from "../expansion-card";
import { Search } from "../form/search";
import { GuidePanel } from "../guide-panel";
import { InfoCard } from "../info-card";
import { HStack, VStack } from "../primitives/stack";
import { Provider } from "../provider";
import { ToggleGroup } from "../toggle-group";
import { BodyShort } from "../typography";
import { en } from "../utils/i18n/locales";
import { Coach, type CoachMark } from "./root/CoachRoot";
export default {
title: "ds-react/Coach",
component: Coach,
parameters: {
chromatic: { disable: true },
},
decorators: [
(Story) => (
<div style={{ minHeight: "5000px" }}>
<Story />
</div>
),
],
} satisfies Meta<typeof Coach>;
export const CoachAnchor: StoryFn<typeof Coach> = () => {
const createRef = useRef<HTMLButtonElement>(null);
const reviewRef = useRef<HTMLButtonElement>(null);
return (
<HStack gap="space-16">
<Button ref={reviewRef}>Review</Button>
<Coach
steps={[
{
id: "step-1",
type: "anchor",
anchorRef: createRef,
title: "Create",
description: "Start by creating a new item.",
},
{
id: "step-2",
type: "anchor",
anchorRef: reviewRef,
placement: "bottom-start",
title: "Review",
description: "Review your item before submitting it.",
},
]}
>
<Coach.Mark>
<Button ref={createRef}>Create</Button>
</Coach.Mark>
</Coach>
</HStack>
);
};
export const MissingAnchor: StoryFn<typeof Coach> = () => {
const anchorRef = useRef<HTMLButtonElement>(null);
const missingRef = useRef<HTMLButtonElement>(null);
return (
<Coach
steps={[
{
id: "step-1",
type: "anchor",
anchorRef,
title: "Anchor",
description: "This step has a valid anchor.",
},
{
id: "step-2",
type: "anchor",
anchorRef: missingRef,
placement: "bottom-start",
title: "Missing anchor",
description: "This step has a missing anchor.",
},
]}
>
<Coach.Mark>
<Button ref={anchorRef}>Create</Button>
</Coach.Mark>
</Coach>
);
};
export const CoachDialog: StoryFn<typeof Coach> = () => {
return (
<HStack gap="space-16">
<Coach
steps={[
{
id: "step-1",
type: "dialog",
title: "Welcome",
description: "Welcome to the new experience.",
},
{
id: "step-2",
type: "dialog",
title: "Changes",
description: "Here is what changed since last time.",
},
]}
>
<Coach.Mark>
<Button>Next step</Button>
</Coach.Mark>
</Coach>
</HStack>
);
};
export const CoachMixed: StoryFn<typeof Coach> = () => {
const dashboardRef = useRef<HTMLButtonElement>(null);
const settingsRef = useRef<HTMLButtonElement>(null);
return (
<VStack
gap="space-16"
align="start"
justify="space-between"
height="5000px"
width="500px"
>
<Coach
steps={[
{
id: "step-1",
type: "dialog",
size: "small",
width: "small",
allowToEndTour: true,
image: (
<img
src="https://i.pinimg.com/originals/59/54/b4/5954b408c66525ad932faa693a647e3f.jpg"
alt="Dashboard overview"
/>
),
title: "Dashboard",
description: "This tour takes 30 seconds.",
},
{
id: "step-2",
type: "anchor",
size: "small",
anchorRef: dashboardRef,
title: "Dashboard",
description: "This tour takes 30 seconds.",
image: (
<img
src="https://thefunnybeaver.com/wp-content/uploads/2019/11/funny-grey-blep.jpg"
alt="Dashboard overview"
/>
),
},
{
id: "step-3",
type: "anchor",
anchorRef: settingsRef,
placement: "left",
allowToEndTour: true,
width: "small",
title: "Settings",
description: "This tour takes 30 seconds.",
/*image: (
<img
src="https://i.pinimg.com/originals/b3/ee/c0/b3eec03459b57860fe1f898b7584683b.jpg"
alt="Settings overview"
/>
),*/
},
]}
>
<Coach.Mark>
<Button ref={dashboardRef}>Dashboard</Button>
</Coach.Mark>
</Coach>
<HStack justify="end" width="100%">
<Button ref={settingsRef}>Settings</Button>
</HStack>
</VStack>
);
};
export const SizeAndWidth: StoryObj<typeof Coach> = {
render: (props) => {
const buttonRef = useRef(null);
return (
<Coach
steps={[
{
id: "render_step",
type: "dialog",
title: "This is a title",
description: "This is some text",
width: props.width,
size: props.size,
},
{
id: "render_step_2",
type: "anchor",
anchorRef: buttonRef,
title: "This is a title",
width: props.width,
size: props.size,
description: "This is some text",
image: (
<img
src="https://i.ytimg.com/vi/1M_oZgVBknw/maxresdefault.jpg"
alt="Render step overview"
/>
),
},
]}
>
<Coach.Mark>
<Button ref={buttonRef}>Button</Button>
</Coach.Mark>
</Coach>
);
},
args: {
size: "medium",
width: "medium",
},
argTypes: {
size: {
control: { type: "select" },
options: ["small", "medium"],
},
width: {
control: { type: "select" },
options: ["small", "medium"],
},
},
};
export const RenderStep: StoryFn<typeof Coach> = () => {
return (
<Coach
renderStep={(step, context) => {
if (context.index === 0) {
return context.defaultContent;
}
return (
<Coach.Step>
<Coach.Title>{step.title}</Coach.Title>
<Coach.Image>{step.image}</Coach.Image>
<Coach.Progress />
<Coach.Description>{step.description}</Coach.Description>
<Coach.Footer>
<Coach.PreviousTrigger>
<Button variant="secondary">Forrige</Button>
</Coach.PreviousTrigger>
<Coach.CloseTrigger>
<Button>Lukk</Button>
</Coach.CloseTrigger>
</Coach.Footer>
</Coach.Step>
);
}}
steps={[
{
id: "render_step",
type: "dialog",
title: "Normal step",
description: "This step demonstrates standard step.",
},
{
id: "render_step_2",
type: "dialog",
title: "Render Step",
description: "This step demonstrates custom renderStep.",
image: (
<img
src="https://i.ytimg.com/vi/1M_oZgVBknw/maxresdefault.jpg"
alt="Render step overview"
/>
),
},
]}
>
<Coach.Mark>
<Button>Feature</Button>
</Coach.Mark>
</Coach>
);
};
export const Mark: StoryObj<typeof CoachMark> = {
render: (props) => {
const [selected, setSelected] = useState(["Nittedal", "Arendal"]);
const options = [
"Lillehammer",
"Nittedal",
"Enebakk",
"Hamar",
"Arendal",
"Gjøvik",
];
return (
<VStack padding="space-40" gap="space-64">
<Coach type="dialog" title="Explore the page">
<Coach.Mark offset={props.offset}>
<ToggleGroup defaultValue="lest" onChange={console.info}>
<ToggleGroup.Item
value="ulest"
icon={<EnvelopeClosedIcon aria-hidden />}
label="Ulest"
/>
<ToggleGroup.Item
value="lest"
icon={<EnvelopeOpenIcon aria-hidden />}
label="Lest"
/>
<ToggleGroup.Item
value="sendt"
icon={<PaperplaneIcon aria-hidden />}
label="Sendt"
/>
</ToggleGroup>
</Coach.Mark>
<Coach.Mark offset={props.offset}>
<Button onClick={() => console.log("Button clicked")}>
Click me
</Button>
</Coach.Mark>
<Coach.Mark offset={props.offset}>
<Chips>
{options.map((option) => (
<Chips.Toggle
key={option}
selected={selected.includes(option)}
onClick={() =>
setSelected(
selected.includes(option)
? selected.filter((x) => x !== option)
: [...selected, option],
)
}
>
{option}
</Chips.Toggle>
))}
</Chips>
</Coach.Mark>
<Coach.Mark offset={props.offset}>
<GuidePanel>
Saksbehandlingstiden varierer fra kommune til kommune. Hvis det
går mer enn X måneder siden du søkte, skal du få brev om at
saksbehandlingstiden er forlenget.
</GuidePanel>
</Coach.Mark>
<Coach.Mark
onClick={() => console.log("Coach dot clicked")}
offset={props.offset}
>
<ExpansionCard aria-label="Demo med bare tittel">
<ExpansionCard.Header>
<ExpansionCard.Title>
Utbetaling av sykepenger
</ExpansionCard.Title>
</ExpansionCard.Header>
<ExpansionCard.Content>
<p>Her er innholdet i ekspansjonskortet.</p>
</ExpansionCard.Content>
</ExpansionCard>
</Coach.Mark>
<Coach.Mark offset={props.offset}>
<InfoCard data-color="info">
<InfoCard.Header>
<InfoCard.Title>Fremhevet informasjon</InfoCard.Title>
</InfoCard.Header>
<InfoCard.Content>
InfoCard brukes for å fremheve informasjon på en side, uten at
det er like kritisk som en alert.
</InfoCard.Content>
</InfoCard>
</Coach.Mark>
<Coach.Mark data-color="success" />
</Coach>
</VStack>
);
},
args: {
offset: "space-16",
},
};
export const Translations: StoryFn<typeof Coach> = () => {
const translations = {
Coach: {
currentStep: "{current}/{total}",
next: "Next",
previous: "Previous",
close: "Close",
},
};
return (
<Provider locale={en} translations={translations}>
<Coach
steps={[
{
id: "progress_translation",
type: "dialog",
title: "Progress Translations",
description: "This step demonstrates translations.",
},
{
id: "progress_translation_2",
type: "dialog",
title: "Translation Test",
description: "See the translation above and on the buttons.",
},
]}
>
<Coach.Mark>
<Button>Feature</Button>
</Coach.Mark>
</Coach>
</Provider>
);
};
export const SingleStepAnchor: StoryFn<typeof Coach> = () => {
const buttonRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
return (
<Coach
showTour={open}
onShowTourChange={setOpen}
type="anchor"
anchorRef={buttonRef}
title="Enkeltsteg"
description="Dette er en enkeltstående coachmark uten steps-prop."
>
<Coach.Mark>
<Button ref={buttonRef} onClick={() => setOpen((prev) => !prev)}>
Feature
</Button>
</Coach.Mark>
</Coach>
);
};
export const SingleStepRenderStep: StoryFn<typeof Coach> = () => {
const buttonRef = useRef<HTMLButtonElement>(null);
return (
<Coach
type="anchor"
anchorRef={buttonRef}
title="Opprett ny"
description="Standardtekst som kan brukes eller overstyres."
renderStep={({ title, description }) => (
<Coach.Step>
<Coach.Title>{title}</Coach.Title>
<Coach.Description>{description}</Coach.Description>
<BodyShort>Egendefinert innhold her!</BodyShort>
<Coach.Footer>
<Coach.CloseTrigger>
<Button size="medium" variant="primary">
Forstått
</Button>
</Coach.CloseTrigger>
</Coach.Footer>
</Coach.Step>
)}
>
<Coach.Mark>
<Button ref={buttonRef}>Feature</Button>
</Coach.Mark>
</Coach>
);
};
export const CoachControlled: StoryFn<typeof Coach> = () => {
const buttonRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
return (
<>
<Button ref={buttonRef} onClick={() => setOpen((prev) => !prev)}>
Toggle tour
</Button>
<Coach
showTour={open}
onShowTourChange={setOpen}
type="anchor"
anchorRef={buttonRef}
title="Kontrollert coach"
description="Denne coachen styres av state."
/>
</>
);
};
export const KeyboardNavigation: StoryFn<typeof Coach> = () => {
const dashboardRef = useRef<HTMLButtonElement>(null);
return (
<Coach
steps={[
{
id: "step-1",
type: "dialog",
size: "small",
width: "small",
allowToEndTour: true,
image: (
<img
src="https://i.pinimg.com/originals/59/54/b4/5954b408c66525ad932faa693a647e3f.jpg"
alt="Dashboard overview"
/>
),
title: "Dashboard",
description: <Search label="Search" />,
},
{
id: "step-2",
type: "anchor",
size: "small",
allowToEndTour: true,
anchorRef: dashboardRef,
title: "Dashboard",
description: <Search label="Search" />,
},
]}
>
<Coach.Mark>
<Button ref={dashboardRef}>Dashboard</Button>
</Coach.Mark>
</Coach>
);
};
+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 };
@@ -84,9 +84,6 @@ export const Attachments: Story = {
options: ["none", "x", "y", "both"],
},
},
args: {
overflow: "none",
},
};
export const Chromatic = renderStoriesForChromatic({
@@ -16,7 +16,7 @@ export default {
chromatic: { disable: true },
},
/* TODO: Temp disabled CI-tests */
tags: ["skip-test"],
tags: ["!play-fn", "skip-test"],
} satisfies Meta<typeof DatePicker>;
type Story = StoryObj<typeof DatePicker>;
@@ -104,7 +104,7 @@ export const DropdownCaptionNavigation: Story = {
};
export const ControlledMonthPreservesFocus: Story = {
tags: ["!skip-test"],
tags: ["play-fn", "!skip-test"],
render: () => {
const [month, setMonth] = React.useState(new Date(2026, 2, 15));
const [selected, setSelected] = React.useState<Date | undefined>(
@@ -11,7 +11,7 @@ export default {
chromatic: { disable: false },
},
/* TODO: Temp disabled CI-tests */
tags: ["skip-test"],
tags: ["!play-fn", "skip-test"],
} satisfies Meta<typeof UNSAFE_Combobox>;
type StoryObject = StoryObj<typeof UNSAFE_Combobox>;
@@ -272,7 +272,6 @@ export const Composition = () => {
: [...prev, option.value],
);
},
multiple: true,
};
return (
@@ -126,7 +126,6 @@ export const OptionMemoization = () => {
</div>
<ComboboxRoot
defaultOpen
multiple
options={groupedOptions}
value={value}
onToggleOption={(option) => {
@@ -38,7 +38,7 @@ export const ComboboxFilter = (props: ComboboxFilterProps) => {
}}
onKeyDown={(event) => {
if (["ArrowLeft", "ArrowRight"].includes(event.key)) {
setVirtuallyFocusedOptionValue(""); // Assume you want to edit the input, so remove virtual focus
setVirtuallyFocusedOptionValue("");
}
}}
aria-autocomplete="list"
@@ -1,4 +1,4 @@
import React from "react";
import React, { useCallback } from "react";
import { Listbox } from "../../../utils/components/Listbox/root/ListboxRoot";
import { useDeferredValue, useEventCallback } from "../../../utils/hooks";
import { useI18n } from "../../../utils/i18n/i18n.hooks";
@@ -51,16 +51,24 @@ function ComboboxListContent<
>({ children }: ComboboxListProps<T>) {
const { virtuallyFocusedOptionValue, filterString } =
useComboboxPopupContext();
const {
options,
value, // Selected options
onToggleOption: originalOnToggleOption,
} = useComboboxRootContext();
const onToggleOption = useEventCallback(originalOnToggleOption);
const { setOpen, options, value, onToggleOption, multiple, triggerRef } =
useComboboxRootContext();
const memoizedOnToggleOption = useEventCallback(onToggleOption);
const deferredFilterString = useDeferredValue(filterString);
const maybeAnnouceHits = useHitsAnnouncer(deferredFilterString);
const translate = useI18n("Combobox2");
const localOnToggleOption = useCallback(
(option: ComboboxOptionData, isSelected: boolean) => {
memoizedOnToggleOption(option, isSelected);
if (!multiple) {
setOpen(false);
triggerRef.current?.focus();
}
},
[memoizedOnToggleOption, multiple, triggerRef, setOpen],
);
if (children && typeof children !== "function") {
return children;
}
@@ -89,7 +97,7 @@ function ComboboxListContent<
<ComboboxOption
key={option.value}
option={option}
onToggleOption={onToggleOption}
onToggleOption={localOnToggleOption}
isSelected={value.includes(option.value)}
hasVirtualFocus={virtuallyFocusedOptionValue === option.value}
highlightText={deferredFilterString}
@@ -102,7 +110,7 @@ function ComboboxListContent<
<ComboboxOption
key={optOrGroup.value}
option={optOrGroup}
onToggleOption={onToggleOption}
onToggleOption={localOnToggleOption}
isSelected={value.includes(optOrGroup.value)}
hasVirtualFocus={virtuallyFocusedOptionValue === optOrGroup.value}
highlightText={deferredFilterString} // TODO: Vurder å kunne slå av dette for bedre ytelse
@@ -118,27 +126,26 @@ function filterOptions<T extends ComboboxOptionData>(
filterString: string,
): (T | ComboboxGroupData<T>)[] {
const filterStringLowerCase = filterString.toLocaleLowerCase();
const results: (T | ComboboxGroupData<T>)[] = [];
for (const optOrGroup of options) {
if ("options" in optOrGroup) {
const matchingOptions: T[] = [];
for (const option of optOrGroup.options) {
if (option.label.toLocaleLowerCase().includes(filterStringLowerCase)) {
matchingOptions.push(option);
return options
.map((optOrGroup) => {
if ("options" in optOrGroup) {
const matchingOptions = optOrGroup.options.filter((option) =>
option.label.toLocaleLowerCase().includes(filterStringLowerCase),
);
if (matchingOptions.length > 0) {
return { ...optOrGroup, options: matchingOptions };
}
return null;
}
if (matchingOptions.length > 0) {
results.push({ ...optOrGroup, options: matchingOptions });
if (
optOrGroup.label.toLocaleLowerCase().includes(filterStringLowerCase)
) {
return optOrGroup;
}
} else if (
optOrGroup.label.toLocaleLowerCase().includes(filterStringLowerCase)
) {
results.push(optOrGroup);
}
}
return results;
return null;
})
.filter((option): option is T | ComboboxGroupData<T> => option !== null);
}
export type { ComboboxListProps };
@@ -91,7 +91,7 @@ function announce(message: string, politeness: Politeness = "polite") {
}
/**
* Announces a message through a persistently-mounted, visually-hidden live region.
* Speaks a message through a persistently-mounted, visually-hidden live region.
*
* The polite and assertive regions are created once and kept mounted.
*
@@ -19,7 +19,16 @@ function ComboboxOverlay({ children, mobileHeader }: ComboboxOverlayProps) {
if (isMobile) {
return (
<ComboboxModal open={rootContext.open} onOpenChange={rootContext.setOpen}>
<ComboboxModal
open={rootContext.open}
onOpenChange={(open) => {
rootContext.setOpen(open);
!open && rootContext.triggerRef.current?.focus(); // This does not work when closing with button
}}
onOpenChangeComplete={(open) =>
!open && rootContext.triggerRef.current?.focus()
}
>
{mobileHeader}
{children}
</ComboboxModal>
@@ -33,6 +42,7 @@ function ComboboxOverlay({ children, mobileHeader }: ComboboxOverlayProps) {
return (
<DismissableLayer
asChild
//safeZone={{ anchor: rootContext.triggerRef.current }}
onDismiss={(event) => {
// We don't have trigger as `safeZone` because we want to close when trigger is focused.
// To avoid immediately re-opening the popup when trigger is clicked (blur closes, click opens),
@@ -45,6 +55,7 @@ function ComboboxOverlay({ children, mobileHeader }: ComboboxOverlayProps) {
return;
}
rootContext.setOpen(false);
rootContext.triggerRef.current?.focus();
}}
enabled={rootContext.open}
>
@@ -66,6 +77,7 @@ type ComboboxModalProps = {
children: React.ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
onOpenChangeComplete: (open: boolean) => void;
};
function subscribeToResize(callback: () => void) {
@@ -77,7 +89,12 @@ function getVisualViewportHeight() {
return visualViewport?.height; // Takes into account virtual keyboard etc.
}
function ComboboxModal({ children, open, onOpenChange }: ComboboxModalProps) {
function ComboboxModal({
children,
open,
onOpenChange,
onOpenChangeComplete,
}: ComboboxModalProps) {
const visualViewportHeight = useSyncExternalStore(
subscribeToResize,
getVisualViewportHeight,
@@ -95,7 +112,12 @@ function ComboboxModal({ children, open, onOpenChange }: ComboboxModalProps) {
}, [open]);
return (
<Dialog size="small" open={open} onOpenChange={onOpenChange}>
<Dialog
size="small"
open={open}
onOpenChange={onOpenChange}
onOpenChangeComplete={onOpenChangeComplete}
>
<Dialog.Popup className="aksel-combobox2__modal" position="fullscreen">
<div
className="aksel-combobox2__modal-inner"
@@ -62,7 +62,6 @@ export const Default = () => {
return (
<ComboboxRoot
defaultOpen
multiple
options={options}
value={value}
onToggleOption={(option, newSelected) => {
@@ -174,7 +173,6 @@ export const CustomOptionRendering = () => {
return (
<ComboboxRoot
defaultOpen
multiple
options={options}
value={value}
onToggleOption={(option, isSelected) => {
@@ -261,7 +259,6 @@ export const ControlledInput = () => {
return (
<ComboboxRoot
defaultOpen
multiple
options={filteredOptions}
value={value}
onToggleOption={(option) => {
@@ -1,5 +1,4 @@
import React, { useRef, useState } from "react";
import { useClientLayoutEffect } from "../../../utils-external";
import React, { useLayoutEffect, useRef, useState } from "react";
import { Listbox } from "../../../utils/components/Listbox/root/ListboxRoot";
import { createStrictContext } from "../../../utils/helpers";
import { useComboboxRootContext } from "../root/ComboboxRoot";
@@ -30,7 +29,7 @@ export const ComboboxPopup = ({ children }: ComboboxPopupProps) => {
const rootContext = useComboboxRootContext();
const ref = useRef<HTMLDivElement | null>(null);
useClientLayoutEffect(
useLayoutEffect(
function onOpen() {
const canHover = window.matchMedia("(hover: hover)").matches;
@@ -86,7 +85,7 @@ export const ComboboxPopup = ({ children }: ComboboxPopupProps) => {
filterString={filterString}
setFilterString={(newString) => {
setFilterString(newString);
setVirtuallyFocusedOptionValue(""); // Remove virtual focus when filter string changes
setVirtuallyFocusedOptionValue("");
}}
>
{children}
@@ -1,4 +1,4 @@
import React, { useCallback, useRef, useState } from "react";
import React, { useRef, useState } from "react";
import { Floating } from "../../../utils/components/floating/Floating";
import { cl, createStrictContext } from "../../../utils/helpers";
import type { FormFieldProps } from "../../useFormField";
@@ -74,8 +74,9 @@ export interface ComboboxRootProps<
defaultOpen?: boolean;
/**
* Whether multiple options can be selected.
* @default true
*/
multiple?: boolean;
multiple?: boolean; // TODO: Hva skal være default?
}
export interface ComboboxRootContextProps {
@@ -83,10 +84,9 @@ export interface ComboboxRootContextProps {
setOpen: (newOpen: boolean) => void;
triggerRef: React.RefObject<HTMLDivElement | null>;
options: ComboboxRootProps["options"];
/** Values of selected options */
value: ComboboxRootProps["value"];
/** Consumer callback */
onToggleOption: ComboboxRootProps["onToggleOption"];
multiple: boolean;
size: Exclude<ComboboxRootProps["size"], undefined>;
disabled: ComboboxRootProps["disabled"];
}
@@ -107,7 +107,7 @@ export function ComboboxRoot<
value,
onToggleOption,
defaultOpen = false,
multiple = false,
multiple = true,
size = "medium",
disabled,
className,
@@ -125,25 +125,14 @@ export function ComboboxRoot<
>
<ComboboxRootContextProvider
open={open}
setOpen={useCallback((newOpen) => {
setOpen(newOpen);
if (!newOpen) {
// queueMicrotask is needed when closing with close button in Dialog
queueMicrotask(() => triggerRef.current?.focus());
}
}, [])}
setOpen={setOpen}
triggerRef={triggerRef}
options={options}
value={value}
onToggleOption={useCallback(
(...args) => {
(onToggleOption as ComboboxRootProps["onToggleOption"])(...args);
if (!multiple) {
setOpen(false);
}
},
[onToggleOption, multiple],
)}
onToggleOption={
onToggleOption as ComboboxRootContextProps["onToggleOption"]
}
multiple={multiple}
size={size}
disabled={disabled}
>
@@ -48,7 +48,7 @@ export const Default: Story = {
args: {
children: "Id elit esse enim reprehenderit enim nisi veniam nostrud.",
title: "GlobalAlert title",
title: "GlobalAlert Title",
size: "medium",
status: "announcement",
centered: true,
+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 {
@@ -41,7 +41,7 @@ export const Default: Story = {
args: {
children: "Id elit esse enim reprehenderit enim nisi veniam nostrud.",
title: "InfoCard title",
title: "InfoCard Title",
size: "medium",
"data-color": "info",
},
@@ -42,7 +42,7 @@ export const Default: Story = {
args: {
children: "Id elit esse enim reprehenderit enim nisi veniam nostrud.",
title: "LocalAlert title",
title: "LocalAlert Title",
size: "medium",
status: "announcement",
},
@@ -9,6 +9,7 @@ export default {
parameters: {
chromatic: { disable: true },
},
tags: ["play-fn"],
} satisfies Meta<typeof Lookup>;
type Story = StoryObj<typeof Lookup>;
@@ -83,17 +83,6 @@ export const DisabledMonths = () => (
/>
);
export const EverythingDisabledBesidesOneMonth = () => (
<div>
<MonthPicker.Standalone
defaultSelected={new Date(2026, 5, 1)}
disabled={[
(date) => !(date.getFullYear() === year && date.getMonth() === 5),
]}
/>
</div>
);
/**
* Only June is enabled. ArrowRight/ArrowLeft on June keeps focus on June instead
* of searching forever. Keyboard search stops at the
@@ -12,7 +12,7 @@ export default {
chromatic: { disable: true },
},
/* TODO: Temp disabled CI-tests */
tags: ["skip-test"],
tags: ["!play-fn", "skip-test"],
} satisfies Meta<typeof MonthPicker>;
type Story = StoryObj<typeof MonthPicker>;
@@ -246,45 +246,6 @@ export const DropdownCaptionView: Story = {
},
};
/**
* Validate that the month grid keeps a tab stop after a month is focused with the mouse
* and focus then moves to an outside element.
*/
export const TabStopAfterMouseFocus: Story = {
// Overrides the file-level skip so this regression test runs in CI
tags: ["!skip-test"],
render: () => (
<>
<MonthPicker.Standalone year={new Date("2025")} />
<button type="button">Outside</button>
</>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const monthButtons = () =>
canvas
.getAllByRole("button")
.filter((b) => b.classList.contains("aksel-date__month-button"));
expect(monthButtons().filter((b) => b.tabIndex === 0)).toHaveLength(1);
const febButton = canvas.getByRole("button", {
name: format(new Date("Feb 01 2025"), "LLLL", { locale: nb }),
});
await userEvent.click(febButton);
expect(febButton).toHaveFocus();
await userEvent.click(canvas.getByRole("button", { name: "Outside" }));
const tabStops = monthButtons().filter((b) => b.tabIndex === 0);
expect(tabStops).toHaveLength(1);
expect(tabStops[0]).toBe(febButton);
await userEvent.tab({ shift: true });
expect(febButton).toHaveFocus();
},
};
/**
* Validate that the monthpicker handles `dropdownCaption` prop correctly
*/
@@ -106,7 +106,7 @@ const MonthPickerButtonInternal = ({
setTabRoot(next);
}}
onFocus={() => {
setTabRoot(month);
setTabRoot(focus);
}}
>
<div className="rdp-day__inner">
@@ -49,15 +49,8 @@ const MonthPickerTable = () => {
hasSelected ? selected : getRootFallback(),
);
// Track the year instead of comparing tabRoot, since tabRoot is undefined
// when every month in the year is disabled and would re-trigger forever.
const [tabRootYear, setTabRootYear] = useState(year.getFullYear());
if (tabRootYear !== year.getFullYear()) {
setTabRootYear(year.getFullYear());
if (tabRoot?.getFullYear() !== year.getFullYear()) {
setTabRoot(hasSelected ? selected : getRootFallback());
}
if (tabRoot?.getFullYear() !== year.getFullYear()) {
setTabRoot(hasSelected ? selected : getRootFallback());
}
const tableMonths = [
@@ -179,3 +179,4 @@ export const Chromatic = renderStoriesForChromatic({
AsLink,
ColorRole,
});
Chromatic.args = { count: 8, siblingCount: 1, boundaryCount: 1 };
@@ -7,6 +7,7 @@ export default {
title: "ds-react/Timeline/Tests",
component: Timeline,
parameters: { chromatic: { disable: true } },
tags: ["play-fn"],
};
type Story = StoryObj<typeof Timeline>;
@@ -19,6 +19,12 @@ export default {
labelSuffix: "delete",
},
},
Coach: {
currentStep: "{current} of {total}",
next: "Next",
previous: "Previous",
close: "Close",
},
Combobox: {
addOption: "Add",
noMatches: "No search hits",
@@ -24,6 +24,12 @@ export default {
labelSuffix: "slett",
},
},
Coach: {
currentStep: "{current} av {total}",
next: "Neste",
previous: "Forrige",
close: "Lukk",
},
Combobox: {
/** The input value will be appended to the end of this text, e.g. `Legg til "input value"`. */
addOption: "Legg til",
@@ -19,6 +19,12 @@ export default {
labelSuffix: "slett",
},
},
Coach: {
currentStep: "{current} av {total}",
next: "Neste",
previous: "Førre",
close: "Lukk",
},
Combobox: {
addOption: "Legg til",
noMatches: "Ingen søketreff",
@@ -1,21 +1,22 @@
import type { Args, StoryObj } from "@storybook/react-vite";
import React from "react";
import type { Renderer, StoryContext } from "storybook/internal/types";
type StoryFnOrObj =
| { render?: (...args: any[]) => React.ReactNode; args?: Args }
| (React.FunctionComponent & { args?: Args });
| React.FunctionComponent;
export function renderStoriesForChromatic(
stories: Record<string, StoryFnOrObj>,
Component?: React.FunctionComponent<any>,
): StoryObj {
return {
render: () => (
render: (...args: [Args, StoryContext<Renderer, Args>]) => (
<>
{Object.entries(stories).map(([storyName, Story]) => (
<div key={storyName}>
<h2 className="storyheading">{storyName}</h2>
{resolveComponent(Story, Component)}
{resolveComponent(Story, args, Component)}
</div>
))}
</>
@@ -29,13 +30,14 @@ export function renderStoriesForChromatic(
function resolveComponent(
Story: StoryFnOrObj,
args: [Args, StoryContext<Renderer, Args>],
Component?: React.FunctionComponent,
) {
if (typeof Story === "function") {
return <Story {...(Story.args || {})} />;
return <Story />;
}
if (Story.render) {
return Story.render(Story.args || {});
return Story.render(...args);
}
if (Story.args && Component) {
return <Component {...Story.args} />;
-4
View File
@@ -1,9 +1,5 @@
# @navikt/ds-tailwind
## 8.22.1
## 8.22.0
## 8.21.0
## 8.20.1
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tailwind",
"version": "8.22.1",
"version": "8.21.0",
"description": "Tailwind config based on @navikt/ds-tokens",
"author": "Aksel | Nav designsystem team",
"keywords": [
@@ -28,7 +28,7 @@
},
"homepage": "https://aksel.nav.no/grunnleggende/kode/tailwind/",
"devDependencies": {
"@navikt/ds-tokens": "^8.22.1",
"@navikt/ds-tokens": "^8.21.0",
"@types/node": "^24.10.9",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.15",
-4
View File
@@ -1,9 +1,5 @@
# @navikt/ds-tokens
## 8.22.1
## 8.22.0
## 8.21.0
## 8.20.1
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tokens",
"version": "8.22.1",
"version": "8.21.0",
"description": "Design-tokens for Nav designsystem",
"author": "Aksel | Nav designsystem team",
"keywords": [
File renamed without changes.
-30
View File
@@ -1,35 +1,5 @@
# Changelog
## 8.22.1
### @navikt/ds-css
- DataGrid: Column dividers in the table header are now hidden when column dividers are disabled, and only shown on hover, focus or while resizing. ([#5505](https://github.com/navikt/aksel/pull/5505))
## 8.22.0
### @navikt/aksel-icons
- Icons: New icons `Gift`, `ArrowsPointingIn` and `ArrowsPointingOut`. ([#5484](https://github.com/navikt/aksel/pull/5484))
* ESM: Mark ESM output as side-effect free, so bundlers like webpack, Turbopack and esbuild can tree-shake unused components and icons. ([#5472](https://github.com/navikt/aksel/pull/5472))
### @navikt/ds-react
- Combobox: The list is no longer focusable ([#5490](https://github.com/navikt/aksel/pull/5490))
- Popover: Closed popovers no longer calculate their position on mount. This makes pages with many Dropdowns or HelpTexts render faster. ([#5474](https://github.com/navikt/aksel/pull/5474))
- ESM: Mark ESM output as side-effect free, so bundlers like webpack, Turbopack and esbuild can tree-shake unused components and icons. ([#5472](https://github.com/navikt/aksel/pull/5472))
- MonthPicker: Fixed issue where navigating to a year without any enabled months could lead to re-render loop in React ([#5503](https://github.com/navikt/aksel/pull/5503))
- MonthPicker: Arrow-key navigation no longer freezes the page when no later or earlier month is enabled. Keyboard search now respects `fromDate`/`toDate` with both caption types and looks at most 100 years ahead or back. ([#5477](https://github.com/navikt/aksel/pull/5477))
- Date utilities: Import date-fns functions and locales from their own paths, so unbundled Node imports (SSR, Vitest/Jest) no longer load every date-fns locale. ([#5476](https://github.com/navikt/aksel/pull/5476))
- DataGrid: Fixed text selection inside details panels when keyboard navigation is enabled. ([`0bdad46`](https://github.com/navikt/aksel/commit/0bdad462664e04abd89c4121de515f6ae6d9aad1))
## 8.21.0
### @navikt/aksel-icons
+6 -6
View File
@@ -11,11 +11,11 @@
"sanity:update": "yarn workspace website update:sanity"
},
"dependencies": {
"@navikt/aksel": "^8.22.1",
"@navikt/aksel-icons": "^8.22.1",
"@navikt/ds-css": "^8.22.1",
"@navikt/ds-react": "^8.22.1",
"@navikt/ds-tailwind": "^8.22.1",
"@navikt/ds-tokens": "^8.22.1"
"@navikt/aksel": "^8.21.0",
"@navikt/aksel-icons": "^8.21.0",
"@navikt/ds-css": "^8.21.0",
"@navikt/ds-react": "^8.21.0",
"@navikt/ds-tailwind": "^8.21.0",
"@navikt/ds-tokens": "^8.21.0"
}
}
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more