Compare commits

..
Author SHA1 Message Date
github-actions[bot]andgithub-actions[bot] 3c95772b1a Version Packages (#3077)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-08-09 09:53:16 +02:00
Ken a312faac6b Updated chromatic-project (#3079)
* feat: Updated chromatic-project

* bug: remove extra forwardslash
2024-08-08 14:34:48 +02:00
02fa92468b FloatingMenu component (#2906)
* ✨ Initial Popper wrapper

* ✨ Floating popper element

* 🏷️ Rename popper -> floating

* 🐛 Fikset imports

* ♻️ FLoating-fil splittet opp i moduler

* 🎨 Satt opp exports og story

* 🎨 Arrow er nå del av content

* 🐛 display block på arrow

* 🎉 Egen Portal-komponent

* 🎨 Portal og Overlay exportert i root

* ♻️ Tooltip bruker egen Portal

* 📝 Changeset

* 🔀 Merge Portal

* 📝 Story with portal

* 📝 Props story

* 🚚 Temp rename

* 🚚 Temp rename

* 🎨 La til Slot på Floating.Anchor

* ✨ Fungerende nesting av dismissable items

* ✨ Fungerende outsidepointerclick handler

* 🚚 Ryddet opp i struktur

* ✨ bedre event-handling

* 🎉 Popover bruker dismissableLayer

* 🔥 Fjernet console.logs

* ♻️ Extender nå ikke htmldivelement

* 📝 Commented on dimsissablelayer

* 📝 Kommentarer

* 🎨 Disable pointer-events on body

* 🎨 Setup content

* 🎨 Fungerende popover med story

* 🎨 useDescendant-API kan bruke uten context-error på nesting

* 🐛 Fikset tilfeller der oppdatering av descendant-options ikke ble reflektert i descendants-map

* 🐛 Satte enabled til optional

* bug: import av Portal brakk stories for Popover og Floating

* bug: DescenadantContext er nå riktig typet som undefined

* label: scopet navn for bruk av descendant-provider i dismissablelayer

* memo: Bedre kommentarer

* feat: Sender ikke callbacks hvis layer er disabled

* memo: Formatering og kommentarer

* memo: Kommentarer

* copy: Kopiert over basekomponent

* refactor: Oppdatert imports

* refactor: Oppdatert Primitive bruk

* feat: Støtter forwardRef

* fire: Fjernet Radix import

* feat: Bruker Floating og ikke PopperPrimitive

* type: Bedre typet composeEventHandler

* refactor: Fjernet scopedProps

* feat: Custom RowingFocus

* wip feat: Replaced rowingfocus

* wip feat: Implemented initial focus for rowingfocus

* wip feat: Removed old collection api

* refactor: Use internal Slot component

* refactor: Fixed all scoped context-use

* feat: Fullført migrering til internt API

* test: Setup stories

* bug: Content is added to descendants

* bug: Found issue for nesten descendants

* copy: Kopiert over refactor for useDescendant

* bug: Fikset descendants nesting

* feat: Fikset keyboard nav med nested descendants

* bug: Removed early return

* memo: Kommentarer

* label: Typer

* refactor: Fjernet Root export

* refactor: Fjernet typeahead

* test: Setup story for entryfocus

* refactor: Forenklet FocusScope

* refactor: bedre dokumentert utils

* refactor: Moved utils to separate file

* refactor: Moved slotted-div to separate file

* refactor: Moved MenuContext out to context-file

* 🚚 Moved focus-files

* refactor: Update syntax

* refactor: Moved MenuAnchor to file

* refactor: Moved MenuGroup to file

* refactor: Moved Separator to file

* refactor: Moved Label to file

* refactor: Moved utility-components

* refactor: Flyttet item til mappe

* refactor: Flyttet checkbox

* refactor: Content in separate file

* refactor: Radio i separate filer

* refactor: SubComponents in files

* refactor: Bedre typer

* refactor: Re-written back to single file

* refactor: Fjernet gamle commits

* bug: Fikset import feil og feil extension av type

* refactor: data-attrb fra radix -> aksel

* refactor: Fjernet console.logs

* bug: Fixed import and test-type error

* refactor: Remove unwanted exports

* feat: Re-added ItemIndicator wrapper

* refactor: Updated exports

* refactor: removed focusguards

* refactor: FocusScope now assumes Slot

* refactor: Removed extra tabIndex

* feat: Implemented ItemIndicator and styling for testing

* refactor: Rowing -> Roving

* refactor: Moved Menu-component into overlays-dir

* bug: Fixed import of Slot

* bug: Re-add tabIndex to MenuItem for correct focus-handling

* memo: Rename Tab -> menu

* refactor: removed unwanted exports in story

* Update @navikt/core/react/src/overlays/floating-menu/Menu.stories.tsx

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>

* bug: Added missing meta import

* bug: Aligned dash with checkmark

* bug: Circle icon now shows for radio-demo

* bug: keyframes has extra ':', breaking animations

* bug: Re-added ItemIndicator for all TickIcon

* bug: WithLabel story now shows complete dropdown

* feat: Added disabled-styling to submenu

* refactor: Now uses ownerdocument where needed instead of document

* adjustments after review 1

* adjustments after review 2

* refactor: Updated to correct function type syntax

* refactor: renamed floating content propname

* refactor: Consistent destructuring of props

* refactor: rename FocusScope props

* memo: Added explanation for why we use flushEvent

* refactor: added consistent use of composeEventHandlers

* refactor: Force userSelect none style

* demo: Allow canceling opening if pointerdown is released outside of menu

* memo: Better line-dividers

* refactor: removed data-highlighted logic

* Update @navikt/core/react/src/overlays/floating-menu/Menu.tsx

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>

* Update @navikt/core/react/src/overlays/floating-menu/Menu.tsx

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>

* feat: Allow use of 'il' in floating-element

* feat: Implemented semantic tags for items and content

* Revert "feat: Implemented semantic tags for items and content"

This reverts commit 6bf473328d.

* Revert "feat: Allow use of 'il' in floating-element"

This reverts commit 5307facbca.

* minor adjustments

* feat: Added optional close keybind for submenus

* refactor: Removed unneeded label component

* memo: Comments

* memo: Comments

* refactor: Update hookname

* refactor: removed duplicate callback

* refactor: Less nesting in FocusScope

* refactor: Remove extra edgecase check not needed for dropdown

* refactor: Impl -> internal

* Update @navikt/core/react/src/overlays/floating-menu/parts/FocusScope.tsx

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>

* Update @navikt/core/react/src/overlays/floating-menu/parts/FocusScope.tsx

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>

* memo: FocusScope jsdoc

* Update @navikt/core/react/src/overlays/floating-menu/parts/FocusScope.tsx

* Update @navikt/core/react/src/overlays/floating-menu/parts/FocusScope.tsx

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>

* testing: Added onCloseAutoFocus demo

* refator: renamed onTriggerLeave

* refator: better handleing of edgecase where we close sidemenu with arrowright

---------

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
Co-authored-by: Halvor Haugan <halvor.haugan@nav.no>
2024-08-08 12:07:03 +02:00
Ken ea3e881467 feat: New icons Split (#3072) 2024-08-08 11:03:22 +02:00
Halvor Haugan 779e30d1df Combobox: Can no longer remove options with backspace when shouldShowSelectedOptions is false (#3076) 2024-08-07 12:48:22 +02:00
Halvor Haugan 0bd7812cdd [aksel.nav.no] Correct icon on hard coded list item (#3075) 2024-08-07 12:39:06 +02:00
Vegard Haugstvedt 58283c0e95 Refactor/split up filtered options (#3073)
* refactor: Move AddNewOption to a separate file to make FilteredOptions cleaner and prepare for future features.

* refactor: Move FilteredOptionsItem to a separate file to make FilteredOptions cleaner and prepare for future features.

* refactor: Move MaxSelectedMessage to a separate file

* refactor: Move LoadingMessage to a separate file

* refactor: Move NoHitsMessage to a separate file

* Added changeset and a type to FilteredOptionsItem props
2024-08-07 12:38:54 +02:00
Ken 7cfb69c239 Update README.md (#3074) 2024-08-07 12:28:34 +02:00
github-actions[bot]andgithub-actions[bot] 09828a4223 Version Packages (#3043)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-08-07 11:52:24 +02:00
Halvor Haugan e776c7c560 Endre changeset 2024-08-07 11:45:17 +02:00
Halvor Haugan 2dd266f0c4 ⬆️ Upgrade dependencies (minor) (#3070) 2024-08-07 09:13:14 +02:00
Ken daee394a71 feat: Hide sandbox-pages from robots (#3069) 2024-08-06 07:31:39 +02:00
KenandHalvor Haugan 8fd183cc90 feat: Updated font support and definition (#3044)
* feat: Updated font definitions

* memo: Changeset

* stories adjustments

---------

Co-authored-by: Halvor Haugan <halvor.haugan@nav.no>
2024-08-05 09:34:07 +02:00
Halvor Haugan 0b1ac4f82c [aksel.nav.no] Support relative markdown links (#3059) 2024-08-05 09:32:46 +02:00
KenandHalvor Haugan ec79e25060 feat: Added stale-issue handling (#3029)
* feat: Added stale-issue handling

* Update .github/workflows/stale.yml

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>

---------

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2024-08-05 08:57:44 +02:00
Vegard HaugstvedtandHalvor Haugan 18d20aaa8e Combobox: Pressing Escape in while focusing a Combobox inside a Modal will close the Modal (#3053)
* If pressing Escape while FilteredOptions is open, Combobox should swallow the keydown event, so it does not close a Modal

* Added changeset

* Update @navikt/core/react/src/form/combobox/Input/Input.tsx

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>

* Prevent default Escape from bubbling out of Combobox also when we have a value (which will be cleared).
Also co-located all the Escape-handling in keyDown. This also better mimics Select's native behaviour.

* Add clearInput to dependency array

* Legg til et Storybook-eksempel på Combobox i Modal, for manuell testing. La inn en tekst for å forklare caset.

---------

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2024-08-02 13:30:22 +02:00
Halvor Haugan 72c8f3112a Combobox: Fixed aria-describedby and some types (#3065) 2024-08-02 12:51:25 +02:00
Vegard HaugstvedtandHalvor Haugan 6e63069d6b Combobox: Fix NullPointer error in single-select with allowNewValues (#3054)
* Prevent NullPointer when selecting a new/custom value in Combobox single-select with Enter (without selecting it in FilteredOptions)

* Update .changeset/cyan-carrots-return.md

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>

* Expect AddNewButton to be visible

---------

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2024-08-02 12:47:37 +02:00
Halvor Haugan 825d87af43 Combobox: Entering an already selected option and pressing enter no longer removes it (#3061) 2024-08-02 12:43:34 +02:00
9181d2ce75 Combobox: Implement free text search (#2974)
* Implement fuzzy search in combobox

* bug: ArrowUp now works as intended, stopped onChange from running each arrow-press

* memo: Added comment

* refactor: Removed extra state-update for searchterm

* Update @navikt/core/react/src/form/combobox/FilteredOptions/filtered-options-util.ts

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>

* bug: Missed function export after refactor

* Add test for filtering in search

* Fix comments to better reflect the new code

* Change type for onChange to take in only value, not the entire event. This simplifies calling onChange programatically.

* Added changesets to reflect the changes in this branch

* KeyboardEvent.currentTarget does not have selectionStart property

* Update example

* Update storybook examples

* bug: Fixed undefined value sent to toggleOption callback

* revert: Removed test for what was corret interaction

* Add generic to enhance the event type for handleKeyDown

* Updated JSDoc

* Changed method signatures for onChange earlier, but forgot these two tests

* Add test for triggering onChange on every typed character in Combobox with autocomplete.

Currently fails on the last character, as it does not actually change the word in the input field.

* Move onChangeHandler from triggering onChange to onInput, as the former does not trigger when typing the last character of an autocompleted word.
onInput seems to work more consistently, but will test multiple use cases.

* Update aksel.nav.no/website/pages/eksempler/combobox/multi-select-controlled.tsx

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>

* Update @navikt/core/react/src/form/combobox/Input/Input.tsx

* Update .changeset/wild-owls-tan.md

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>

* Update @navikt/core/react/src/form/combobox/Input/Input.tsx

* updateInputValue is no longer neccessary

* Update @navikt/core/react/src/form/combobox/Input/Input.tsx

---------

Co-authored-by: Ken <ken.aleksander@gmail.com>
Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>
Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2024-08-02 11:04:50 +02:00
Ken 22016ec90f prettier-plugin-tailwindcss update (#3046)
* feat: Updated tailwind prettier plugin

* refactor: Used prettier-plugin to remove extra whitespace in className
2024-07-10 10:44:58 +02:00
Ken f7d4c43f98 Added support for markdown links in intro lists (#3045)
* feat: Added support for markdown-links in intro-list

* memo: Added comments
2024-07-10 10:40:22 +02:00
Ken c67fd8bcda feat: Increased amount of articles shown in masonary 2024-07-09 14:27:42 +02:00
Julian Nymark 5efa09d00e 🐛 bump playroom version + set white bg (#3042) 2024-07-08 15:35:46 +02:00
Ken 3afc568a04 refactor: Better naming for modifiers check in Tabs and Togglegroup 2024-07-08 08:44:36 +02:00
Ken 2fe2586ac6 Respect meta-keys when navigating in Tabs, ToggleGroup (#3041)
* bug: RovingFocus now respects alt-keys

* memo: changeset
2024-07-08 08:41:11 +02:00
Ken 420c5c30d8 Primitives CSS update (#3028)
* refactor: Updated stack to only add CSS when used as prop

* refactor: Updated hgrid to only add CSS when used as prop
2024-07-04 14:55:25 +02:00
Ken 6cf141a8c6 Font sync (#3032)
* refactor: Created namespaced location for SourceSans3 fonts

* testing: Added story/chromatic for fonts

* refactor: Rename to use - instead of underline

* refactor: Remove SourceSans3 prefix
2024-07-01 11:04:54 +02:00
dcba0fffad Chromatic stories consolidation (#2996)
* Guidepanel

* Bleed

* Move 'options' object out of 'control' object

* Hide & Show

* Page

* h2 instead of Heading in Chromatic FN

* 🎨 update stories

* 🐛 disable the forcedColors parameter, seems buggy?

- it seems to be a chromatic error, or at least I'm not sure what's
  happening when this parameter is enabled. Some stories seem to
  sporadically get the forcedColors applied.

* Stack

* Combobox: put 'Test' before every test-story for clarity

* Combobox & move combobox tests to tests-file

* made combobox stories testable with Chromatic snapshots

* Update @navikt/core/react/src/button/button.stories.tsx

* Alert rewrite

* Bleed add chromatic disable false

* FileUpload

* FormSummary

* HelpText

* Tooltip

* yarn update chromatic to 11.5.4

* Show: move parameters into Chromatic object

* memo: Removed duplicate heading

* bug: Removed extra argTypes

---------

Co-authored-by: Julian Nymark <julian.nymark@nav.no>
Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
Co-authored-by: Ken <ken.aleksander@gmail.com>
2024-07-01 10:58:33 +02:00
Halvor Haugan da0a66fb62 Fix type for default export in stories (#3038) 2024-07-01 09:47:02 +02:00
Halvor Haugan 3a52359169 Improved example of top aligned Modal (#3039) 2024-07-01 09:40:01 +02:00
1b8d553c05 <VirtualFocus /> component (#2894)
* 🚧 WIP on some dropdown prop API

* ♻️ rename dropdown -> autocomplete

* 🚧 WIP towards Autocomplete (inspiration from Menu)

* 🚧 WIP Autocomplete

* 🚧 wip Autocomplete

* 🚧 wip Autocomplete

* 🚧 working idea for context (get & set autocomplete)

const thinger = () => {

* 🚧 pick property on Item

* 🚧 enrich story/example of Autocomplete

show how easy it is to add icons / do something more complicated inside
the autocomplete suggestions list alongside a <Search>

* 🚧 wip

- fairly nicely working list of virtually focusable items
- API interface kept simple (moved pick to Anchor though, single "exit
  point")
- item has focus function now, which triggers when it gets virtual
  focus, the API should not distinguish between virtual and real focus
  was the thinking here, it's more an implementation detail.

* 🚧 WIP new story for Autocomplete

- showcase dropdown search autocomplete

* Update @navikt/core/react/src/form/autocomplete/Autocomplete.tsx

Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>

* Update @navikt/core/react/src/form/autocomplete/Autocomplete.tsx

Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>

* 🔥 refactor class -> attr, remove css, use user provided <style> in story

* 🔥 remove originial ideas for a prop on <Search>

* ♻️ refactor + use mouseMove over mouseOver

* 🐛 reset virtualfocus on input change (within Anchor)

* ✨ add prop 'loop' for Autocomplete

* 🚚 Autocomplete -> VirtualFocus

* 🚧 WIP on some dropdown prop API

* ♻️ rename dropdown -> autocomplete

* 🚧 WIP towards Autocomplete (inspiration from Menu)

* 🚧 WIP Autocomplete

* 🚧 wip Autocomplete

* 🚧 wip Autocomplete

* 🚧 working idea for context (get & set autocomplete)

const thinger = () => {

* 🚧 pick property on Item

* 🚧 enrich story/example of Autocomplete

show how easy it is to add icons / do something more complicated inside
the autocomplete suggestions list alongside a <Search>

* 🚧 wip

- fairly nicely working list of virtually focusable items
- API interface kept simple (moved pick to Anchor though, single "exit
  point")
- item has focus function now, which triggers when it gets virtual
  focus, the API should not distinguish between virtual and real focus
  was the thinking here, it's more an implementation detail.

* 🚧 WIP new story for Autocomplete

- showcase dropdown search autocomplete

* 🔥 refactor class -> attr, remove css, use user provided <style> in story

* Update @navikt/core/react/src/form/autocomplete/Autocomplete.tsx

Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>

* Update @navikt/core/react/src/form/autocomplete/Autocomplete.tsx

Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>

* 🔥 remove originial ideas for a prop on <Search>

* ♻️ refactor + use mouseMove over mouseOver

* 🐛 reset virtualfocus on input change (within Anchor)

* ✨ add prop 'loop' for Autocomplete

* 🚚 Autocomplete -> VirtualFocus

* 🚚 Autocomplete -> VirtualFocus

* 🔥 remove unused CSS

* 🚧 add onActive, delete unused code

* 🚧 rename CSS classes

* ♻️ simplify <VirtualFocus> impl

- use the descendants API > grabbing the DOM nodes...

* 🔥 remove the aria-activedescendant responsibility from VirtualFocus

* Update aksel.nav.no/website/sanity/config.ts

* 🔥 remove comments that no longer apply

* 🔥 remove biome.json changes from PR

* ♻️ various PR changes

- homogenize the API (so both anchor and item have the sameish props,
  onActive, pick...)
- use useId() so multiple <VirtualFocus> on the same page get unique ids

* 🚧 + ♻️ testing changes in VO

- it's not great, weird behaviour on readouts of Items... (FF completely
  broken, sometimes reads "rgla" from "hourglass", other times nothing,
  Chrome is a little better)
- is this a decent API at all? is it overcomplicated?

* 🏷️ type itemRole

* 🔥 remove empty file

* ♻️ pr comments

- renaming to better match the rest of DS
- remove itemRole (just use role)
- eslint ignore

* ⚡ do not go via descendant API if self

* 🎨 add forwardRef & ...rest to Content

* 🐛 fix lying prop doc comment, remove unused CSS class

* ♻️ use <Slot /> (hoistmerge child)

* 💄 fix broken CSS (class was removed)

* 📝 update jsdoc

* 🚚 move role to Anchor (from VirtualFocus wrapper)

* 🎨 remove filtering from broken example

* 🐛 Anchor should trigger virtualfocused.onSelect()

* 🐛 use react v17 shimmed useId()

* Update @navikt/core/react/src/form/virtualfocus/VirtualFocus.tsx

Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>

* 🚧 import

* 🎨 improve story

* 🐛 do not let consumer overwrite certain VirtualBox implementation

* 🎨 update story

* 🚚 move virtualfocus to utils

* 🎨 update to latest RovingFocus.tsx, tweaks to stories

* Update @navikt/core/react/src/util/virtualfocus/VirtualFocus.tsx

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>

* 🎨 improve docs (jsdoc), add Simple story

* 🚚 split up monster component into /parts

* 🐛 pull out dependency to remove cyclic dependency

* 🔥 RovingFocus.tsx

* 🐛 do not ignore external prop

* 🎨 simplify function calls + reorder for consistency

* 🔥 remove unused code

* 🎨 disable chromatic for internal tool stories

* 🏷️ Omit<> props that will get ignored/overridden

* 🏷️ Omit<> props that will get ignored/overridden

* 🏷️ use satisfies

---------

Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>
Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2024-06-28 15:56:54 +02:00
141 changed files with 7862 additions and 4926 deletions

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
with:
autoAcceptChanges: "main"
token: ${{ secrets.GITHUB_TOKEN }}
projectToken: x3xqdfgkujg
projectToken: chpt_1dc4af6a1c8cc48
exitZeroOnChanges: false
buildScriptName: "build:storybook-chromatic"
onlyChanged: true
+29
View File
@@ -0,0 +1,29 @@
# https://github.com/actions/stale
name: Mark stale issues
on:
schedule:
- cron: "31 3 * * *"
workflow_dispatch:
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/stale@v9
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-issue-stale: 120
days-before-pr-stale: -1
days-before-close: 30
any-of-labels: "Besvart"
stale-issue-message: |-
Hei! Det ser ut som det ikke har vært aktivitet her på en stund. Etter 30 dager vil saken bli lukket automatisk.
Om saken fortsatt er relevant eller du har oppdateringer, vil en kommentar hindre at den lukkes. Du kan alltids åpne den igjen hvis det skulle være nødvendig!
stale-issue-label: "no-issue-activity"
ascending: true
operations-per-run: 200
+8
View File
@@ -1,5 +1,13 @@
# @navikt/aksel-icons
## 6.15.0
### Minor Changes
- Icons: New icon-series for 'SplitHorizontal' and 'SplitVertical' ([#3072](https://github.com/navikt/aksel/pull/3072))
## 6.14.0
## 6.13.0
## 6.12.0
@@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.5 8H16.5V6.5H20.75C21.4404 6.5 22 7.05964 22 7.75V16.25C22 16.9404 21.4404 17.5 20.75 17.5H16.5V16H20.5V8Z" fill="#23262A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.5 8H7.5V6.5H3.25C2.55964 6.5 2 7.05964 2 7.75V16.25C2 16.9404 2.55964 17.5 3.25 17.5H7.5V16H3.5V8Z" fill="#23262A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 6C11.5858 6 11.25 5.66421 11.25 5.25V3.75C11.25 3.33579 11.5858 3 12 3C12.4142 3 12.75 3.33579 12.75 3.75V5.25C12.75 5.66421 12.4142 6 12 6Z" fill="#23262A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 11C11.5858 11 11.25 10.6642 11.25 10.25V8.75C11.25 8.33579 11.5858 8 12 8C12.4142 8 12.75 8.33579 12.75 8.75V10.25C12.75 10.6642 12.4142 11 12 11Z" fill="#23262A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 16C11.5858 16 11.25 15.6642 11.25 15.25V13.75C11.25 13.3358 11.5858 13 12 13C12.4142 13 12.75 13.3358 12.75 13.75V15.25C12.75 15.6642 12.4142 16 12 16Z" fill="#23262A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 21C11.5858 21 11.25 20.6642 11.25 20.25V18.75C11.25 18.3358 11.5858 18 12 18C12.4142 18 12.75 18.3358 12.75 18.75V20.25C12.75 20.6642 12.4142 21 12 21Z" fill="#23262A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,14 @@
name: SplitHorizontal
category: Interface
sub_category: Design
keywords:
- splitt
- split
- del
- divide
- horisontal
- horisontalt
- horizontal
variant: Stroke
updated_at: 06.08.2024
created_at: 06.08.2024
@@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 6.5H20.75C21.4404 6.5 22 7.05964 22 7.75V16.25C22 16.9404 21.4404 17.5 20.75 17.5H16V6.5Z" fill="#23262A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 6.5H3.25C2.55964 6.5 2 7.05964 2 7.75V16.25C2 16.9404 2.55964 17.5 3.25 17.5H8V6.5Z" fill="#23262A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 6C11.5858 6 11.25 5.66421 11.25 5.25V3.75C11.25 3.33579 11.5858 3 12 3C12.4142 3 12.75 3.33579 12.75 3.75V5.25C12.75 5.66421 12.4142 6 12 6Z" fill="#23262A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 11C11.5858 11 11.25 10.6642 11.25 10.25V8.75C11.25 8.33579 11.5858 8 12 8C12.4142 8 12.75 8.33579 12.75 8.75V10.25C12.75 10.6642 12.4142 11 12 11Z" fill="#23262A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 16C11.5858 16 11.25 15.6642 11.25 15.25V13.75C11.25 13.3358 11.5858 13 12 13C12.4142 13 12.75 13.3358 12.75 13.75V15.25C12.75 15.6642 12.4142 16 12 16Z" fill="#23262A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 21C11.5858 21 11.25 20.6642 11.25 20.25V18.75C11.25 18.3358 11.5858 18 12 18C12.4142 18 12.75 18.3358 12.75 18.75V20.25C12.75 20.6642 12.4142 21 12 21Z" fill="#23262A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,14 @@
name: SplitHorizontalFill
category: Interface
sub_category: Design
keywords:
- splitt
- split
- del
- divide
- horisontal
- horisontalt
- horizontal
variant: Fill
updated_at: 06.08.2024
created_at: 06.08.2024
@@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 20.5V16.5H17.5V20.75C17.5 21.4404 16.9404 22 16.25 22H7.75C7.05964 22 6.5 21.4404 6.5 20.75L6.5 16.5H8V20.5H16Z" fill="#23262A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 3.5V7.5H17.5V3.25C17.5 2.55964 16.9404 2 16.25 2L7.75 2C7.05965 2 6.5 2.55964 6.5 3.25L6.5 7.5H8L8 3.5L16 3.5Z" fill="#23262A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18 12C18 11.5858 18.3358 11.25 18.75 11.25L20.25 11.25C20.6642 11.25 21 11.5858 21 12C21 12.4142 20.6642 12.75 20.25 12.75L18.75 12.75C18.3358 12.75 18 12.4142 18 12Z" fill="#23262A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13 12C13 11.5858 13.3358 11.25 13.75 11.25H15.25C15.6642 11.25 16 11.5858 16 12C16 12.4142 15.6642 12.75 15.25 12.75H13.75C13.3358 12.75 13 12.4142 13 12Z" fill="#23262A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 12C8 11.5858 8.33579 11.25 8.75 11.25H10.25C10.6642 11.25 11 11.5858 11 12C11 12.4142 10.6642 12.75 10.25 12.75H8.75C8.33579 12.75 8 12.4142 8 12Z" fill="#23262A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 12C3 11.5858 3.33579 11.25 3.75 11.25H5.25C5.66421 11.25 6 11.5858 6 12C6 12.4142 5.66421 12.75 5.25 12.75H3.75C3.33579 12.75 3 12.4142 3 12Z" fill="#23262A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,14 @@
name: SplitVertical
category: Interface
sub_category: Design
keywords:
- splitt
- split
- del
- divide
- vertikal
- vertikalt
- vertical
variant: Stroke
updated_at: 06.08.2024
created_at: 06.08.2024
@@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.5 16V20.75C17.5 21.4404 16.9404 22 16.25 22H7.75C7.05964 22 6.5 21.4404 6.5 20.75L6.5 16H17.5Z" fill="#23262A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.5 8V3.25C17.5 2.55965 16.9404 2 16.25 2L7.75 2C7.05965 2 6.5 2.55964 6.5 3.25L6.5 8L17.5 8Z" fill="#23262A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18 12C18 11.5858 18.3358 11.25 18.75 11.25L20.25 11.25C20.6642 11.25 21 11.5858 21 12C21 12.4142 20.6642 12.75 20.25 12.75L18.75 12.75C18.3358 12.75 18 12.4142 18 12Z" fill="#23262A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13 12C13 11.5858 13.3358 11.25 13.75 11.25H15.25C15.6642 11.25 16 11.5858 16 12C16 12.4142 15.6642 12.75 15.25 12.75H13.75C13.3358 12.75 13 12.4142 13 12Z" fill="#23262A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 12C8 11.5858 8.33579 11.25 8.75 11.25H10.25C10.6642 11.25 11 11.5858 11 12C11 12.4142 10.6642 12.75 10.25 12.75H8.75C8.33579 12.75 8 12.4142 8 12Z" fill="#23262A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 12C3 11.5858 3.33579 11.25 3.75 11.25H5.25C5.66421 11.25 6 11.5858 6 12C6 12.4142 5.66421 12.75 5.25 12.75H3.75C3.33579 12.75 3 12.4142 3 12Z" fill="#23262A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,14 @@
name: SplitVerticalFill
category: Interface
sub_category: Design
keywords:
- splitt
- split
- del
- divide
- vertikal
- vertikalt
- vertical
variant: Fill
updated_at: 06.08.2024
created_at: 06.08.2024
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-icons",
"version": "6.13.0",
"version": "6.15.0",
"author": "Aksel | NAV",
"homepage": "https://aksel.nav.no/ikoner",
"repository": {
+4
View File
@@ -1,5 +1,9 @@
# @navikt/aksel-stylelint
## 6.15.0
## 6.14.0
## 6.13.0
## 6.12.0
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-stylelint",
"version": "6.13.0",
"version": "6.15.0",
"author": "Aksel | NAV",
"homepage": "https://aksel.nav.no/grunnleggende/kode/stylelint",
"repository": {
@@ -35,8 +35,8 @@
"dev": "yarn watch:lint"
},
"devDependencies": {
"@navikt/ds-css": "^6.13.0",
"@navikt/ds-tokens": "^6.13.0",
"@navikt/ds-css": "^6.15.0",
"@navikt/ds-tokens": "^6.15.0",
"concurrently": "7.2.1",
"postcss-selector-parser": "^6.0.13",
"postcss-value-parser": "^4.2.0",
+14
View File
@@ -1,5 +1,19 @@
# @navikt/aksel
## 6.15.0
### Patch Changes
- Updated dependencies []:
- @navikt/ds-css@6.15.0
## 6.14.0
### Patch Changes
- Updated dependencies [[`8fd183cc9`](https://github.com/navikt/aksel/commit/8fd183cc909a1f27a0a9f496415d280c1207a479)]:
- @navikt/ds-css@6.14.0
## 6.13.0
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel",
"version": "6.13.0",
"version": "6.15.0",
"description": "Aksel command line interface. Handles css-imports, codemods and more",
"author": "Aksel | NAV designsystem team",
"license": "MIT",
@@ -29,7 +29,7 @@
},
"homepage": "https://aksel.nav.no/grunnleggende/kode/kommandolinje",
"dependencies": {
"@navikt/ds-css": "6.13.0",
"@navikt/ds-css": "6.15.0",
"axios": "1.6.0",
"chalk": "4.1.0",
"clipboardy": "^2.3.0",
+8
View File
@@ -1,5 +1,13 @@
# @navikt/ds-css
## 6.15.0
## 6.14.0
### Minor Changes
- Fonts: Added built-in support for cyrillic, cyrillic-ext and latin-ext ([#3044](https://github.com/navikt/aksel/pull/3044))
## 6.13.0
### Minor Changes
+1 -1
View File
@@ -17,7 +17,7 @@
body,
:host {
font-family: var(--a-font-family, "Source Sans Pro", Arial, sans-serif);
font-family: var(--a-font-family, "Source Sans 3", "Source Sans Pro", Arial, sans-serif);
line-height: 1.333;
font-size: 1.125rem;
}
+109 -8
View File
@@ -1,28 +1,129 @@
/* cyrillic-ext */
@font-face {
font-family: "Source Sans 3";
font-style: italic;
font-weight: 400 600;
font-display: swap;
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/italic-cyrillic-ext.woff2) format("woff2");
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: "Source Sans 3";
font-style: italic;
font-weight: 400 600;
font-display: swap;
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/italic-cyrillic.woff2) format("woff2");
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* latin-ext */
@font-face {
font-family: "Source Sans 3";
font-style: italic;
font-weight: 400 600;
font-display: swap;
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/italic-latin-ext.woff2) format("woff2");
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: "Source Sans 3";
font-style: italic;
font-weight: 400 600;
font-display: swap;
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/italic-latin.woff2) format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: "Source Sans 3";
font-style: normal;
font-weight: 400 600;
font-display: swap;
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/normal-cyrillic-ext.woff2) format("woff2");
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: "Source Sans 3";
font-style: normal;
font-weight: 400 600;
font-display: swap;
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/normal-cyrillic.woff2) format("woff2");
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* latin-ext */
@font-face {
font-family: "Source Sans 3";
font-style: normal;
font-weight: 400 600;
font-display: swap;
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/normal-latin-ext.woff2) format("woff2");
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: "Source Sans 3";
font-style: normal;
font-weight: 400 600;
font-display: swap;
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/normal-latin.woff2) format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Legacy support */
/* latin-ext */
@font-face {
font-family: "Source Sans Pro";
font-style: italic;
font-weight: 400;
font-weight: 400 600;
font-display: swap;
src: url("https://cdn.nav.no/aksel/fonts/SourceSans3-italic.woff2") format("woff2");
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/italic-latin-ext.woff2) format("woff2");
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: "Source Sans Pro";
font-style: italic;
font-weight: 600;
font-weight: 400 600;
font-display: swap;
src: url("https://cdn.nav.no/aksel/fonts/SourceSans3-italic-latin-semibold.woff2") format("woff2");
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/italic-latin.woff2) format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: "Source Sans Pro";
font-style: normal;
font-weight: 400;
font-weight: 400 600;
font-display: swap;
src: url("https://cdn.nav.no/aksel/fonts/SourceSans3-normal.woff2") format("woff2");
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/normal-latin-ext.woff2) format("woff2");
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: "Source Sans Pro";
font-style: normal;
font-weight: 600;
font-weight: 400 600;
font-display: swap;
src: url("https://cdn.nav.no/aksel/fonts/SourceSans3-normal.woff2") format("woff2");
src: url(https://cdn.nav.no/aksel/fonts/SourceSans3/normal-latin.woff2) format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-css",
"version": "6.13.0",
"version": "6.15.0",
"description": "CSS for NAV Designsystem",
"author": "Aksel | NAV designsystem team",
"keywords": [
@@ -27,7 +27,7 @@
"css:get-version": "node config/get-version.js"
},
"devDependencies": {
"@navikt/ds-tokens": "^6.13.0",
"@navikt/ds-tokens": "^6.15.0",
"cssnano": "6.0.0",
"fast-glob": "3.2.11",
"lodash": "4.17.21",
+26 -3
View File
@@ -6,6 +6,12 @@
--__ac-hgrid-columns-xl: var(--__ac-hgrid-columns-lg);
--__ac-hgrid-columns-2xl: var(--__ac-hgrid-columns-xl);
--__ac-hgrid-columns: var(--__ac-hgrid-columns-xs);
display: grid;
grid-template-columns: var(--__ac-hgrid-columns);
}
.navds-hgrid-gap {
--__ac-hgrid-gap-xs: initial;
--__ac-hgrid-gap-sm: var(--__ac-hgrid-gap-xs);
--__ac-hgrid-gap-md: var(--__ac-hgrid-gap-sm);
@@ -13,17 +19,22 @@
--__ac-hgrid-gap-xl: var(--__ac-hgrid-gap-lg);
--__ac-hgrid-gap-2xl: var(--__ac-hgrid-gap-xl);
--__ac-hgrid-gap: var(--__ac-hgrid-gap-xs);
gap: var(--__ac-hgrid-gap);
}
.navds-hgrid-align {
--__ac-hgrid-align: initial;
display: grid;
grid-template-columns: var(--__ac-hgrid-columns);
gap: var(--__ac-hgrid-gap);
align-items: var(--__ac-hgrid-align);
}
@media (min-width: 480px) {
.navds-hgrid {
--__ac-hgrid-columns: var(--__ac-hgrid-columns-sm);
}
.navds-hgrid-gap {
--__ac-hgrid-gap: var(--__ac-hgrid-gap-sm);
}
}
@@ -31,6 +42,9 @@
@media (min-width: 768px) {
.navds-hgrid {
--__ac-hgrid-columns: var(--__ac-hgrid-columns-md);
}
.navds-hgrid-gap {
--__ac-hgrid-gap: var(--__ac-hgrid-gap-md);
}
}
@@ -38,6 +52,9 @@
@media (min-width: 1024px) {
.navds-hgrid {
--__ac-hgrid-columns: var(--__ac-hgrid-columns-lg);
}
.navds-hgrid-gap {
--__ac-hgrid-gap: var(--__ac-hgrid-gap-lg);
}
}
@@ -45,6 +62,9 @@
@media (min-width: 1280px) {
.navds-hgrid {
--__ac-hgrid-columns: var(--__ac-hgrid-columns-xl);
}
.navds-hgrid-gap {
--__ac-hgrid-gap: var(--__ac-hgrid-gap-xl);
}
}
@@ -52,6 +72,9 @@
@media (min-width: 1440px) {
.navds-hgrid {
--__ac-hgrid-columns: var(--__ac-hgrid-columns-2xl);
}
.navds-hgrid-gap {
--__ac-hgrid-gap: var(--__ac-hgrid-gap-2xl);
}
}
+81 -18
View File
@@ -1,5 +1,8 @@
.navds-stack {
--__ac-stack-wrap: initial;
display: flex;
}
.navds-stack-gap {
--__ac-stack-gap-xs: initial;
--__ac-stack-gap-sm: var(--__ac-stack-gap-xs);
--__ac-stack-gap-md: var(--__ac-stack-gap-sm);
@@ -7,13 +10,11 @@
--__ac-stack-gap-xl: var(--__ac-stack-gap-lg);
--__ac-stack-gap-2xl: var(--__ac-stack-gap-xl);
--__ac-stack-gap: var(--__ac-stack-gap-xs);
--__ac-stack-justify-xs: initial;
--__ac-stack-justify-sm: var(--__ac-stack-justify-xs);
--__ac-stack-justify-md: var(--__ac-stack-justify-sm);
--__ac-stack-justify-lg: var(--__ac-stack-justify-md);
--__ac-stack-justify-xl: var(--__ac-stack-justify-lg);
--__ac-stack-justify-2xl: var(--__ac-stack-justify-xl);
--__ac-stack-justify: var(--__ac-stack-justify-xs);
gap: var(--__ac-stack-gap);
}
.navds-stack-align {
--__ac-stack-align-xs: initial;
--__ac-stack-align-sm: var(--__ac-stack-align-xs);
--__ac-stack-align-md: var(--__ac-stack-align-sm);
@@ -21,6 +22,23 @@
--__ac-stack-align-xl: var(--__ac-stack-align-lg);
--__ac-stack-align-2xl: var(--__ac-stack-align-xl);
--__ac-stack-align: var(--__ac-stack-align-xs);
align-items: var(--__ac-stack-align);
}
.navds-stack-justify {
--__ac-stack-justify-xs: initial;
--__ac-stack-justify-sm: var(--__ac-stack-justify-xs);
--__ac-stack-justify-md: var(--__ac-stack-justify-sm);
--__ac-stack-justify-lg: var(--__ac-stack-justify-md);
--__ac-stack-justify-xl: var(--__ac-stack-justify-lg);
--__ac-stack-justify-2xl: var(--__ac-stack-justify-xl);
--__ac-stack-justify: var(--__ac-stack-justify-xs);
justify-content: var(--__ac-stack-justify);
}
.navds-stack-direction {
--__ac-stack-direction-xs: initial;
--__ac-stack-direction-sm: var(--__ac-stack-direction-xs);
--__ac-stack-direction-md: var(--__ac-stack-direction-sm);
@@ -29,11 +47,11 @@
--__ac-stack-direction-2xl: var(--__ac-stack-direction-xl);
--__ac-stack-direction: var(--__ac-stack-direction-xs);
gap: var(--__ac-stack-gap);
display: flex;
align-items: var(--__ac-stack-align);
justify-content: var(--__ac-stack-justify);
flex-flow: var(--__ac-stack-direction) var(--__ac-stack-wrap);
flex-direction: var(--__ac-stack-direction);
}
.navds-stack-wrap {
flex-wrap: wrap;
}
.navds-stack__spacer {
@@ -48,46 +66,91 @@
}
@media (min-width: 480px) {
.navds-stack {
.navds-stack-gap {
--__ac-stack-gap: var(--__ac-stack-gap-sm);
}
.navds-stack-align {
--__ac-stack-align: var(--__ac-stack-align-sm);
}
.navds-stack-justify {
--__ac-stack-justify: var(--__ac-stack-justify-sm);
}
.navds-stack-direction {
--__ac-stack-direction: var(--__ac-stack-direction-sm);
}
}
@media (min-width: 768px) {
.navds-stack {
.navds-stack-gap {
--__ac-stack-gap: var(--__ac-stack-gap-md);
}
.navds-stack-align {
--__ac-stack-align: var(--__ac-stack-align-md);
}
.navds-stack-justify {
--__ac-stack-justify: var(--__ac-stack-justify-md);
}
.navds-stack-direction {
--__ac-stack-direction: var(--__ac-stack-direction-md);
}
}
@media (min-width: 1024px) {
.navds-stack {
.navds-stack-gap {
--__ac-stack-gap: var(--__ac-stack-gap-lg);
}
.navds-stack-align {
--__ac-stack-align: var(--__ac-stack-align-lg);
}
.navds-stack-justify {
--__ac-stack-justify: var(--__ac-stack-justify-lg);
}
.navds-stack-direction {
--__ac-stack-direction: var(--__ac-stack-direction-lg);
}
}
@media (min-width: 1280px) {
.navds-stack {
.navds-stack-gap {
--__ac-stack-gap: var(--__ac-stack-gap-xl);
}
.navds-stack-align {
--__ac-stack-align: var(--__ac-stack-align-xl);
}
.navds-stack-justify {
--__ac-stack-justify: var(--__ac-stack-justify-xl);
}
.navds-stack-direction {
--__ac-stack-direction: var(--__ac-stack-direction-xl);
}
}
@media (min-width: 1440px) {
.navds-stack {
.navds-stack-gap {
--__ac-stack-gap: var(--__ac-stack-gap-2xl);
}
.navds-stack-align {
--__ac-stack-align: var(--__ac-stack-align-2xl);
}
.navds-stack-justify {
--__ac-stack-justify: var(--__ac-stack-justify-2xl);
}
.navds-stack-direction {
--__ac-stack-direction: var(--__ac-stack-direction-2xl);
}
}
+36
View File
@@ -1,5 +1,41 @@
# @navikt/ds-react
## 6.15.0
### Patch Changes
- Combobox: Can no longer remove options with backspace when shouldShowSelectedOptions is false ([#3076](https://github.com/navikt/aksel/pull/3076))
- Refactored Combobox FilteredOptions ([#3073](https://github.com/navikt/aksel/pull/3073))
- Updated dependencies [[`ea3e88146`](https://github.com/navikt/aksel/commit/ea3e881467d27d43464ff9d111369a6b8539f593)]:
- @navikt/aksel-icons@6.15.0
- @navikt/ds-tokens@6.15.0
## 6.14.0
### Minor Changes
- Breaking change: Combobox.onChange now receive only value as argument, instead of ChangeEvent ([#2974](https://github.com/navikt/aksel/pull/2974))
- Improved search in Combobox - find hits anywhere in the label ([#2974](https://github.com/navikt/aksel/pull/2974))
### Patch Changes
- Tabs, ToggleGroup: Rovingfocus now respects shift, alt, ctrl and meta-keys when navigating. ([#3041](https://github.com/navikt/aksel/pull/3041))
- Prevent NullPointer when adding a new/custom option in Combobox single-select ([#3054](https://github.com/navikt/aksel/pull/3054))
- Combobox: Entering an already selected option and pressing enter no longer removes it ([#3061](https://github.com/navikt/aksel/pull/3061))
- Combobox: Description is now connected to the input field via aria-describedby ([#3065](https://github.com/navikt/aksel/pull/3065))
- Prevent Escape in open Combobox from closing Modals ([#3053](https://github.com/navikt/aksel/pull/3053))
- Updated dependencies [[`8fd183cc9`](https://github.com/navikt/aksel/commit/8fd183cc909a1f27a0a9f496415d280c1207a479)]:
- @navikt/ds-tokens@6.14.0
- @navikt/aksel-icons@6.14.0
## 6.13.0
### Minor Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-react",
"version": "6.13.0",
"version": "6.15.0",
"description": "React components from the Norwegian Labour and Welfare Administration.",
"author": "Aksel, a team part of the Norwegian Labour and Welfare Administration.",
"license": "MIT",
@@ -594,8 +594,8 @@
"dependencies": {
"@floating-ui/react": "0.25.4",
"@floating-ui/react-dom": "^2.0.9",
"@navikt/aksel-icons": "^6.13.0",
"@navikt/ds-tokens": "^6.13.0",
"@navikt/aksel-icons": "^6.15.0",
"@navikt/ds-tokens": "^6.15.0",
"clsx": "^2.1.0",
"date-fns": "^3.0.0",
"react-day-picker": "8.10.0"
@@ -61,28 +61,28 @@ export const Controls: Story = {
variant: {
control: {
type: "radio",
options: [
"primary",
"secondary",
"tertiary",
"danger",
"primary-neutral",
"secondary-neutral",
"tertiary-neutral",
],
},
options: [
"primary",
"secondary",
"tertiary",
"danger",
"primary-neutral",
"secondary-neutral",
"tertiary-neutral",
],
},
size: {
control: {
type: "radio",
options: ["medium", "small", "xsmall"],
},
options: ["medium", "small", "xsmall"],
},
iconPosition: {
control: {
type: "radio",
options: ["left", "right"],
},
options: ["left", "right"],
},
},
};
@@ -10,7 +10,10 @@ import { ComboboxProps } from "./types";
export const Combobox = forwardRef<
HTMLInputElement,
Omit<ComboboxProps, "onChange" | "options" | "size" | "onClear" | "value">
Omit<
ComboboxProps,
"onChange" | "options" | "size" | "onClear" | "value" | "disabled"
>
>((props, ref) => {
const { className, hideLabel = false, description, label, ...rest } = props;
@@ -35,6 +35,7 @@ const ComboboxProvider = forwardRef<HTMLInputElement, ComboboxProps>(
allowNewValues = false,
children,
defaultValue,
disabled,
error,
errorId,
filteredOptions: externalFilteredOptions,
@@ -60,6 +61,8 @@ const ComboboxProvider = forwardRef<HTMLInputElement, ComboboxProps>(
<InputContextProvider
value={{
defaultValue,
description: rest.description,
disabled,
error,
errorId,
id,
@@ -0,0 +1,63 @@
import cl from "clsx";
import React from "react";
import { PlusIcon } from "@navikt/aksel-icons";
import { BodyShort, Label } from "../../../typography";
import { useInputContext } from "../Input/Input.context";
import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
import { isInList, toComboboxOption } from "../combobox-utils";
import filteredOptionsUtil from "./filtered-options-util";
import { useFilteredOptionsContext } from "./filteredOptionsContext";
const AddNewOption = () => {
const {
inputProps: { id },
size,
value,
} = useInputContext();
const {
setIsMouseLastUsedInputDevice,
toggleIsListOpen,
activeDecendantId,
virtualFocus,
} = useFilteredOptionsContext();
const { isMultiSelect, selectedOptions, toggleOption } =
useSelectedOptionsContext();
return (
<li
tabIndex={-1}
onMouseMove={() => {
if (activeDecendantId !== filteredOptionsUtil.getAddNewOptionId(id)) {
virtualFocus.moveFocusToElement(
filteredOptionsUtil.getAddNewOptionId(id),
);
setIsMouseLastUsedInputDevice(true);
}
}}
onPointerUp={(event) => {
toggleOption(toComboboxOption(value), event);
if (!isMultiSelect && !isInList(value, selectedOptions))
toggleIsListOpen(false);
}}
id={filteredOptionsUtil.getAddNewOptionId(id)}
className={cl(
"navds-combobox__list-item navds-combobox__list-item--new-option",
{
"navds-combobox__list-item--new-option--focus":
activeDecendantId === filteredOptionsUtil.getAddNewOptionId(id),
},
)}
role="option"
aria-selected={false}
>
<PlusIcon aria-hidden />
<BodyShort size={size}>
Legg til{" "}
<Label as="span" size={size}>
&#8220;{value}&#8221;
</Label>
</BodyShort>
</li>
);
};
export default AddNewOption;
@@ -1,20 +1,18 @@
import cl from "clsx";
import React from "react";
import { CheckmarkIcon, PlusIcon } from "@navikt/aksel-icons";
import { Loader } from "../../../loader";
import { BodyShort, Label } from "../../../typography";
import { useInputContext } from "../Input/Input.context";
import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
import { isInList, toComboboxOption } from "../combobox-utils";
import { ComboboxOption } from "../types";
import AddNewOption from "./AddNewOption";
import FilteredOptionsItem from "./FilteredOptionsItem";
import LoadingMessage from "./LoadingMessage";
import MaxSelectedMessage from "./MaxSelectedMessage";
import NoSearchHitsMessage from "./NoSearchHitsMessage";
import filteredOptionsUtil from "./filtered-options-util";
import { useFilteredOptionsContext } from "./filteredOptionsContext";
const FilteredOptions = () => {
const {
inputProps: { id },
size,
value,
} = useInputContext();
const {
allowNewValues,
@@ -23,17 +21,9 @@ const FilteredOptions = () => {
filteredOptions,
setFilteredOptionsRef,
isMouseLastUsedInputDevice,
setIsMouseLastUsedInputDevice,
isValueNew,
toggleIsListOpen,
activeDecendantId,
virtualFocus,
} = useFilteredOptionsContext();
const { isMultiSelect, selectedOptions, toggleOption, maxSelected } =
useSelectedOptionsContext();
const isDisabled = (option: ComboboxOption) =>
maxSelected?.isLimitReached && !isInList(option.value, selectedOptions);
const { maxSelected } = useSelectedOptionsContext();
const shouldRenderNonSelectables =
maxSelected?.isLimitReached || // Render maxSelected message
@@ -55,30 +45,10 @@ const FilteredOptions = () => {
>
{shouldRenderNonSelectables && (
<div className="navds-combobox__list_non-selectables" role="status">
{maxSelected?.isLimitReached && (
<div
className="navds-combobox__list-item--max-selected"
id={filteredOptionsUtil.getMaxSelectedOptionsId(id)}
>
{maxSelected.message ??
`${selectedOptions.length} av ${maxSelected.limit} er valgt.`}
</div>
)}
{isLoading && (
<div
className="navds-combobox__list-item--loading"
id={filteredOptionsUtil.getIsLoadingId(id)}
>
<Loader title="Søker..." />
</div>
)}
{maxSelected?.isLimitReached && <MaxSelectedMessage />}
{isLoading && <LoadingMessage />}
{!isLoading && filteredOptions.length === 0 && !allowNewValues && (
<div
className="navds-combobox__list-item--no-options"
id={filteredOptionsUtil.getNoHitsId(id)}
>
Ingen søketreff
</div>
<NoSearchHitsMessage />
)}
</div>
)}
@@ -90,90 +60,10 @@ const FilteredOptions = () => {
className="navds-combobox__list-options"
>
{isValueNew && !maxSelected?.isLimitReached && allowNewValues && (
<li
tabIndex={-1}
onMouseMove={() => {
if (
activeDecendantId !==
filteredOptionsUtil.getAddNewOptionId(id)
) {
virtualFocus.moveFocusToElement(
filteredOptionsUtil.getAddNewOptionId(id),
);
setIsMouseLastUsedInputDevice(true);
}
}}
onPointerUp={(event) => {
toggleOption(toComboboxOption(value), event);
if (!isMultiSelect && !isInList(value, selectedOptions))
toggleIsListOpen(false);
}}
id={filteredOptionsUtil.getAddNewOptionId(id)}
className={cl(
"navds-combobox__list-item navds-combobox__list-item--new-option",
{
"navds-combobox__list-item--new-option--focus":
activeDecendantId ===
filteredOptionsUtil.getAddNewOptionId(id),
},
)}
role="option"
aria-selected={false}
>
<PlusIcon aria-hidden />
<BodyShort size={size}>
Legg til{" "}
<Label as="span" size={size}>
&#8220;{value}&#8221;
</Label>
</BodyShort>
</li>
<AddNewOption />
)}
{filteredOptions.map((option) => (
<li
className={cl("navds-combobox__list-item", {
"navds-combobox__list-item--focus":
activeDecendantId ===
filteredOptionsUtil.getOptionId(id, option.label),
"navds-combobox__list-item--selected": isInList(
option.value,
selectedOptions,
),
})}
data-no-focus={isDisabled(option) || undefined}
id={filteredOptionsUtil.getOptionId(id, option.label)}
key={option.label}
tabIndex={-1}
onMouseMove={() => {
if (
activeDecendantId !==
filteredOptionsUtil.getOptionId(id, option.label)
) {
virtualFocus.moveFocusToElement(
filteredOptionsUtil.getOptionId(id, option.label),
);
setIsMouseLastUsedInputDevice(true);
}
}}
onPointerUp={(event) => {
if (isDisabled(option)) {
return;
}
toggleOption(option, event);
if (
!isMultiSelect &&
!isInList(option.value, selectedOptions)
) {
toggleIsListOpen(false);
}
}}
role="option"
aria-selected={isInList(option.value, selectedOptions)}
aria-disabled={isDisabled(option) || undefined}
>
<BodyShort size={size}>{option.label}</BodyShort>
{isInList(option.value, selectedOptions) && <CheckmarkIcon />}
</li>
<FilteredOptionsItem key={option.value} option={option} />
))}
</ul>
)}
@@ -0,0 +1,73 @@
import cl from "clsx";
import React from "react";
import { CheckmarkIcon } from "@navikt/aksel-icons";
import { BodyShort } from "../../../typography";
import { useInputContext } from "../Input/Input.context";
import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
import { isInList } from "../combobox-utils";
import { ComboboxOption } from "../types";
import filteredOptionsUtil from "./filtered-options-util";
import { useFilteredOptionsContext } from "./filteredOptionsContext";
const FilteredOptionsItem = ({ option }: { option: ComboboxOption }) => {
const {
inputProps: { id },
size,
} = useInputContext();
const {
setIsMouseLastUsedInputDevice,
toggleIsListOpen,
activeDecendantId,
virtualFocus,
} = useFilteredOptionsContext();
const { isMultiSelect, maxSelected, selectedOptions, toggleOption } =
useSelectedOptionsContext();
const isDisabled = (_option: ComboboxOption) =>
maxSelected?.isLimitReached && !isInList(_option.value, selectedOptions);
return (
<li
className={cl("navds-combobox__list-item", {
"navds-combobox__list-item--focus":
activeDecendantId ===
filteredOptionsUtil.getOptionId(id, option.label),
"navds-combobox__list-item--selected": isInList(
option.value,
selectedOptions,
),
})}
data-no-focus={isDisabled(option) || undefined}
id={filteredOptionsUtil.getOptionId(id, option.label)}
key={option.label}
tabIndex={-1}
onMouseMove={() => {
if (
activeDecendantId !==
filteredOptionsUtil.getOptionId(id, option.label)
) {
virtualFocus.moveFocusToElement(
filteredOptionsUtil.getOptionId(id, option.label),
);
setIsMouseLastUsedInputDevice(true);
}
}}
onPointerUp={(event) => {
if (isDisabled(option)) {
return;
}
toggleOption(option, event);
if (!isMultiSelect && !isInList(option.value, selectedOptions)) {
toggleIsListOpen(false);
}
}}
role="option"
aria-selected={isInList(option.value, selectedOptions)}
aria-disabled={isDisabled(option) || undefined}
>
<BodyShort size={size}>{option.label}</BodyShort>
{isInList(option.value, selectedOptions) && <CheckmarkIcon />}
</li>
);
};
export default FilteredOptionsItem;
@@ -0,0 +1,20 @@
import React from "react";
import { Loader } from "../../../loader";
import { useInputContext } from "../Input/Input.context";
import filteredOptionsUtil from "./filtered-options-util";
const LoadingMessage = () => {
const {
inputProps: { id },
} = useInputContext();
return (
<div
className="navds-combobox__list-item--loading"
id={filteredOptionsUtil.getIsLoadingId(id)}
>
<Loader title="Søker..." />
</div>
);
};
export default LoadingMessage;
@@ -0,0 +1,27 @@
import React from "react";
import { useInputContext } from "../Input/Input.context";
import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
import filteredOptionsUtil from "./filtered-options-util";
const MaxSelectedMessage = () => {
const {
inputProps: { id },
} = useInputContext();
const { maxSelected, selectedOptions } = useSelectedOptionsContext();
if (!maxSelected) {
return null;
}
return (
<div
className="navds-combobox__list-item--max-selected"
id={filteredOptionsUtil.getMaxSelectedOptionsId(id)}
>
{maxSelected.message ??
`${selectedOptions.length} av ${maxSelected.limit} er valgt.`}
</div>
);
};
export default MaxSelectedMessage;
@@ -0,0 +1,19 @@
import React from "react";
import { useInputContext } from "../Input/Input.context";
import filteredOptionsUtil from "./filtered-options-util";
const NoSearchHitsMessage = () => {
const {
inputProps: { id },
} = useInputContext();
return (
<div
className="navds-combobox__list-item--no-options"
id={filteredOptionsUtil.getNoHitsId(id)}
>
Ingen søketreff
</div>
);
};
export default NoSearchHitsMessage;
@@ -4,11 +4,18 @@ const normalizeText = (text: string): string =>
typeof text === "string" ? text.toLocaleLowerCase().trim() : "";
const isPartOfText = (value: string, text: string) =>
normalizeText(text).startsWith(normalizeText(value ?? ""));
normalizeText(text).includes(normalizeText(value ?? ""));
const getMatchingValuesFromList = (value: string, list: ComboboxOption[]) =>
list.filter((listItem) => isPartOfText(value, listItem.label));
const getFirstValueStartingWith = (text: string, list: ComboboxOption[]) => {
const normalizedText = normalizeText(text);
return list.find((listItem) =>
normalizeText(listItem.label).startsWith(normalizedText),
);
};
const getFilteredOptionsId = (comboboxId: string) =>
`${comboboxId}-filtered-options`;
@@ -37,4 +44,5 @@ export default {
getIsLoadingId,
getNoHitsId,
getMaxSelectedOptionsId,
getFirstValueStartingWith,
};
@@ -106,16 +106,19 @@ const FilteredOptionsProvider = ({
}, [allowNewValues, customOptions, id, options, value]);
useClientLayoutEffect(() => {
const autoCompleteCandidate =
filteredOptionsUtils.getFirstValueStartingWith(
searchTerm,
filteredOptions,
)?.label;
if (
shouldAutocomplete &&
filteredOptionsUtils.normalizeText(searchTerm) !== "" &&
(previousSearchTerm?.length || 0) < searchTerm.length &&
filteredOptions.length > 0
autoCompleteCandidate &&
(previousSearchTerm?.length || 0) < searchTerm.length
) {
setValue(
`${searchTerm}${filteredOptions[0].label.substring(searchTerm.length)}`,
`${searchTerm}${autoCompleteCandidate.substring(searchTerm.length)}`,
);
setSearchTerm(searchTerm);
}
}, [
filteredOptions,
@@ -1,11 +1,4 @@
import React, {
ChangeEvent,
ChangeEventHandler,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { createContext } from "../../../util/create-context";
import { useClientLayoutEffect } from "../../../util/hooks";
import { FormFieldType, useFormField } from "../../useFormField";
@@ -13,12 +6,12 @@ import { ComboboxProps } from "../types";
interface InputContextValue extends FormFieldType {
clearInput: NonNullable<ComboboxProps["onClear"]>;
error?: string;
error?: ComboboxProps["error"];
focusInput: () => void;
inputRef: React.RefObject<HTMLInputElement>;
value: string;
setValue: (text: string) => void;
onChange: ChangeEventHandler<HTMLInputElement>;
onChange: (newValue: string) => void;
searchTerm: string;
setSearchTerm: React.Dispatch<React.SetStateAction<string>>;
shouldAutocomplete?: boolean;
@@ -31,7 +24,24 @@ const [InputContextProvider, useInputContext] =
errorMessage: "useInputContext must be used within an InputContextProvider",
});
const InputProvider = ({ children, value: props }) => {
interface Props {
children: React.ReactNode;
value: {
defaultValue: ComboboxProps["defaultValue"];
description: ComboboxProps["description"];
disabled: ComboboxProps["disabled"];
error: ComboboxProps["error"];
errorId: ComboboxProps["errorId"];
id: ComboboxProps["id"];
value: ComboboxProps["value"];
onChange: ComboboxProps["onChange"];
onClear: ComboboxProps["onClear"];
shouldAutocomplete: ComboboxProps["shouldAutocomplete"];
size: ComboboxProps["size"];
};
}
const InputProvider = ({ children, value: props }: Props) => {
const {
defaultValue = "",
description,
@@ -68,30 +78,22 @@ const InputProvider = ({ children, value: props }) => {
const [searchTerm, setSearchTerm] = useState(value);
const onChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const newValue = event.currentTarget.value;
(newValue: string) => {
externalValue ?? setInternalValue(newValue);
externalOnChange?.(event);
setSearchTerm(newValue);
externalOnChange?.(newValue);
},
[externalValue, externalOnChange],
);
const setValue = useCallback(
(text) => {
setInternalValue(text);
},
[setInternalValue],
);
const clearInput = useCallback(
(event: React.PointerEvent | React.KeyboardEvent | React.MouseEvent) => {
onClear?.(event);
externalOnChange?.(null, "");
setValue("");
externalOnChange?.("");
setInternalValue("");
setSearchTerm("");
},
[externalOnChange, onClear, setValue],
[externalOnChange, onClear, setInternalValue],
);
const focusInput = useCallback(() => {
@@ -111,7 +113,7 @@ const InputProvider = ({ children, value: props }) => {
focusInput,
inputRef,
value,
setValue,
setValue: setInternalValue,
onChange,
searchTerm,
setSearchTerm,
@@ -1,26 +1,38 @@
import cl from "clsx";
import React, {
ChangeEvent,
InputHTMLAttributes,
forwardRef,
useCallback,
useRef,
} from "react";
import { omit } from "../../../util";
import { useMergeRefs } from "../../../util/hooks";
import filteredOptionsUtil from "../FilteredOptions/filtered-options-util";
import { useFilteredOptionsContext } from "../FilteredOptions/filteredOptionsContext";
import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
import { useInputContext } from "./Input.context";
interface InputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "value"> {
extends Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "disabled"> {
ref: React.Ref<HTMLInputElement>;
inputClassName?: string;
shouldShowSelectedOptions?: boolean;
value?: string;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ inputClassName, ...rest }, ref) => {
const { clearInput, inputProps, onChange, size, value } = useInputContext();
({ inputClassName, shouldShowSelectedOptions, ...rest }, ref) => {
const internalRef = useRef<HTMLInputElement>(null);
const mergedRefs = useMergeRefs(ref, internalRef);
const {
clearInput,
inputProps,
onChange,
size,
value,
searchTerm,
setValue,
} = useInputContext();
const {
selectedOptions,
removeSelectedOption,
@@ -56,7 +68,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
if (!isMultiSelect && !isTextInSelectedOptions(currentOption.label)) {
toggleIsListOpen(false);
}
} else if (shouldAutocomplete && isTextInSelectedOptions(value)) {
} else if (isTextInSelectedOptions(value)) {
event.preventDefault();
// Trying to set the same value that is already set, so just clearing the input
clearInput(event);
@@ -67,13 +79,13 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
allowNewValues && isValueNew
? { label: value, value }
: filteredOptions[0];
if (!selectedValue) {
return;
}
toggleOption(selectedValue, event);
if (
!isMultiSelect &&
!isTextInSelectedOptions(
filteredOptions[0].label || selectedValue.label,
)
) {
if (!isMultiSelect && !isTextInSelectedOptions(selectedValue.label)) {
toggleIsListOpen(false);
}
}
@@ -96,10 +108,6 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
e.preventDefault();
switch (e.key) {
case "Escape":
clearInput(e);
toggleIsListOpen(false);
break;
case "Enter":
case "Accept":
onEnter(e);
@@ -118,10 +126,10 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
};
const handleKeyDown = useCallback(
(e) => {
(e: React.KeyboardEvent<HTMLInputElement>) => {
setIsMouseLastUsedInputDevice(false);
if (e.key === "Backspace") {
if (value === "") {
if (value === "" && shouldShowSelectedOptions) {
const lastSelectedOption =
selectedOptions[selectedOptions.length - 1];
if (lastSelectedOption) {
@@ -132,17 +140,34 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
if (activeDecendantId || value) {
e.preventDefault();
}
} else if (e.key === "ArrowDown") {
// Check that cursor position is at the end of the input field,
// so we don't interfere with text editing
if (e.target.selectionStart === value?.length) {
e.preventDefault();
if (virtualFocus.activeElement === null || !isListOpen) {
toggleIsListOpen(true);
}
virtualFocus.moveFocusDown();
} else if (e.key === "Escape") {
if (isListOpen || value) {
e.preventDefault(); // Prevents closing an encasing Modal, as Combobox reacts on keyup.
clearInput(e);
toggleIsListOpen(false);
}
} else if (["ArrowLeft", "ArrowRight"].includes(e.key)) {
/**
* In case user has an active selection and 'completes' the selection with ArrowLeft or ArrowRight
* we need to make sure to update the filter.
*/
if (value !== "" && value !== searchTerm) {
onChange(value);
}
} else if (e.key === "ArrowDown") {
// Reset the value to the search term to cancel autocomplete
// if the user moves focus down to the FilteredOptions
if (value !== searchTerm) {
setValue(searchTerm);
}
if (virtualFocus.activeElement === null || !isListOpen) {
toggleIsListOpen(true);
}
virtualFocus.moveFocusDown();
} else if (e.key === "ArrowUp") {
if (value !== "" && value !== searchTerm) {
onChange(value);
}
// Check that the FilteredOptions list is open and has virtual focus.
// Otherwise ignore keystrokes, so it doesn't interfere with text editing
if (isListOpen && activeDecendantId) {
@@ -161,13 +186,18 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
isListOpen,
activeDecendantId,
setIsMouseLastUsedInputDevice,
clearInput,
toggleIsListOpen,
onChange,
virtualFocus,
setValue,
searchTerm,
shouldShowSelectedOptions,
],
);
const onChangeHandler = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
(event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value;
if (newValue && newValue !== "") {
toggleIsListOpen(true);
@@ -175,7 +205,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
toggleIsListOpen(false);
}
virtualFocus.moveFocusToTop();
onChange(event);
onChange(newValue);
},
[filteredOptions.length, virtualFocus, onChange, toggleIsListOpen],
);
@@ -184,10 +214,11 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
<input
{...rest}
{...omit(inputProps, ["aria-invalid"])}
ref={ref}
ref={mergedRefs}
value={value}
onBlur={() => virtualFocus.moveFocusToTop()}
onChange={onChangeHandler}
onClick={() => value !== searchTerm && onChange(value)}
onInput={onChangeHandler}
type="text"
role="combobox"
onKeyUp={handleKeyUp}
@@ -24,6 +24,7 @@ export const InputController = forwardRef<
| "size"
| "onClear"
| "value"
| "disabled"
>
>((props, ref) => {
const {
@@ -72,6 +73,7 @@ export const InputController = forwardRef<
id={inputProps.id}
ref={mergedInputRef}
inputClassName={inputClassName}
shouldShowSelectedOptions={shouldShowSelectedOptions}
{...rest}
/>
</SelectedOptions>
@@ -8,6 +8,7 @@ import { UNSAFE_Combobox } from "../index";
const options = [
"banana",
"apple",
"apple pie",
"tangerine",
"pear",
"grape",
@@ -71,86 +72,193 @@ describe("Render combobox", () => {
});
});
test("Should show loading icon when loading (used for async search)", async () => {
render(<App options={[]} isListOpen isLoading />);
describe("Combobox state-handling", () => {
test("Should show loading icon when loading (used for async search)", async () => {
render(<App options={[]} isListOpen isLoading />);
expect(await screen.findByText("Søker...")).toBeInTheDocument();
});
});
expect(await screen.findByText("Søker...")).toBeInTheDocument();
});
describe("Combobox state-handling", () => {
test("Should not select previous focused element when closes", async () => {
render(<App options={options} />);
test("Should not select previous focused element when closes", async () => {
render(<App options={options} />);
await act(async () => {
await userEvent.click(
screen.getByRole("combobox", { name: "Hva er dine favorittfrukter?" }),
await act(async () => {
await userEvent.click(
screen.getByRole("combobox", {
name: "Hva er dine favorittfrukter?",
}),
);
});
await act(async () => {
await userEvent.type(
screen.getByRole("combobox", {
name: "Hva er dine favorittfrukter?",
}),
"ban",
);
await userEvent.keyboard("{ArrowDown}");
await userEvent.keyboard("{ArrowUp}");
await userEvent.keyboard("{Enter}");
});
expect(screen.queryByRole("button", { name: "banana slett" })).toBeNull();
});
test("Should reset list when resetting input (ESC)", async () => {
render(<App options={options} />);
await act(async () => {
await userEvent.click(
screen.getByRole("combobox", {
name: "Hva er dine favorittfrukter?",
}),
);
});
await act(async () => {
await userEvent.type(
screen.getByRole("combobox", {
name: "Hva er dine favorittfrukter?",
}),
"apple",
);
await userEvent.keyboard("{ArrowDown}");
await userEvent.keyboard("{Escape}");
await userEvent.keyboard("{ArrowDown}");
});
expect(
await screen.findByRole("option", { name: "banana" }),
).toBeInTheDocument();
});
test("Should handle complex options with label and value", async () => {
const onToggleSelected = vi.fn();
render(
<App
options={[
{ label: "Hjelpemidler [HJE]", value: "HJE" },
{ label: "Oppfølging [OPP]", value: "OPP" },
{ label: "Sykepenger [SYK]", value: "SYK" },
{ label: "Sykemelding [SYM]", value: "SYM" },
]}
onToggleSelected={onToggleSelected}
/>,
);
});
await act(async () => {
await userEvent.type(
screen.getByRole("combobox", { name: "Hva er dine favorittfrukter?" }),
"ban",
);
await userEvent.keyboard("{ArrowDown}");
await userEvent.keyboard("{ArrowUp}");
await userEvent.keyboard("{Enter}");
});
expect(screen.queryByRole("button", { name: "banana slett" })).toBeNull();
});
test("Should reset list when resetting input (ESC)", async () => {
render(<App options={options} />);
await act(async () => {
await userEvent.click(
screen.getByRole("combobox", { name: "Hva er dine favorittfrukter?" }),
);
});
await act(async () => {
await userEvent.type(
screen.getByRole("combobox", { name: "Hva er dine favorittfrukter?" }),
"apple",
);
await userEvent.keyboard("{ArrowDown}");
await userEvent.keyboard("{Escape}");
await userEvent.keyboard("{ArrowDown}");
});
expect(
await screen.findByRole("option", { name: "banana" }),
).toBeInTheDocument();
});
test("Should handle complex options with label and value", async () => {
const onToggleSelected = vi.fn();
render(
<App
options={[
{ label: "Hjelpemidler [HJE]", value: "HJE" },
{ label: "Oppfølging [OPP]", value: "OPP" },
{ label: "Sykepenger [SYK]", value: "SYK" },
{ label: "Sykemelding [SYM]", value: "SYM" },
]}
onToggleSelected={onToggleSelected}
/>,
);
expect(screen.getByRole("combobox")).toBeInTheDocument();
const bananaOption = screen.getByRole("option", {
name: "Hjelpemidler [HJE]",
selected: false,
});
await act(async () => {
await userEvent.click(bananaOption);
});
expect(onToggleSelected).toHaveBeenCalledWith("HJE", true, false);
expect(
screen.getByRole("option", {
expect(screen.getByRole("combobox")).toBeInTheDocument();
const bananaOption = screen.getByRole("option", {
name: "Hjelpemidler [HJE]",
selected: true,
}),
).toBeInTheDocument();
selected: false,
});
await act(async () => {
await userEvent.click(bananaOption);
});
expect(onToggleSelected).toHaveBeenCalledWith("HJE", true, false);
expect(
screen.getByRole("option", {
name: "Hjelpemidler [HJE]",
selected: true,
}),
).toBeInTheDocument();
});
test("should trigger onChange for every character typed or removed", async () => {
const onChange = vi.fn();
const onToggleSelected = vi.fn();
render(
<App
options={["Apple", "Orange", "Banana", "Lemon"]}
onChange={onChange}
onToggleSelected={onToggleSelected}
shouldAutocomplete
/>,
);
const combobox = screen.getByRole("combobox");
expect(combobox).toBeInTheDocument();
await act(async () => {
await userEvent.click(combobox);
await userEvent.type(combobox, "Lemon");
});
expect(onChange).toHaveBeenNthCalledWith(1, "L");
expect(onChange).toHaveBeenNthCalledWith(2, "Le");
expect(onChange).toHaveBeenNthCalledWith(3, "Lem");
expect(onChange).toHaveBeenNthCalledWith(4, "Lemo");
expect(onChange).toHaveBeenNthCalledWith(5, "Lemon");
});
test("should trigger onChange while typing and on accepting autocomplete suggestions", async () => {
const onChange = vi.fn();
const onToggleSelected = vi.fn();
render(
<App
options={[
"Hjelpemidler [HJE]",
"Oppfølging [OPP]",
"Sykepenger [SYK]",
"Sykemelding [SYM]",
]}
onChange={onChange}
onToggleSelected={onToggleSelected}
shouldAutocomplete
/>,
);
const combobox = screen.getByRole("combobox");
expect(combobox).toBeInTheDocument();
await act(async () => {
await userEvent.click(combobox);
await userEvent.type(combobox, "Syke");
await userEvent.keyboard("{ArrowRight}");
await userEvent.keyboard("{ArrowDown}");
await userEvent.keyboard("{Enter}");
});
expect(onChange).toHaveBeenNthCalledWith(1, "S");
expect(onChange).toHaveBeenNthCalledWith(2, "Sy");
expect(onChange).toHaveBeenNthCalledWith(3, "Syk");
expect(onChange).toHaveBeenNthCalledWith(4, "Syke");
expect(onChange).toHaveBeenNthCalledWith(5, "Sykepenger [SYK]");
expect(onChange).toHaveBeenCalledWith("");
expect(onToggleSelected).toHaveBeenCalledOnce();
expect(onToggleSelected).toHaveBeenCalledWith(
"Sykepenger [SYK]",
true,
false,
);
});
});
describe("search", () => {
test("should find matched anywhere in the label", async () => {
render(<App options={options} />);
const combobox = screen.getByRole("combobox", {
name: "Hva er dine favorittfrukter?",
});
await act(async () => {
await userEvent.click(combobox);
await userEvent.type(combobox, "p");
});
const searchHits = [
"apple",
"apple pie",
"pear",
"grape",
"passion fruit",
"pineapple",
"grape fruit",
];
searchHits.forEach((label) => {
expect(screen.getByRole("option", { name: label })).toBeInTheDocument();
});
screen.getAllByRole("option").forEach((option) => {
expect(
option.textContent && searchHits.includes(option.textContent),
).toBe(true);
});
});
});
});
@@ -1,17 +1,21 @@
import { Meta, StoryFn, StoryObj } from "@storybook/react";
import { expect, fn, userEvent, within } from "@storybook/test";
import React, { useMemo, useRef, useState } from "react";
import { Meta, StoryFn } from "@storybook/react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "../../button";
import { Chips } from "../../chips";
import { VStack } from "../../layout/stack";
import { Modal } from "../../modal";
import { TextField } from "../textfield";
import { ComboboxProps, UNSAFE_Combobox } from "./index";
import { UNSAFE_Combobox } from "./index";
export default {
title: "ds-react/Combobox",
component: UNSAFE_Combobox,
decorators: [(story) => <div style={{ width: "300px" }}>{story()}</div>],
parameters: {
chromatic: { disable: true },
},
} satisfies Meta<typeof UNSAFE_Combobox>;
type StoryObject = StoryObj<typeof UNSAFE_Combobox>;
type StoryFunction = StoryFn<typeof UNSAFE_Combobox>;
const options = [
@@ -41,8 +45,15 @@ Default.args = {
isLoading: false,
isMultiSelect: false,
allowNewValues: false,
onChange: console.log,
};
Default.argTypes = {
description: {
control: { type: "text" },
},
disabled: {
control: { type: "boolean" },
},
isListOpen: {
control: { type: "boolean" },
},
@@ -56,31 +67,36 @@ Default.argTypes = {
},
};
export const MultiSelect: StoryFunction = (props) => {
export const MultiSelect: StoryFn = () => {
const [selectedOptions, setSelectedOptions] = useState<string[]>([
"pear",
"kiwi",
]);
return (
<UNSAFE_Combobox
id="combobox-with-multiselect"
label="Komboboks - velg flere"
options={props.options}
isMultiSelect={props.isMultiSelect}
size={props.size}
options={options}
isMultiSelect={true}
selectedOptions={selectedOptions}
onToggleSelected={(option, isSelected) =>
isSelected
? setSelectedOptions([...selectedOptions, option])
: setSelectedOptions(selectedOptions.filter((o) => o !== option))
}
/>
);
};
MultiSelect.args = {
options,
isMultiSelect: true,
size: "medium",
};
export const MultiSelectWithComplexOptions: StoryFunction = (props) => {
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
export const MultiSelectWithComplexOptions: StoryFn = () => {
const [selectedOptions, setSelectedOptions] = useState<string[]>([
"HJE",
"OPP",
]);
return (
<>
<UNSAFE_Combobox
{...props}
options={props.options.map((option) => ({
options={complexOptions.map((option) => ({
...option,
label: `${option.label} [${option.value}]`,
}))}
@@ -93,74 +109,81 @@ export const MultiSelectWithComplexOptions: StoryFunction = (props) => {
: setSelectedOptions(selectedOptions.filter((o) => o !== value))
}
selectedOptions={selectedOptions}
isMultiSelect
/>
</>
);
};
MultiSelectWithComplexOptions.args = {
options: [
{ label: "Hjelpemidler", value: "HJE" },
{ label: "Oppfølging", value: "OPP" },
{ label: "Sykepenger", value: "SYK" },
{ label: "Sykemelding", value: "SYM" },
{ label: "Foreldre- og svangerskapspenger", value: "FOR" },
{ label: "Arbeidsavklaringspenger", value: "AAP" },
{ label: "Uføretrygd", value: "UFO" },
{ label: "Pensjon", value: "PEN" },
{ label: "Barnetrygd", value: "BAR" },
{ label: "Kontantstøtte", value: "KON" },
{ label: "Bostøtte", value: "BOS" },
{ label: "Barnebidrag", value: "BBI" },
{ label: "Bidragsforskudd", value: "BIF" },
{ label: "Grunn- og hjelpestønad", value: "GRU" },
],
isMultiSelect: true,
size: "medium",
};
const complexOptions = [
{ label: "Hjelpemidler", value: "HJE" },
{ label: "Oppfølging", value: "OPP" },
{ label: "Sykepenger", value: "SYK" },
{ label: "Sykemelding", value: "SYM" },
{ label: "Foreldre- og svangerskapspenger", value: "FOR" },
{ label: "Arbeidsavklaringspenger", value: "AAP" },
{ label: "Uføretrygd", value: "UFO" },
{ label: "Pensjon", value: "PEN" },
{ label: "Barnetrygd", value: "BAR" },
{ label: "Kontantstøtte", value: "KON" },
{ label: "Bostøtte", value: "BOS" },
{ label: "Barnebidrag", value: "BBI" },
{ label: "Bidragsforskudd", value: "BIF" },
{ label: "Grunn- og hjelpestønad", value: "GRU" },
];
export const WithAddNewOptions: StoryFunction = (props) => {
export const WithAddNewOptions: StoryFn = ({ open }: { open?: boolean }) => {
const [value, setValue] = useState<string | undefined>("hello");
const comboboxRef = useRef<HTMLInputElement>(null);
return (
<UNSAFE_Combobox
id="combobox-with-add-new-options"
label="Komboboks med mulighet for å legge til nye verdier"
options={props.options}
allowNewValues={props.allowNewValues}
shouldAutocomplete={props.shouldAutocomplete}
options={options}
allowNewValues={true}
shouldAutocomplete={true}
value={value}
onChange={setValue}
isListOpen={open ?? (comboboxRef.current ? true : undefined)}
ref={comboboxRef}
/>
);
};
WithAddNewOptions.args = {
options,
allowNewValues: true,
shouldAutocomplete: true,
};
export const MultiSelectWithAddNewOptions: StoryFunction = (props) => {
export const MultiSelectWithAddNewOptions: StoryFn = ({
open,
}: {
open?: boolean;
}) => {
const [value, setValue] = useState<string | undefined>("world");
const [selectedOptions, setSelectedOptions] = useState<string[]>(["hello"]);
const comboboxRef = useRef<HTMLInputElement>(null);
return (
<UNSAFE_Combobox
id="combobox-with-multiselect-and-add-new-options"
isMultiSelect={props.isMultiSelect}
isMultiSelect={true}
label="Multiselect komboboks med mulighet for å legge til nye verdier"
options={props.options}
allowNewValues={props.allowNewValues}
options={options}
allowNewValues={true}
value={value}
selectedOptions={selectedOptions}
onChange={setValue}
onToggleSelected={(option, isSelected) =>
isSelected
? setSelectedOptions([...selectedOptions, option])
: setSelectedOptions(selectedOptions.filter((o) => o !== option))
}
isListOpen={open ?? (comboboxRef.current ? true : undefined)}
ref={comboboxRef}
/>
);
};
MultiSelectWithAddNewOptions.args = {
allowNewValues: true,
isMultiSelect: true,
options,
shouldAutocomplete: false,
};
export const MultiSelectWithExternalChips: StoryFn<{
controlled: boolean;
options: ComboboxProps["options"];
}> = (props) => {
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
export const MultiSelectWithExternalChips: StoryFn = () => {
const [selectedOptions, setSelectedOptions] = useState<string[]>([
"kiwi",
"pear",
]);
const [value, setValue] = useState("");
const toggleSelected = (option: string) =>
@@ -174,8 +197,7 @@ export const MultiSelectWithExternalChips: StoryFn<{
{selectedOptions.map((option) => (
<Chips.Removable
key={option}
onPointerUp={() => toggleSelected(option)}
onKeyUp={(e) => e.key === "Enter" && toggleSelected(option)}
onClick={() => toggleSelected(option)}
>
{option}
</Chips.Removable>
@@ -184,16 +206,12 @@ export const MultiSelectWithExternalChips: StoryFn<{
)}
<UNSAFE_Combobox
id="combobox-with-external-chips"
options={props.options}
options={options}
selectedOptions={selectedOptions}
onToggleSelected={(option) => toggleSelected(option)}
isMultiSelect
value={props.controlled ? value : undefined}
onChange={(event) =>
props.controlled
? setValue(event?.currentTarget.value || "")
: undefined
}
value={value}
onChange={setValue}
label="Komboboks"
size="medium"
shouldShowSelectedOptions={false}
@@ -202,58 +220,40 @@ export const MultiSelectWithExternalChips: StoryFn<{
);
};
MultiSelectWithExternalChips.args = {
controlled: false,
options,
};
export const Loading: StoryFunction = (props) => (
export const Loading: StoryFn = () => (
<UNSAFE_Combobox
id="combobox-with-loading-indicator"
label="Komboboks (laster)"
options={[]}
selectedOptions={[]}
isListOpen={props.isListOpen}
isLoading={props.isLoading}
isListOpen={true}
isLoading={true}
/>
);
Loading.args = {
isLoading: true,
isListOpen: true,
};
export const ComboboxWithNoHits: StoryFunction = (props) => {
const [value, setValue] = useState(props.value);
export const ComboboxWithNoHits: StoryFn = () => {
const [value, setValue] = useState<string | undefined>("Orange");
return (
<UNSAFE_Combobox
id="combobox-with-no-hits"
label="Komboboks (uten søketreff)"
options={props.options}
options={options}
value={value}
onChange={(event) => setValue(event?.currentTarget.value)}
onChange={setValue}
isListOpen={true}
/>
);
};
ComboboxWithNoHits.args = {
options,
value: "Orange",
};
export const Controlled: StoryFn<{
value: string;
options: string[];
initialSelectedOptions: string[];
}> = (props) => {
const [value, setValue] = useState(props.value);
const [selectedOptions, setSelectedOptions] = useState(
props.initialSelectedOptions,
);
export const Controlled: StoryFn = () => {
const [value, setValue] = useState<string>("apple");
const [selectedOptions, setSelectedOptions] = useState([
"passion fruit",
"grape fruit",
]);
const filteredOptions = useMemo(
() => props.options.filter((option) => option.includes(value)),
[props.options, value],
() => options.filter((option) => option.includes(value)),
[value],
);
const onToggleSelected = (option: string, isSelected: boolean) => {
@@ -277,8 +277,8 @@ export const Controlled: StoryFn<{
id="combobox-controlled"
filteredOptions={filteredOptions}
isMultiSelect
options={props.options}
onChange={(event) => setValue(event?.target.value || "")}
options={options}
onChange={setValue}
onToggleSelected={onToggleSelected}
selectedOptions={selectedOptions}
value={value}
@@ -287,47 +287,82 @@ export const Controlled: StoryFn<{
);
};
Controlled.args = {
value: "apple",
options,
initialSelectedOptions: ["passion fruit", "grape fruit"],
export const ComboboxSizes = () => {
const [multiSelectedOptions, setMultiSelectedOptions] = useState<string[]>([
"pear",
"kiwi",
]);
const [singleSelectedOption, setSingleSelectedOption] = useState<string[]>([
"apple",
]);
return (
<>
<UNSAFE_Combobox
label="Hva er dine favorittfrukter?"
description="Medium single-select"
options={options}
selectedOptions={singleSelectedOption}
onToggleSelected={(option, isSelected) =>
isSelected
? setSingleSelectedOption([option])
: setSingleSelectedOption(
singleSelectedOption.filter((o) => o !== option),
)
}
/>
<br />
<UNSAFE_Combobox
label="Hva er dine favorittfrukter?"
description="Small single-select"
options={options}
size="small"
selectedOptions={singleSelectedOption}
onToggleSelected={(option, isSelected) =>
isSelected
? setSingleSelectedOption([option])
: setSingleSelectedOption(
singleSelectedOption.filter((o) => o !== option),
)
}
/>
<br />
<UNSAFE_Combobox
label="Hva er dine favorittfrukter?"
description="Medium multiselect"
options={options}
isMultiSelect
allowNewValues
selectedOptions={multiSelectedOptions}
onToggleSelected={(option, isSelected) =>
isSelected
? setMultiSelectedOptions([...multiSelectedOptions, option])
: setMultiSelectedOptions(
multiSelectedOptions.filter((o) => o !== option),
)
}
/>
<br />
<UNSAFE_Combobox
label="Hva er dine favorittfrukter?"
description="Small multiselect"
options={options}
isMultiSelect
size="small"
allowNewValues
selectedOptions={multiSelectedOptions}
onToggleSelected={(option, isSelected) =>
isSelected
? setMultiSelectedOptions([...multiSelectedOptions, option])
: setMultiSelectedOptions(
multiSelectedOptions.filter((o) => o !== option),
)
}
/>
</>
);
};
export const ComboboxSizes = () => (
<>
<UNSAFE_Combobox
label="Hva er dine favorittfrukter?"
description="Medium single-select"
options={options}
/>
<br />
<UNSAFE_Combobox
label="Hva er dine favorittfrukter?"
description="Small single-select"
options={options}
size="small"
/>
<br />
<UNSAFE_Combobox
label="Hva er dine favorittfrukter?"
description="Medium multiselect"
options={options}
isMultiSelect
allowNewValues
/>
<br />
<UNSAFE_Combobox
label="Hva er dine favorittfrukter?"
description="Small multiselect"
options={options}
isMultiSelect
size="small"
allowNewValues
/>
</>
);
export const MaxSelectedOptions: StoryFunction = () => {
export const MaxSelectedOptions: StoryFn = ({ open }: { open?: boolean }) => {
const [value, setValue] = useState<string | undefined>("");
const [selectedOptions, setSelectedOptions] = useState([
options[0],
@@ -348,15 +383,15 @@ export const MaxSelectedOptions: StoryFunction = () => {
}
isMultiSelect
allowNewValues
isListOpen={comboboxRef.current ? undefined : true}
isListOpen={open ?? (comboboxRef.current ? undefined : true)}
value={value}
onChange={(event) => setValue(event?.target.value)}
onChange={setValue}
ref={comboboxRef}
/>
);
};
export const WithError: StoryFunction = (props) => {
export const WithError: StoryFn = () => {
const [hasSelectedValue, setHasSelectedValue] = useState(false);
const [isLoading, setIsLoading] = useState(false);
return (
@@ -364,7 +399,7 @@ export const WithError: StoryFunction = (props) => {
filteredOptions={isLoading ? [] : undefined}
options={options}
label="Hva er dine favorittfrukter?"
error={!hasSelectedValue && props.error}
error={!hasSelectedValue && "Du må velge en favorittfrukt."}
isLoading={isLoading}
onChange={() => {
setIsLoading(true);
@@ -374,350 +409,87 @@ export const WithError: StoryFunction = (props) => {
/>
);
};
WithError.args = {
error: "Du må velge en favorittfrukt.",
export const InModal: StoryFn = () => {
const modalRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
modalRef.current?.showModal();
}, []);
return (
<>
<Button onClick={() => modalRef.current?.showModal()}>Åpne modal</Button>
<Modal
ref={modalRef}
header={{ heading: "Overskrift" }}
width="medium"
style={{ height: "auto" }}
>
<Modal.Body style={{ height: "100% " }}>
<p>
Modalen skal ikke lukke seg om man trykker Escape mens virtuelt
fokus er i Combobox sin nedtrekksliste eller om inputfeltet
inneholder tekst.
</p>
<UNSAFE_Combobox
options={options}
label="Hva er dine favorittfrukter?"
/>
</Modal.Body>
</Modal>
</>
);
};
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const CancelInputTest: StoryObject = {
render: () => {
return (
<UNSAFE_Combobox options={options} label="Hva er dine favorittfrukter?" />
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
userEvent.click(input);
await userEvent.type(input, "apple", { delay: 200 });
await sleep(1000);
userEvent.keyboard("{ArrowDown}");
await sleep(1000);
userEvent.keyboard("{Escape}");
await sleep(1000);
userEvent.keyboard("{ArrowDown}");
await sleep(500);
const banana = canvas.getByText("banana");
userEvent.click(banana);
},
export const Disabled: StoryFn = () => {
return (
<UNSAFE_Combobox
options={options}
label="Hva er dine favorittfrukter?"
disabled
/>
);
};
export const RemoveSelectedMultiSelectTest: StoryObject = {
render: () => {
return (
<UNSAFE_Combobox
options={options}
label="Hva er dine favorittfrukter?"
isMultiSelect
/>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
userEvent.click(input);
await userEvent.type(input, "apple", { delay: 200 });
await sleep(250);
userEvent.keyboard("{ArrowDown}");
await sleep(250);
userEvent.keyboard("{Enter}");
await sleep(250);
userEvent.keyboard("{Escape}");
await sleep(250);
userEvent.click(input);
await userEvent.type(input, "banana", { delay: 200 });
await sleep(250);
userEvent.keyboard("{ArrowDown}");
await sleep(250);
userEvent.keyboard("{Enter}");
await sleep(250);
const appleSlett = canvas.getByLabelText("apple slett");
appleSlett.focus();
await sleep(250);
userEvent.click(appleSlett);
await sleep(250);
const appleOption = canvas.getByRole("option", {
name: "apple",
selected: false,
});
expect(appleOption).toBeVisible();
userEvent.keyboard("{Escape}");
await sleep(250);
expect(appleOption).not.toBeVisible();
const bananaSlett = canvas.getByLabelText("banana slett");
expect(bananaSlett).toBeInTheDocument();
const appleSlettAgain = canvas.queryByLabelText("apple slett");
expect(appleSlettAgain).not.toBeInTheDocument();
},
export const Chromatic: StoryFn = () => {
const H2 = (props: { children: string; style?: React.CSSProperties }) => (
<h2 style={{ marginBottom: "-0.25rem", ...props.style }}>
{props.children}
</h2>
);
return (
<VStack gap="2">
<H2>MultiSelect</H2>
<MultiSelect />
<H2>ComboboxWithNoHits</H2>
<ComboboxWithNoHits />
<H2 style={{ marginTop: "5rem" }}>MultiSelectWithComplexOptions</H2>
<MultiSelectWithComplexOptions />
<H2>WithAddNewOptions</H2>
<WithAddNewOptions open />
<H2 style={{ marginTop: "5rem" }}>MultiSelectWithAddNewOptions</H2>
<MultiSelectWithAddNewOptions open />
<H2 style={{ marginTop: "5rem" }}>MultiSelectWithExternalChips</H2>
<MultiSelectWithExternalChips />
<H2>Loading</H2>
<Loading />
<H2 style={{ marginTop: "5rem" }}>Controlled Input Value</H2>
<Controlled />
<H2>ComboboxSizes</H2>
<ComboboxSizes />
<H2>MaxSelectedOptions</H2>
<MaxSelectedOptions open />
<H2 style={{ marginTop: "20rem" }}>WithError</H2>
<WithError />
<H2>Disabled</H2>
<Disabled />
</VStack>
);
};
export const AllowNewValuesTest: StoryObject = {
render: () => {
return (
<UNSAFE_Combobox
options={options}
label="Hva er dine favorittfrukter?"
allowNewValues
/>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
userEvent.click(input);
await userEvent.type(input, "aaa", { delay: 200 });
await sleep(250);
userEvent.keyboard("{ArrowDown}");
await sleep(250);
userEvent.keyboard("{ArrowDown}");
await sleep(250);
userEvent.keyboard("{Enter}");
await sleep(250);
userEvent.keyboard("{Escape}");
await sleep(250);
const invalidSelect = canvas.queryByLabelText("aaa slett");
expect(invalidSelect).not.toBeInTheDocument();
},
};
export const DisallowNewValuesTest: StoryObject = {
render: () => {
return (
<UNSAFE_Combobox options={options} label="Hva er dine favorittfrukter?" />
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
userEvent.click(input);
await userEvent.type(input, "aaa", { delay: 200 });
await sleep(250);
userEvent.keyboard("{ArrowDown}");
await sleep(250);
userEvent.keyboard("{ArrowDown}");
await sleep(250);
userEvent.keyboard("{Enter}");
await sleep(250);
userEvent.keyboard("{Escape}");
await sleep(250);
const invalidSelect = canvas.queryByLabelText("aaa slett");
expect(invalidSelect).not.toBeInTheDocument();
},
};
export const TestThatCallbacksOnlyFireWhenExpected: StoryObj<{
onChange: ReturnType<typeof fn>;
onClear: ReturnType<typeof fn>;
onToggleSelected: ReturnType<typeof fn>;
}> = {
args: {
onChange: fn(),
onClear: fn(),
onToggleSelected: fn(),
},
render: (props) => {
return (
<UNSAFE_Combobox
options={options}
label="Hva er dine favorittfrukter?"
{...props}
/>
);
},
play: async ({ canvasElement, args }) => {
args.onToggleSelected.mockClear();
args.onClear.mockClear();
args.onChange.mockClear();
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
const searchWord = "tangerine";
userEvent.click(input);
await userEvent.type(input, searchWord, { delay: 200 });
await sleep(250);
userEvent.keyboard("{ArrowDown}");
await sleep(250);
userEvent.keyboard("{Enter}");
await sleep(250);
expect(args.onClear.mock.calls).toHaveLength(1);
expect(args.onToggleSelected.mock.calls).toHaveLength(1);
expect(args.onChange.mock.calls).toHaveLength(searchWord.length + 1);
},
};
export const TestCasingWhenAutoCompleting = {
args: {
onChange: fn(),
onClear: fn(),
onToggleSelected: fn(),
},
render: (props) => {
return (
<UNSAFE_Combobox
options={["Camel Case", "lowercase", "UPPERCASE"]}
label="Liker du best store eller små bokstaver?"
shouldAutocomplete
allowNewValues
{...props}
/>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole<HTMLInputElement>("combobox");
// With exisiting option
userEvent.click(input);
await userEvent.type(input, "cAmEl CaSe", { delay: 250 });
await sleep(250);
expect(input.value).toBe("cAmEl CaSe");
await userEvent.type(input, "{Enter}");
await sleep(250);
const chips = canvas.getAllByRole("list")[0];
const selectedUpperCaseChip = within(chips).getAllByRole("listitem")[0];
expect(selectedUpperCaseChip).toHaveTextContent("Camel Case"); // A weird issue is preventing the accessible name from being used in the test, even if it works in VoiceOver
// With custom option
userEvent.click(input);
await userEvent.type(input, "cAmEl{Backspace}", { delay: 250 });
await sleep(250);
expect(input.value).toBe("cAmEl");
await userEvent.type(input, "{Enter}");
await sleep(250);
const selectedNewValueChip = within(chips).getAllByRole("listitem")[0];
expect(selectedNewValueChip).toHaveTextContent("cAmEl"); // A weird issue is preventing the accessible name from being used in the test, even if it works in VoiceOver
},
};
export const TestHoverAndFocusSwitching: StoryObject = {
render: () => {
return (
<UNSAFE_Combobox options={options} label="Hva er dine favorittfrukter?" />
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await sleep(500);
const getInput = () =>
canvas.getByRole("combobox", {
name: "Hva er dine favorittfrukter?",
});
userEvent.click(getInput());
expect(getInput().getAttribute("aria-expanded")).toEqual("false");
expect(getInput().getAttribute("aria-activedescendant")).toBeNull();
await sleep(250);
userEvent.keyboard("{ArrowDown}");
await sleep(250);
const bananaOption = canvas.getByRole("option", { name: "banana" });
expect(getInput().getAttribute("aria-activedescendant")).toBe(
bananaOption.getAttribute("id"),
);
userEvent.keyboard("{ArrowDown}");
await sleep(250);
const appleOption = canvas.getByRole("option", { name: "apple" });
expect(getInput().getAttribute("aria-activedescendant")).toBe(
appleOption.getAttribute("id"),
);
userEvent.hover(bananaOption);
await sleep(250);
expect(getInput().getAttribute("aria-activedescendant")).toBe(
bananaOption.getAttribute("id"),
);
},
};
export const TestEnterNotSubmittingForm: StoryObj<{
onSubmit: ReturnType<typeof fn>;
}> = {
args: {
onSubmit: fn(),
},
render: ({ onSubmit }) => {
return (
<form action="https://www.aksel.nav.no" method="get" onSubmit={onSubmit}>
<UNSAFE_Combobox
options={options}
label="Hva er dine favorittfrukter?"
isMultiSelect
allowNewValues
/>
</form>
);
},
play: async ({ canvasElement, args }) => {
args.onSubmit.mockClear();
const canvas = within(canvasElement);
const waitTime = 0; // Change for debugging
await sleep(waitTime);
const getInput = () =>
canvas.getByRole("combobox", {
name: "Hva er dine favorittfrukter?",
});
const getOption = (name: string, selected: boolean) =>
canvas.getByRole("option", { name, selected });
await userEvent.click(getInput(), { delay: waitTime });
await userEvent.keyboard("{ArrowDown}", { delay: waitTime });
expect(getInput().getAttribute("aria-activedescendant")).toBe(
getOption("banana", false).getAttribute("id"),
);
await userEvent.keyboard("{Enter}", { delay: waitTime });
expect(args.onSubmit).not.toHaveBeenCalled();
expect(getOption("banana", true)).toBeVisible();
await userEvent.keyboard("{Shift>}{Tab}", { delay: waitTime });
await userEvent.keyboard("{Enter}", { delay: waitTime });
expect(args.onSubmit).not.toHaveBeenCalled();
await userEvent.keyboard("test"); // Type option that does not exist
await userEvent.keyboard("{Enter}", { delay: waitTime });
expect(args.onSubmit).not.toHaveBeenCalled();
await userEvent.keyboard("{ArrowDown}", { delay: waitTime }); // Select "test" custom option
expect(
canvas.getByRole("option", { name: "test", selected: true }),
).toBeVisible();
await userEvent.keyboard("{Enter}", { delay: waitTime }); // De-select "test"
expect(
canvas.queryByRole("option", { name: "test", selected: false }),
).toBeNull();
expect(args.onSubmit).not.toHaveBeenCalled();
await userEvent.keyboard("{Escape}", { delay: waitTime }); // Clear input field
await userEvent.keyboard("{Enter}", { delay: waitTime }); // Enter on empty Input with closed list
expect(args.onSubmit).toHaveBeenCalledOnce();
Chromatic.parameters = {
chromatic: {
disable: false,
},
};
@@ -0,0 +1,404 @@
import { Meta, StoryObj } from "@storybook/react";
import { expect, fn, userEvent, within } from "@storybook/test";
import React from "react";
import { UNSAFE_Combobox } from "./index";
export default {
title: "ds-react/Combobox/Tests",
component: UNSAFE_Combobox,
decorators: [(story) => <div style={{ width: "300px" }}>{story()}</div>],
parameters: {
chromatic: { disable: false },
},
} satisfies Meta<typeof UNSAFE_Combobox>;
type StoryObject = StoryObj<typeof UNSAFE_Combobox>;
const options = [
"banana",
"apple",
"apple pie",
"tangerine",
"pear",
"grape",
"kiwi",
"mango",
"passion fruit",
"pineapple",
"strawberry",
"watermelon",
"grape fruit",
];
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const CancelInput: StoryObject = {
render: () => {
return (
<UNSAFE_Combobox options={options} label="Hva er dine favorittfrukter?" />
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
userEvent.click(input);
await userEvent.type(input, "apple", { delay: 200 });
await sleep(1000);
userEvent.keyboard("{ArrowDown}");
await sleep(1000);
userEvent.keyboard("{Escape}");
await sleep(1000);
userEvent.keyboard("{ArrowDown}");
await sleep(500);
const banana = canvas.getByText("banana");
userEvent.click(banana);
},
};
export const RemoveSelectedMultiSelect: StoryObject = {
render: () => {
return (
<UNSAFE_Combobox
options={options}
label="Hva er dine favorittfrukter?"
isMultiSelect
/>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
userEvent.click(input);
await userEvent.type(input, "apple", { delay: 200 });
await sleep(250);
userEvent.keyboard("{ArrowDown}");
await sleep(250);
userEvent.keyboard("{Enter}");
await sleep(250);
userEvent.keyboard("{Escape}");
await sleep(250);
userEvent.click(input);
await userEvent.type(input, "banana", { delay: 200 });
await sleep(250);
userEvent.keyboard("{ArrowDown}");
await sleep(250);
userEvent.keyboard("{Enter}");
await sleep(250);
const appleSlett = canvas.getByLabelText("apple slett");
appleSlett.focus();
await sleep(250);
userEvent.click(appleSlett);
await sleep(250);
const appleOption = canvas.getByRole("option", {
name: "apple",
selected: false,
});
expect(appleOption).toBeVisible();
userEvent.keyboard("{Escape}");
await sleep(250);
expect(appleOption).not.toBeVisible();
const bananaSlett = canvas.getByLabelText("banana slett");
expect(bananaSlett).toBeInTheDocument();
const appleSlettAgain = canvas.queryByLabelText("apple slett");
expect(appleSlettAgain).not.toBeInTheDocument();
},
};
export const AllowNewValuesMultiSelect: StoryObject = {
render: () => {
return (
<UNSAFE_Combobox
isMultiSelect={true}
options={options}
label="Hva er dine favorittfrukter?"
allowNewValues
/>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
userEvent.click(input);
await userEvent.type(input, "aaa", { delay: 200 });
await sleep(250);
userEvent.keyboard("{ArrowDown}");
await sleep(250);
userEvent.keyboard("{Enter}");
await sleep(250);
const invalidSelect = canvas.queryByLabelText("aaa slett");
expect(invalidSelect).toBeInTheDocument();
},
};
export const AllowNewValuesSingleSelect: StoryObject = {
render: () => {
return (
<UNSAFE_Combobox
options={options}
label="Hva er dine favorittfrukter?"
allowNewValues
/>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
userEvent.click(input);
await userEvent.type(input, "aaa", { delay: 200 });
await sleep(250);
expect(
canvas.getByRole("option", { name: "Legg til “aaa”" }),
).toBeVisible();
userEvent.keyboard("{ArrowDown}");
await sleep(250);
userEvent.keyboard("{Enter}");
await sleep(250);
const invalidSelect = canvas.queryByLabelText("aaa slett");
expect(invalidSelect).not.toBeInTheDocument();
},
};
export const DisallowNewValues: StoryObject = {
render: () => {
return (
<UNSAFE_Combobox options={options} label="Hva er dine favorittfrukter?" />
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
userEvent.click(input);
await userEvent.type(input, "aaa", { delay: 200 });
await sleep(250);
userEvent.keyboard("{ArrowDown}");
await sleep(250);
userEvent.keyboard("{ArrowDown}");
await sleep(250);
userEvent.keyboard("{Enter}");
await sleep(250);
userEvent.keyboard("{Escape}");
await sleep(250);
const invalidSelect = canvas.queryByLabelText("aaa slett");
expect(invalidSelect).not.toBeInTheDocument();
},
};
export const CallbacksOnlyFireWhenExpected: StoryObj<{
onChange: ReturnType<typeof fn>;
onClear: ReturnType<typeof fn>;
onToggleSelected: ReturnType<typeof fn>;
}> = {
args: {
onChange: fn(),
onClear: fn(),
onToggleSelected: fn(),
},
render: (props) => {
return (
<UNSAFE_Combobox
options={options}
label="Hva er dine favorittfrukter?"
{...props}
/>
);
},
play: async ({ canvasElement, args }) => {
args.onToggleSelected.mockClear();
args.onClear.mockClear();
args.onChange.mockClear();
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
const searchWord = "tangerine";
userEvent.click(input);
await userEvent.type(input, searchWord, { delay: 200 });
await sleep(250);
userEvent.keyboard("{ArrowDown}");
await sleep(250);
userEvent.keyboard("{Enter}");
await sleep(250);
expect(args.onClear.mock.calls).toHaveLength(1);
expect(args.onToggleSelected.mock.calls).toHaveLength(1);
expect(args.onChange.mock.calls).toHaveLength(searchWord.length + 1);
},
};
export const CorrectCasingWhenAutoCompleting = {
args: {
onChange: fn(),
onClear: fn(),
onToggleSelected: fn(),
},
render: (props) => {
return (
<UNSAFE_Combobox
options={["Camel Case", "lowercase", "UPPERCASE"]}
label="Liker du best store eller små bokstaver?"
shouldAutocomplete
allowNewValues
{...props}
/>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole<HTMLInputElement>("combobox");
// With exisiting option
userEvent.click(input);
await userEvent.type(input, "cAmEl CaSe", { delay: 250 });
await sleep(250);
expect(input.value).toBe("cAmEl CaSe");
await userEvent.type(input, "{Enter}");
await sleep(250);
const chips = canvas.getAllByRole("list")[0];
const selectedUpperCaseChip = within(chips).getAllByRole("listitem")[0];
expect(selectedUpperCaseChip).toHaveTextContent("Camel Case"); // A weird issue is preventing the accessible name from being used in the test, even if it works in VoiceOver
// With custom option
userEvent.click(input);
await userEvent.type(input, "cAmEl{Backspace}", { delay: 250 });
await sleep(250);
expect(input.value).toBe("cAmEl");
await userEvent.type(input, "{Enter}");
await sleep(250);
const selectedNewValueChip = within(chips).getAllByRole("listitem")[0];
expect(selectedNewValueChip).toHaveTextContent("cAmEl"); // A weird issue is preventing the accessible name from being used in the test, even if it works in VoiceOver
},
};
export const HoverAndFocusSwitching: StoryObject = {
render: () => {
return (
<UNSAFE_Combobox options={options} label="Hva er dine favorittfrukter?" />
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await sleep(500);
const getInput = () =>
canvas.getByRole("combobox", {
name: "Hva er dine favorittfrukter?",
});
userEvent.click(getInput());
expect(getInput().getAttribute("aria-expanded")).toEqual("false");
expect(getInput().getAttribute("aria-activedescendant")).toBeNull();
await sleep(250);
userEvent.keyboard("{ArrowDown}");
await sleep(250);
const bananaOption = canvas.getByRole("option", { name: "banana" });
expect(getInput().getAttribute("aria-activedescendant")).toBe(
bananaOption.getAttribute("id"),
);
userEvent.keyboard("{ArrowDown}");
await sleep(250);
const appleOption = canvas.getByRole("option", { name: "apple" });
expect(getInput().getAttribute("aria-activedescendant")).toBe(
appleOption.getAttribute("id"),
);
userEvent.hover(bananaOption);
await sleep(250);
expect(getInput().getAttribute("aria-activedescendant")).toBe(
bananaOption.getAttribute("id"),
);
},
};
export const EnterNotSubmittingForm: StoryObj<{
onSubmit: ReturnType<typeof fn>;
}> = {
args: {
onSubmit: fn(),
},
render: ({ onSubmit }) => {
return (
<form action="https://www.aksel.nav.no" method="get" onSubmit={onSubmit}>
<UNSAFE_Combobox
options={options}
label="Hva er dine favorittfrukter?"
isMultiSelect
allowNewValues
/>
</form>
);
},
play: async ({ canvasElement, args }) => {
args.onSubmit.mockClear();
const canvas = within(canvasElement);
const waitTime = 0; // Change for debugging
await sleep(waitTime);
const getInput = () =>
canvas.getByRole("combobox", {
name: "Hva er dine favorittfrukter?",
});
const getOption = (name: string, selected: boolean) =>
canvas.getByRole("option", { name, selected });
await userEvent.click(getInput(), { delay: waitTime });
await userEvent.keyboard("{ArrowDown}", { delay: waitTime });
expect(getInput().getAttribute("aria-activedescendant")).toBe(
getOption("banana", false).getAttribute("id"),
);
await userEvent.keyboard("{Enter}", { delay: waitTime });
expect(args.onSubmit).not.toHaveBeenCalled();
expect(getOption("banana", true)).toBeVisible();
await userEvent.keyboard("{Shift>}{Tab}", { delay: waitTime });
await userEvent.keyboard("{Enter}", { delay: waitTime });
expect(args.onSubmit).not.toHaveBeenCalled();
await userEvent.keyboard("test"); // Type option that does not exist
await userEvent.keyboard("{Enter}", { delay: waitTime });
expect(args.onSubmit).not.toHaveBeenCalled();
await userEvent.keyboard("{ArrowDown}", { delay: waitTime }); // Select "test" custom option
expect(
canvas.getByRole("option", { name: "test", selected: true }),
).toBeVisible();
await userEvent.keyboard("{Enter}", { delay: waitTime }); // De-select "test"
expect(
canvas.queryByRole("option", { name: "test", selected: false }),
).toBeNull();
expect(args.onSubmit).not.toHaveBeenCalled();
await userEvent.keyboard("{Escape}", { delay: waitTime }); // Clear input field
await userEvent.keyboard("{Enter}", { delay: waitTime }); // Enter on empty Input with closed list
expect(args.onSubmit).toHaveBeenCalledOnce();
},
};
+11 -7
View File
@@ -1,4 +1,4 @@
import React, { ChangeEvent, InputHTMLAttributes } from "react";
import React, { InputHTMLAttributes } from "react";
import { FormFieldProps } from "../useFormField";
/**
@@ -29,7 +29,10 @@ export type MaxSelected = {
export interface ComboboxProps
extends FormFieldProps,
Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "onChange" | "value"> {
Omit<
InputHTMLAttributes<HTMLInputElement>,
"size" | "onChange" | "value" | "defaultValue"
> {
/**
* Combobox label.
*/
@@ -89,12 +92,9 @@ export interface ComboboxProps
/**
* Callback function triggered whenever the value of the input field is triggered.
*
* @param event
* @param value The value after change
*/
onChange?: (
event: ChangeEvent<HTMLInputElement> | null,
value?: string,
) => void;
onChange?: (value: string) => void;
/**
* Callback function triggered whenever the input field is cleared.
*
@@ -156,4 +156,8 @@ export interface ComboboxProps
* This converts the input to a controlled input, so you have to use onChange to update the value.
*/
value?: string;
/**
* Initial value of the input field. Only works when the input is uncontrolled.
*/
defaultValue?: string;
}
@@ -10,8 +10,8 @@ export default {
size: {
control: {
type: "radio",
options: ["medium", "small"],
},
options: ["medium", "small"],
},
error: {
type: "string",
@@ -20,7 +20,7 @@ export default {
parameters: {
chromatic: { disable: true },
},
} as Meta;
} satisfies Meta<typeof ConfirmationPanel>;
type Story = StoryObj<typeof ConfirmationPanel>;
@@ -15,8 +15,8 @@ export default {
size: {
control: {
type: "radio",
options: ["medium", "small"],
},
options: ["medium", "small"],
},
},
parameters: {
@@ -11,8 +11,8 @@ export default {
size: {
control: {
type: "radio",
options: ["medium", "small"],
},
options: ["medium", "small"],
},
description: {
type: "string",
@@ -1,4 +1,4 @@
import { Meta, StoryObj } from "@storybook/react";
import { Meta, StoryFn, StoryObj } from "@storybook/react";
import { fireEvent, within } from "@storybook/test";
import React from "react";
import { ImageIcon } from "@navikt/aksel-icons";
@@ -48,8 +48,8 @@ export const Default: StoryObj<typeof FileUpload.Dropzone> = {
parameters: { chromatic: { disable: true } },
};
export const States: StoryObj = {
render: () => (
export const States: StoryFn = () => {
return (
<div>
<h2>Disabled</h2>
<FileUpload.Dropzone
@@ -78,7 +78,7 @@ export const States: StoryObj = {
onSelect={onSelect}
/>
</div>
),
);
};
States.play = ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -86,8 +86,8 @@ States.play = ({ canvasElement }) => {
fireEvent.dragEnter(button);
};
export const Translation: StoryObj = {
render: () => (
export const Translation: StoryFn = () => {
return (
<div>
<h2>Single file</h2>
<FileUpload.Dropzone
@@ -119,5 +119,5 @@ export const Translation: StoryObj = {
disabled
/>
</div>
),
);
};
@@ -1,4 +1,4 @@
import { Meta, StoryObj } from "@storybook/react";
import { Meta, StoryFn } from "@storybook/react";
import React from "react";
import { FileItem, UNSAFE_FileUpload as FileUpload } from ".";
import { VStack } from "../../layout/stack";
@@ -28,8 +28,8 @@ const filePptx = new File(["abc"], "file.pptx");
const fileWebp = new File(["abc"], "file.webp");
const fileDocx = new File(["abc"], "file.docx");
export const Icons: StoryObj<typeof FileUpload.Item> = {
render: () => (
export const Icons: StoryFn = () => {
return (
<FileUpload>
<VStack gap="5" as="ul">
<FileUpload.Item file={fileTxt} as="li" />
@@ -44,11 +44,11 @@ export const Icons: StoryObj<typeof FileUpload.Item> = {
<FileUpload.Item file={filePptx} as="li" />
</VStack>
</FileUpload>
),
);
};
export const States: StoryObj<typeof FileUpload.Item> = {
render: () => (
export const States: StoryFn = () => {
return (
<div>
<h2>Error</h2>
<FileUpload.Item file={fileTxt} error="Plain error" />
@@ -116,11 +116,11 @@ export const States: StoryObj<typeof FileUpload.Item> = {
}}
/>
</div>
),
);
};
export const Download: StoryObj = {
render: () => (
export const Download: StoryFn = () => {
return (
<VStack gap="5">
<FileUpload.Item
file={{
@@ -144,11 +144,11 @@ export const Download: StoryObj = {
/>
<FileUpload.Item file={fileTxt} />
</VStack>
),
);
};
export const Description: StoryObj = {
render: () => (
export const Description: StoryFn = () => {
return (
<VStack gap="5">
<FileUpload.Item file={fileTxt} description="Mottat 11.11.11" />
<FileUpload.Item
@@ -156,5 +156,5 @@ export const Description: StoryObj = {
description="Lorem ipsum dolor, sit amet consectetur adipisicing elit. Enim officiis nisi beatae quia non iste nihil accusantium nobis amet, officia eius, repellendus a cupiditate, commodi eos! Quis illum repudiandae exercitationem."
/>
</VStack>
),
);
};
@@ -1,4 +1,4 @@
import { Meta, StoryFn, StoryObj } from "@storybook/react";
import { Meta, StoryFn } from "@storybook/react";
import React, { useEffect, useState } from "react";
import { UploadIcon } from "@navikt/aksel-icons";
import { UNSAFE_FileUpload as FileUpload, FileUploadItemProps } from ".";
@@ -11,6 +11,16 @@ import {
FileRejected,
FileRejectionReason,
} from "./FileUpload.types";
import {
States as DropzoneStates,
Translation as DropzoneTranslation,
} from "./file-upload-dropzone.stories";
import {
Description as ItemDescription,
Download as ItemDownload,
Icons as ItemIcons,
States as ItemStates,
} from "./file-upload-item.stories";
const meta: Meta<typeof FileUpload.Dropzone> = {
title: "ds-react/FileUpload",
@@ -180,7 +190,7 @@ export const Single: StoryFn = () => {
};
Single.parameters = { chromatic: { disable: true } };
export const Translation = () => (
export const Translation: StoryFn = () => (
<FileUpload
translations={{
dropzone: {
@@ -230,19 +240,45 @@ export const Translation = () => (
</FileUpload>
);
export const TriggerWithButton: StoryObj<typeof FileUpload.Trigger> = {
render: (props) => {
return (
<FileUpload.Trigger {...props} onSelect={console.log}>
<Button variant="secondary" icon={<UploadIcon aria-hidden />}>
Last opp filer
</Button>
</FileUpload.Trigger>
);
},
args: {
multiple: true,
accept: "",
maxSizeInBytes: 0,
},
export const TriggerWithButton: StoryFn = (props) => (
<FileUpload.Trigger {...props} onSelect={console.log}>
<Button variant="secondary" icon={<UploadIcon aria-hidden />}>
Last opp filer
</Button>
</FileUpload.Trigger>
);
TriggerWithButton.args = {
multiple: true,
accept: "",
maxSizeInBytes: 0,
};
export const Chromatic: StoryFn = () => (
<div>
<h2>Default</h2>
<Default />
<h2>Single</h2>
<Single />
<h2>Translation</h2>
<Translation />
<h2>TriggerWithButton</h2>
<TriggerWithButton />
<h2>DropzoneStates</h2>
<DropzoneStates />
<h2>DropzoneTranslation</h2>
<DropzoneTranslation />
<h2>ItemDescription</h2>
<ItemDescription />
<h2>ItemDownload</h2>
<ItemDownload />
<h2>ItemIcons</h2>
<ItemIcons />
<h2>ItemStates</h2>
<ItemStates />
</div>
);
Chromatic.parameters = {
chromatic: { disable: false },
};
@@ -316,50 +316,6 @@ export const Empty: StoryFn<typeof FormSummary> = () => (
</VStack>
);
export const Chromatic: StoryObj<typeof FormSummary> = {
render: () => (
<div>
<div>
<h2>Default</h2>
<Default>{null}</Default>
</div>
<div>
<h2>Long Texts</h2>
<LongTexts>{null}</LongTexts>
</div>
<div>
<h2>No Link</h2>
<NoLink>{null}</NoLink>
</div>
<div>
<h2>Realistic Usage</h2>
<RealisticUsage>{null}</RealisticUsage>
</div>
<div>
<h2>Empty</h2>
<Empty>{null}</Empty>
</div>
</div>
),
parameters: {
chromatic: {
disable: false,
modes: {
default: {
viewport: {
width: 620,
},
},
sm: {
viewport: {
width: 479,
},
},
},
},
},
};
export const CommaSeparated: StoryFn<typeof FormSummary> = () => (
<FormSummary>
<FormSummary.Header>
@@ -386,3 +342,51 @@ export const CommaSeparated: StoryFn<typeof FormSummary> = () => (
</FormSummary.Answers>
</FormSummary>
);
export const Chromatic: StoryObj<typeof FormSummary> = {
render: () => (
<div>
<div>
<h2>Default</h2>
<Default>{null}</Default>
</div>
<div>
<h2>Long Texts</h2>
<LongTexts>{null}</LongTexts>
</div>
<div>
<h2>No Link</h2>
<NoLink>{null}</NoLink>
</div>
<div>
<h2>Realistic Usage</h2>
<RealisticUsage>{null}</RealisticUsage>
</div>
<div>
<h2>Empty</h2>
<Empty>{null}</Empty>
</div>
<div>
<h2>Comma Separated</h2>
<CommaSeparated>{null}</CommaSeparated>
</div>
</div>
),
parameters: {
chromatic: {
disable: false,
modes: {
default: {
viewport: {
width: 620,
},
},
sm: {
viewport: {
width: 479,
},
},
},
},
},
};
@@ -14,14 +14,14 @@ export default {
size: {
control: {
type: "radio",
options: ["medium", "small"],
},
options: ["medium", "small"],
},
variant: {
control: {
type: "radio",
options: ["primary", "secondary", "simple"],
},
options: ["primary", "secondary", "simple"],
},
error: {
control: {
@@ -32,7 +32,7 @@ export default {
parameters: {
chromatic: { disable: true },
},
} as Meta;
} satisfies Meta<typeof Search>;
type Story = StoryObj<typeof Search>;
@@ -10,14 +10,14 @@ export default {
size: {
control: {
type: "radio",
options: ["medium", "small"],
},
options: ["medium", "small"],
},
position: {
control: {
type: "radio",
options: ["right", "left"],
},
options: ["right", "left"],
},
description: {
type: "string",
@@ -9,8 +9,8 @@ export default {
size: {
control: {
type: "radio",
options: ["medium", "small"],
},
options: ["medium", "small"],
},
description: {
type: "string",
@@ -28,7 +28,7 @@ export default {
parameters: {
chromatic: { disable: true },
},
} as Meta;
} satisfies Meta<typeof TextField>;
type Story = StoryObj<typeof TextField>;
@@ -1,13 +1,18 @@
import { Meta } from "@storybook/react";
import { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { InformationIcon } from "@navikt/aksel-icons";
import { VStack } from "../layout/stack";
import { BodyLong } from "../typography";
import GuidePanel from "./GuidePanel";
type Story = StoryObj<typeof GuidePanel>;
export default {
title: "ds-react/GuidePanel",
component: GuidePanel,
parameters: {
chromatic: { disable: true },
},
} satisfies Meta<typeof GuidePanel>;
const panelText = `Sit sint eu dolore reprehenderit exercitation labore aute anim sit
@@ -15,29 +20,30 @@ adipisicing proident. Tempor ipsum ea cupidatat qui esse do veniam
cupidatat. Excepteur irure reprehenderit esse tempor nisi duis qui ea
enim id.`;
export const Default = {
render: (props: { poster: boolean; colorOverride: boolean }) => {
const style = props.colorOverride
? {
"--ac-guide-panel-illustration-bg": "var(--a-purple-200)",
"--ac-guide-panel-border": "var(--a-purple-400)",
}
: {};
export const Default: StoryObj<{ poster?: boolean; colorOverride?: boolean }> =
{
render: (props) => {
const style = props.colorOverride
? {
"--ac-guide-panel-illustration-bg": "var(--a-purple-200)",
"--ac-guide-panel-border": "var(--a-purple-400)",
}
: {};
return (
<GuidePanel style={style} poster={props?.poster}>
{panelText}
</GuidePanel>
);
},
return (
<GuidePanel style={style} poster={props?.poster}>
{panelText}
</GuidePanel>
);
},
args: {
poster: false,
colorOverride: false,
},
};
args: {
poster: false,
colorOverride: false,
},
};
export const PosterVariants = {
export const PosterVariants: Story = {
render: () => (
<VStack gap="6" align="start">
<GuidePanel>
@@ -58,33 +64,69 @@ export const PosterVariants = {
},
};
export const ColorOverride = () => (
<GuidePanel
style={{
"--ac-guide-panel-illustration-bg": "var(--a-purple-200)",
"--ac-guide-panel-border": "var(--a-purple-400)",
}}
>
{panelText}
</GuidePanel>
);
export const ColorOverride: Story = {
render: () => (
<GuidePanel
style={{
"--ac-guide-panel-illustration-bg": "var(--a-purple-200)",
"--ac-guide-panel-border": "var(--a-purple-400)",
}}
>
{panelText}
</GuidePanel>
),
};
export const Content = () => (
<GuidePanel>
<BodyLong spacing>
Duis et ex ad magna nostrud ut officia nulla cillum commodo sint irure
elit nulla. Ad proident nulla ex sunt exercitation sunt Lorem non laboris
ea ex cillum nulla consequat. Enim pariatur eiusmod quis est fugiat
officia nostrud dolore occaecat nisi.
</BodyLong>
<BodyLong>
Do esse magna nulla amet excepteur. Tempor laboris ipsum magna velit
dolore nulla id ex mollit. Deserunt ut esse laboris pariatur tempor
laborum veniam enim. Nisi deserunt officia minim enim.
</BodyLong>
</GuidePanel>
);
export const Content: Story = {
render: () => (
<GuidePanel>
<BodyLong spacing>
Duis et ex ad magna nostrud ut officia nulla cillum commodo sint irure
elit nulla. Ad proident nulla ex sunt exercitation sunt Lorem non
laboris ea ex cillum nulla consequat. Enim pariatur eiusmod quis est
fugiat officia nostrud dolore occaecat nisi.
</BodyLong>
<BodyLong>
Do esse magna nulla amet excepteur. Tempor laboris ipsum magna velit
dolore nulla id ex mollit. Deserunt ut esse laboris pariatur tempor
laborum veniam enim. Nisi deserunt officia minim enim.
</BodyLong>
</GuidePanel>
),
};
export const CustomIllustration = () => (
<GuidePanel illustration={<InformationIcon />}>{panelText}</GuidePanel>
);
export const CustomIllustration: Story = {
render: () => (
<GuidePanel illustration={<InformationIcon />}>{panelText}</GuidePanel>
),
};
export const Chromatic: Story = {
render: (props, context) => (
<div>
<div>
<h2>Default</h2>
{Default.render?.(Default.args || {}, context)}
</div>
<div>
<h2>PosterVariants</h2>
{PosterVariants.render?.(props, context)}
</div>
<div>
<h2>ColorOverride</h2>
{ColorOverride.render?.(props, context)}
</div>
<div>
<h2>Content</h2>
{Content.render?.(props, context)}
</div>
<div>
<h2>CustomIllustration</h2>
{CustomIllustration.render?.(props, context)}
</div>
</div>
),
parameters: {
chromatic: { disable: false },
},
};
@@ -1,4 +1,4 @@
import { Meta, StoryFn, StoryObj } from "@storybook/react";
import { Meta, StoryFn } from "@storybook/react";
import React, { useEffect, useRef } from "react";
import { BodyLong, Heading } from "../typography";
import HelpText from "./HelpText";
@@ -7,41 +7,42 @@ const meta: Meta<typeof HelpText> = {
title: "ds-react/HelpText",
component: HelpText,
parameters: {
chromatic: { delay: 300 },
chromatic: { disable: true },
},
};
export default meta;
export const Default: StoryObj<typeof HelpText> = {
render: (props) => (
export const Default: StoryFn<typeof HelpText> = (props) => {
return (
<HelpText {...props}>Id ullamco excepteur elit fugiat labore.</HelpText>
),
);
};
args: {
title: "Show tooltip",
Default.args = {
title: "Show tooltip",
};
Default.argTypes = {
placement: {
control: { type: "radio" },
options: [
"top",
"bottom",
"right",
"left",
"top-start",
"top-end",
"bottom-start",
"bottom-end",
"right-start",
"right-end",
"left-start",
"left-end",
],
},
argTypes: {
placement: {
control: { type: "radio" },
options: [
"top",
"bottom",
"right",
"left",
"top-start",
"top-end",
"bottom-start",
"bottom-end",
"right-start",
"right-end",
"left-start",
"left-end",
],
},
strategy: {
control: { type: "radio" },
options: ["fixed", "absolute"],
},
strategy: {
control: { type: "radio" },
options: ["fixed", "absolute"],
},
};
@@ -89,3 +90,20 @@ export const WrapperClassName: StoryFn = () => (
</BodyLong>
</div>
);
export const Chromatic = () => {
return (
<div>
<h2>Default</h2>
<Default>{null}</Default>
<h2>Open</h2>
<Open />
<h2>WrapperClassName</h2>
<WrapperClassName />
</div>
);
};
Chromatic.parameters = {
chromatic: { disable: false, delay: 300 },
};
@@ -14,18 +14,10 @@ import InternalHeader from "./InternalHeader";
export default {
title: "ds-react/InternalHeader",
component: InternalHeader,
argTypes: {
user: {
control: {
type: "radio",
options: ["simple", "with description", "with dropdown"],
},
},
},
parameters: {
chromatic: { disable: true },
},
} as Meta;
} satisfies Meta<typeof InternalHeader>;
type Story = StoryObj<typeof InternalHeader>;
@@ -126,6 +118,13 @@ export const Default = {
args: {
titleAsHeading: false,
systemMenu: false,
user: "simple",
},
argTypes: {
user: {
control: { type: "radio" },
options: ["simple", "with description", "with dropdown"],
},
},
};
@@ -8,6 +8,9 @@ import { Bleed } from "./Bleed";
export default {
title: "ds-react/Primitives/Bleed",
component: Bleed,
parameters: {
chromatic: { disable: true },
},
} satisfies Meta<typeof Bleed>;
export const Default = {
@@ -393,3 +396,25 @@ export const ReflectivePadding = {
</>
),
};
export const Chromatic = {
render: () => (
<VStack gap="4">
<h2>Default</h2>
<Default.render />
<h2>AsChild</h2>
<AsChild.render />
<h2>Breakpoints</h2>
<Breakpoints.render />
<h2>Px</h2>
<Px.render />
<h2>Full</h2>
<Full.render />
<h2>ReflectivePadding</h2>
<ReflectivePadding.render />
</VStack>
),
parameters: {
chromatic: { disable: false },
},
};
+4 -1
View File
@@ -92,7 +92,10 @@ export const HGrid: OverridableComponent<HGridProps, HTMLDivElement> =
<Comp
{...omit(rest, PRIMITIVE_PROPS)}
ref={ref}
className={cl("navds-hgrid", className)}
className={cl("navds-hgrid", className, {
"navds-hgrid-gap": gap,
"navds-hgrid-align": align,
})}
style={styles}
>
{children}
@@ -23,7 +23,7 @@ export default {
control: { type: "radio" },
},
},
} as Meta;
} satisfies Meta<typeof HGrid>;
type Story = StoryObj<typeof HGrid>;
@@ -3,6 +3,7 @@ import React, { useEffect } from "react";
import bgColors from "@navikt/ds-tokens/src/colors-bg.json";
import { Box } from "../box";
import { HGrid } from "../grid";
import { VStack } from "../stack";
import Page from "./Page";
import { widths } from "./parts/PageBlock";
@@ -11,6 +12,7 @@ const meta: Meta = {
component: Page,
parameters: {
layout: "fullscreen",
chromatic: { disable: true },
},
};
@@ -204,12 +206,6 @@ WithDecorator.decorators = [
},
];
WithDecorator.parameters = {
chromatic: {
disable: true,
},
};
export const OutsideBackground = () => {
return (
<Page
@@ -269,3 +265,28 @@ function Footer({ width = "lg", gutters = false }: any) {
</Page.Block>
);
}
export const Chromatic = () => {
return (
<VStack gap="2">
<h2>Default</h2>
<Default />
<h2>BelowFold</h2>
<BelowFold />
<h2>Background</h2>
<Background />
<h2>ContentBlockPadding</h2>
<ContentBlockPadding />
<h2>Gutters</h2>
<Gutters />
<h2>OutsideBackground</h2>
<OutsideBackground />
</VStack>
);
};
Chromatic.parameters = {
chromatic: {
disable: false,
},
};
@@ -1,4 +1,4 @@
import type { Meta } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { Hide } from ".";
import { Tag } from "../../tag";
@@ -7,9 +7,16 @@ import { VStack } from "../stack";
export default {
title: "ds-react/Primitives/Hide",
component: Hide,
parameters: {
chromatic: {
disable: true,
},
},
} satisfies Meta<typeof Hide>;
export const Default = {
type Story = StoryObj<typeof Hide>;
export const Default: Story = {
render: () => (
<VStack gap="12">
<VStack gap="2" align="center">
@@ -44,7 +51,7 @@ export const Default = {
),
};
export const AsChild = {
export const AsChild: Story = {
render: () => (
<VStack gap="12">
<VStack gap="2" align="center">
@@ -78,3 +85,19 @@ export const AsChild = {
</VStack>
),
};
export const Chromatic: Story = {
render: (props, context) => (
<VStack gap="4">
<h2>Default</h2>
{Default.render?.(props, context)}
<h2>AsChild</h2>
{AsChild.render?.(props, context)}
</VStack>
),
parameters: {
chromatic: {
disable: false,
},
},
};
@@ -1,4 +1,4 @@
import type { Meta } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { Show } from ".";
import { Tag } from "../../tag";
@@ -7,9 +7,16 @@ import { VStack } from "../stack";
export default {
title: "ds-react/Primitives/Show",
component: Show,
parameters: {
chromatic: {
disable: true,
},
},
} satisfies Meta<typeof Show>;
export const Default = {
type Story = StoryObj<typeof Show>;
export const Default: Story = {
render: () => (
<VStack gap="12">
<VStack gap="2" align="center">
@@ -44,7 +51,7 @@ export const Default = {
),
};
export const AsChild = {
export const AsChild: Story = {
render: () => (
<VStack gap="12">
<VStack gap="2" align="center">
@@ -78,3 +85,19 @@ export const AsChild = {
</VStack>
),
};
export const Chromatic: Story = {
render: (props, context) => (
<VStack gap="4">
<h2>Default</h2>
{Default.render?.(props, context)}
<h2>AsChild</h2>
{AsChild.render?.(props, context)}
</VStack>
),
parameters: {
chromatic: {
disable: false,
},
},
};
@@ -73,7 +73,7 @@ export const Stack: OverridableComponent<StackProps, HTMLDivElement> =
children,
className,
as: Component = "div",
align = "stretch",
align,
justify,
wrap = true,
gap,
@@ -86,7 +86,6 @@ export const Stack: OverridableComponent<StackProps, HTMLDivElement> =
) => {
const style: React.CSSProperties = {
..._style,
"--__ac-stack-wrap": wrap ? "wrap" : "nowrap",
...getResponsiveProps(`stack`, "gap", "spacing", gap),
...getResponsiveValue(`stack`, "direction", direction),
...getResponsiveValue(`stack`, "align", align),
@@ -104,6 +103,11 @@ export const Stack: OverridableComponent<StackProps, HTMLDivElement> =
className={cl("navds-stack", className, {
"navds-vstack": direction === "column",
"navds-hstack": direction === "row",
"navds-stack-gap": gap,
"navds-stack-align": align,
"navds-stack-justify": justify,
"navds-stack-direction": direction,
"navds-stack-wrap": wrap,
})}
>
{children}
@@ -1,4 +1,4 @@
import type { Meta } from "@storybook/react";
import type { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { HStack, Spacer, Stack, VStack } from ".";
import { Box } from "../box";
@@ -6,9 +6,14 @@ import { Box } from "../box";
export default {
title: "ds-react/Primitives/Stack",
component: HStack,
parameters: {
chromatic: { disable: true },
},
} satisfies Meta<typeof HStack>;
export const Horizontal = {
type Story = StoryObj<typeof Stack>;
export const Horizontal: Story = {
render: () => (
<HStack gap="4">
<Placeholders count={4} />
@@ -16,7 +21,7 @@ export const Horizontal = {
),
};
export const Spacing = {
export const Spacing: Story = {
render: () => (
<div style={{ height: "80vh", display: "flex" }}>
<VStack gap="8">
@@ -41,7 +46,7 @@ export const Spacing = {
},
};
export const Vertical = {
export const Vertical: Story = {
render: () => (
<VStack gap="4">
<Placeholders count={4} />
@@ -49,7 +54,7 @@ export const Vertical = {
),
};
export const VerticalDemo = {
export const VerticalDemo: Story = {
render: () => (
<VStack gap="2">
<VStack>
@@ -63,7 +68,7 @@ export const VerticalDemo = {
),
};
export const VerticalAlign = {
export const VerticalAlign: Story = {
render: () => (
<VStack gap="4">
<VStack align="start">
@@ -82,7 +87,7 @@ export const VerticalAlign = {
},
};
export const OverrideComponent = {
export const OverrideComponent: Story = {
render: () => (
<VStack gap="4" as="form" onSubmit={(e) => e.preventDefault()}>
<Placeholders count={4} />
@@ -90,7 +95,7 @@ export const OverrideComponent = {
),
};
export const Responsive = {
export const Responsive: Story = {
render: () => (
<VStack gap={{ xs: "1", sm: "3", md: "6", lg: "10", xl: "16" }}>
<Placeholders count={4} />
@@ -98,7 +103,7 @@ export const Responsive = {
),
};
export const Nested = {
export const Nested: Story = {
render: () => (
<VStack gap="16">
<Placeholders count={2}>
@@ -110,7 +115,7 @@ export const Nested = {
),
};
export const DividerDemo = {
export const DividerDemo: Story = {
render: () => (
<div style={{ height: "80vh", width: "40rem" }}>
<VStack gap={{ xs: "2", md: "6", lg: "12" }}>
@@ -134,7 +139,7 @@ export const DividerDemo = {
),
};
export const ResponsiveDirection = {
export const ResponsiveDirection: Story = {
render: () => (
<Box
background="surface-alt-3-subtle"
@@ -181,3 +186,31 @@ function Placeholders({
</>
);
}
export const Chromatic: Story = {
render: (props, context) => (
<VStack gap="4">
<h2>Spacing</h2>
{Spacing.render?.(props, context)}
<h2>Vertical</h2>
{Vertical.render?.(props, context)}
<h2>VerticalDemo</h2>
{VerticalDemo.render?.(props, context)}
<h2>VerticalAlign</h2>
{VerticalAlign.render?.(props, context)}
<h2>OverrideComponent</h2>
{OverrideComponent.render?.(props, context)}
<h2>Responsive</h2>
{Responsive.render?.(props, context)}
<h2>Nested</h2>
{Nested.render?.(props, context)}
<h2>DividerDemo</h2>
{DividerDemo.render?.(props, context)}
<h2>ResponsiveDirection</h2>
{ResponsiveDirection.render?.(props, context)}
</VStack>
),
parameters: {
chromatic: { disable: false },
},
};
@@ -12,7 +12,7 @@ export default {
},
},
},
} as Meta;
} satisfies Meta<typeof LinkPanel>;
export const Default = {
render: (props: any) => {
+1 -1
View File
@@ -16,7 +16,7 @@ export default {
parameters: {
chromatic: { disable: true },
},
} as Meta;
} satisfies Meta<typeof List>;
type Story = StoryObj<typeof List>;
@@ -10,28 +10,28 @@ export default {
size: {
control: {
type: "radio",
options: [
"3xlarge",
"2xlarge",
"xlarge",
"large",
"medium",
"small",
"xsmall",
],
},
options: [
"3xlarge",
"2xlarge",
"xlarge",
"large",
"medium",
"small",
"xsmall",
],
},
variant: {
control: {
type: "radio",
options: ["neutral", "interaction", "inverted"],
},
options: ["neutral", "interaction", "inverted"],
},
},
parameters: {
chromatic: { disable: false },
},
} as Meta;
} satisfies Meta<typeof Loader>;
type Story = StoryObj<typeof Loader>;
@@ -0,0 +1,629 @@
import { Meta } from "@storybook/react";
import React, { useRef, useState } from "react";
import { Menu } from "./Menu";
export default {
title: "Utilities/FloatingMenu",
decorators: [
(Story) => (
<div>
{storyStyles}
<Story />
</div>
),
],
} satisfies Meta<typeof Menu>;
export const Styled = () => (
<MenuWithAnchor>
<Menu.Item className="item" onSelect={() => window.alert("undo")}>
Undo
</Menu.Item>
<Menu.Item className="item" onSelect={() => window.alert("redo")}>
Redo
</Menu.Item>
<Menu.Separator className="separator" />
<Menu.Item className="item" disabled onSelect={() => window.alert("cut")}>
Cut
</Menu.Item>
<Menu.Item className="item" onSelect={() => window.alert("copy")}>
Copy
</Menu.Item>
<Menu.Item className="item" onSelect={() => window.alert("paste")}>
Paste
</Menu.Item>
</MenuWithAnchor>
);
export const Submenus = () => {
const [open1, setOpen1] = React.useState(false);
const [open2, setOpen2] = React.useState(false);
const [open3, setOpen3] = React.useState(false);
const [open4, setOpen4] = React.useState(false);
return (
<MenuWithAnchor>
<Menu.Item className="item" onSelect={() => window.alert("undo")}>
Undo
</Menu.Item>
<Submenu open={open1} onOpenChange={setOpen1}>
<Menu.Item className="item" disabled>
Disabled
</Menu.Item>
<Menu.Item className="item" onSelect={() => window.alert("one")}>
One
</Menu.Item>
<Submenu open={open2} onOpenChange={setOpen2}>
<Menu.Item className="item" onSelect={() => window.alert("one")}>
One
</Menu.Item>
<Menu.Item className="item" onSelect={() => window.alert("two")}>
Two
</Menu.Item>
<Menu.Item className="item" onSelect={() => window.alert("three")}>
Three
</Menu.Item>
<Menu.Item className="item" onSelect={() => window.alert("four")}>
Four
</Menu.Item>
<Menu.Item className="item" onSelect={() => window.alert("five")}>
Five
</Menu.Item>
<Menu.Item className="item" onSelect={() => window.alert("six")}>
Six
</Menu.Item>
</Submenu>
<Submenu heading="Sub Menu" open={open3} onOpenChange={setOpen3}>
<Menu.Item className="item" onSelect={() => window.alert("one")}>
One
</Menu.Item>
<Menu.Item className="item" onSelect={() => window.alert("two")}>
Two
</Menu.Item>
<Menu.Item className="item" onSelect={() => window.alert("three")}>
Three
</Menu.Item>
</Submenu>
<Menu.Item className="item" onSelect={() => window.alert("two")}>
Two
</Menu.Item>
<Submenu open={open4} onOpenChange={setOpen4} disabled>
<Menu.Item className="item" onSelect={() => window.alert("one")}>
One
</Menu.Item>
<Menu.Item className="item" onSelect={() => window.alert("two")}>
Two
</Menu.Item>
<Menu.Item className="item" onSelect={() => window.alert("three")}>
Three
</Menu.Item>
</Submenu>
<Menu.Item className="item" onSelect={() => window.alert("three")}>
Three
</Menu.Item>
</Submenu>
<Menu.Separator className="separator" />
<Menu.Item className="item" disabled onSelect={() => window.alert("cut")}>
Cut
</Menu.Item>
<Menu.Item className="item" onSelect={() => window.alert("copy")}>
Copy
</Menu.Item>
<Menu.Item className="item" onSelect={() => window.alert("paste")}>
Paste
</Menu.Item>
</MenuWithAnchor>
);
};
const foodGroups: {
label?: string;
foods: { value: string; label: string; disabled?: boolean }[];
}[] = [
{
label: "Fruits",
foods: [
{ value: "apple", label: "Apple" },
{ value: "banana", label: "Banana" },
{ value: "blueberry", label: "Blueberry" },
{ value: "grapes", label: "Grapes" },
{ value: "pineapple", label: "Pineapple" },
],
},
{
label: "Vegetables",
foods: [
{ value: "aubergine", label: "Aubergine" },
{ value: "broccoli", label: "Broccoli" },
{ value: "carrot", label: "Carrot", disabled: true },
{ value: "courgette", label: "Courgette" },
{ value: "leek", label: "Leek" },
],
},
{
label: "Meat",
foods: [
{ value: "beef", label: "Beef" },
{ value: "beef-with-sauce", label: "Beef with sauce" },
{ value: "chicken", label: "Chicken" },
{ value: "lamb", label: "Lamb" },
{ value: "pork", label: "Pork" },
],
},
{
foods: [
{ value: "candies", label: "Candies" },
{ value: "chocolates", label: "Chocolates" },
],
},
];
export const WithLabels = () => (
<MenuWithAnchor>
{foodGroups.map((foodGroup, index) => (
<Menu.Group key={index}>
{foodGroup.label && (
<div className="label" key={foodGroup.label}>
{foodGroup.label}
</div>
)}
{foodGroup.foods.map((food) => (
<Menu.Item
key={food.value}
className="item"
disabled={food.disabled}
onSelect={() => window.alert(food.label)}
>
{food.label}
</Menu.Item>
))}
{index < foodGroups.length - 1 && (
<Menu.Separator className="separator" />
)}
</Menu.Group>
))}
</MenuWithAnchor>
);
WithLabels.parameters = {
layout: "padded",
};
export const CheckboxItems = () => {
const options = ["Crows", "Ravens", "Magpies", "Jackdaws"];
const [selection, setSelection] = React.useState<string[]>([]);
const handleSelectAll = () => {
setSelection((currentSelection) =>
currentSelection.length === options.length ? [] : options,
);
};
return (
<MenuWithAnchor>
<Menu.CheckboxItem
className="item"
checked={
selection.length === options.length
? true
: selection.length
? "indeterminate"
: false
}
onCheckedChange={handleSelectAll}
>
Select all
{selection.length === options.length ? <TickIcon /> : <div>—</div>}
</Menu.CheckboxItem>
<Menu.Separator className="separator" />
{options.map((option) => (
<Menu.CheckboxItem
key={option}
className="item"
checked={selection.includes(option)}
onCheckedChange={() =>
setSelection((current) =>
current.includes(option)
? current.filter((el) => el !== option)
: current.concat(option),
)
}
>
{option}
<Menu.ItemIndicator>
<TickIcon />
</Menu.ItemIndicator>
</Menu.CheckboxItem>
))}
</MenuWithAnchor>
);
};
export const RadioItems = () => {
const files = ["README.md", "index.js", "page.css"];
const [file, setFile] = React.useState(files[1]);
return (
<MenuWithAnchor>
<Menu.Item className="item" onSelect={() => window.alert("minimize")}>
Minimize window
</Menu.Item>
<Menu.Item className="item" onSelect={() => window.alert("zoom")}>
Zoom
</Menu.Item>
<Menu.Item className="item" onSelect={() => window.alert("smaller")}>
Smaller
</Menu.Item>
<Menu.Separator className="separator" />
<Menu.RadioGroup value={file} onValueChange={setFile}>
{files.map((_file) => (
<Menu.RadioItem key={_file} className="item" value={_file}>
{_file}
<Menu.ItemIndicator>
<CircleIcon />
</Menu.ItemIndicator>
</Menu.RadioItem>
))}
</Menu.RadioGroup>
</MenuWithAnchor>
);
};
export const Animated = () => {
const files = ["README.md", "index.js", "page.css"];
const [file, setFile] = React.useState(files[1]);
const [open, setOpen] = React.useState(true);
const checkboxItems = [
{ label: "Bold", state: React.useState(false) },
{ label: "Italic", state: React.useState(true) },
{ label: "Underline", state: React.useState(false) },
{ label: "Strikethrough", state: React.useState(false), disabled: true },
];
return (
<>
<label>
<input
type="checkbox"
checked={open}
onChange={(event) => setOpen(event.target.checked)}
/>{" "}
open
</label>
<br />
<br />
<MenuWithAnchor className="contentAnimated content" open={open}>
{checkboxItems.map(
({ label, state: [checked, setChecked], disabled }) => (
<Menu.CheckboxItem
key={label}
className="item"
checked={checked}
onCheckedChange={setChecked}
disabled={disabled}
>
{label}
<Menu.ItemIndicator>
<TickIcon />
</Menu.ItemIndicator>
</Menu.CheckboxItem>
),
)}
<Menu.RadioGroup value={file} onValueChange={setFile}>
{files.map((_file) => (
<Menu.RadioItem key={_file} className="item" value={_file}>
{_file}
<Menu.ItemIndicator>
<TickIcon />
</Menu.ItemIndicator>
</Menu.RadioItem>
))}
</Menu.RadioGroup>
</MenuWithAnchor>
</>
);
};
type MenuProps = Omit<
React.ComponentProps<typeof Menu> & React.ComponentProps<typeof Menu.Content>,
| "trapFocus"
| "onCloseAutoFocus"
| "disableOutsidePointerEvents"
| "disableOutsideScroll"
>;
const MenuWithAnchor = (props: MenuProps) => {
const { open = true, children, ...contentProps } = props;
return (
<Menu open={open} onOpenChange={() => {}} modal={false}>
<Menu.Anchor>
<div />
</Menu.Anchor>
<Menu.Portal>
<Menu.Content
className="content"
onCloseAutoFocus={(event) => event.preventDefault()}
align="start"
{...contentProps}
>
{children}
</Menu.Content>
</Menu.Portal>
</Menu>
);
};
export const MenuWithOpenButton = () => {
const [open, setOpen] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
return (
<Menu open={open} onOpenChange={() => setOpen((x) => !x)} modal={false}>
<Menu.Anchor asChild>
<button
ref={triggerRef}
type="button"
onPointerDown={(event) => {
// Only open if it's the left button (mousedown gets triggered by all mouse buttons)
// but not when the control key is pressed (avoiding MacOS right click)
if (event.button === 0 && event.ctrlKey === false) {
setOpen((x) => !x);
if (!open) {
// Prevent trigger focusing when opening
// This allows the content to be given focus without competition
event.preventDefault();
// Close if pointerUp outside of the content/trigger
const cb = (e: PointerEvent) => {
if (!(e.target instanceof Node)) return;
const isInsideSafezone =
contentRef.current?.contains(e.target) ||
triggerRef.current?.contains(e.target) ||
e.target === triggerRef.current ||
e.target === contentRef.current;
if (!isInsideSafezone) {
setOpen(false);
}
};
document.addEventListener("pointerup", cb, {
once: true,
});
}
}
}}
onPointerUp={(event) => {
console.log(event);
}}
onKeyDown={(event) => {
if (["Enter", " "].includes(event.key)) setOpen((x) => !x);
if (event.key === "ArrowDown") setOpen(true);
// Prevent keydown from scrolling window / first focused item to execute
// that keydown (inadvertently closing the menu)
if (["Enter", " ", "ArrowDown"].includes(event.key))
event.preventDefault();
}}
>
button
</button>
</Menu.Anchor>
<Menu.Portal>
<Menu.Content
className="content"
align="start"
ref={contentRef}
onCloseAutoFocus={() => {
triggerRef.current?.focus();
}}
>
<Menu.Item className="item" onSelect={() => window.alert("undo")}>
Undo
</Menu.Item>
<Menu.Item className="item" onSelect={() => window.alert("redo")}>
Redo
</Menu.Item>
<Menu.Separator className="separator" />
<Menu.Item
className="item"
disabled
onSelect={() => window.alert("cut")}
>
Cut
</Menu.Item>
<Menu.Item className="item" onSelect={() => window.alert("copy")}>
Copy
</Menu.Item>
<Menu.Item className="item" onSelect={() => window.alert("paste")}>
Paste
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
);
};
const Submenu: React.FC<
MenuProps & { animated?: boolean; disabled?: boolean; heading?: string }
> = (props) => {
const {
heading = "Submenu",
open = true,
onOpenChange,
children,
animated = false,
disabled,
...contentProps
} = props;
return (
<Menu.Sub open={open} onOpenChange={onOpenChange}>
<Menu.SubTrigger className="subTrigger" disabled={disabled}>
{heading} →
</Menu.SubTrigger>
<Menu.Portal>
<Menu.SubContent
className={animated ? "contentAnimated content" : "content"}
{...contentProps}
>
{children}
</Menu.SubContent>
</Menu.Portal>
</Menu.Sub>
);
};
export const TestMenu = () => {
return (
<Menu open onOpenChange={() => {}} modal={true}>
<Menu.Anchor asChild>
<button>Menu</button>
</Menu.Anchor>
<Menu.Portal>
<Menu.Content
className="content"
onCloseAutoFocus={(event) => event.preventDefault()}
align="start"
>
<Menu.Item className="item" onSelect={() => window.alert("Undo")}>
Undo
</Menu.Item>
<Menu.Item className="item" onSelect={() => window.alert("Add")}>
Add
</Menu.Item>
<Menu.Item className="item" onSelect={() => window.alert("Save")}>
Save
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
);
};
const itemCss = `
display: flex;
align-items: center;
justify-content: space-between;
line-height: 1;
cursor: default;
white-space: nowrap;
height: 25px;
padding: 0 10px;
color: black;
border-radius: 3px;
`;
const storyStyles = (
<style>{`
*:focus {
color: red !important;
}
.content {
display: inline-block;
box-sizing: border-box;
min-width: 130px;
background-color: white;
border: 1px solid gray;
border-radius: 6px;
padding: 5px;
box-shadow: 0 5px 10px 0 rgba(0, 0, 0, 0.1);
font-size: 13px;
&:focus-within {
borderColor: black;
}
}
.label {
${itemCss}
font-weight: 600;
font-size: 14px;
background-color: var(--a-gray-100);
}
.item {
${itemCss}
outline: none;
&[data-disabled] {
color: gray !important;
}
>[data-state="unchecked"]{
visibility: hidden;
}
}
.subTrigger {
${itemCss}
&[data-disabled] {
color: gray;
}
}
.separator {
margin: 5px 10px;
height: 1px;
background-color: gray;
}
.contentAnimated[data-state="open"] {
animation: animateIn 300ms ease;
}
.contentAnimated[data-state="closed"] {
animation: animateOut 300ms ease;
}
@keyframes animateIn {
from { transform: scale(0.95); opacity: 0.01; }
to { transform: scale(1); opacity: 1; }
}
@keyframes animateOut {
from { transform: scale(1); opacity: 1; }
to { transform: scale(0.95); opacity: 0; }
}
`}</style>
);
const TickIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
width="12"
height="12"
fill="none"
stroke="currentcolor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="3"
>
<path d="M2 20 L12 28 30 4" />
</svg>
);
const CircleIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
width="12"
height="12"
fill="none"
stroke="currentcolor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="6"
>
<circle cx="16" cy="16" r="4" />
</svg>
);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,84 @@
import * as React from "react";
import { forwardRef, useEffect, useState } from "react";
import { Slot } from "../../../slot/Slot";
import { useCallbackRef, useMergeRefs } from "../../../util/hooks";
const AUTOFOCUS_ON_MOUNT = "focusScope.autoFocusOnMount";
const AUTOFOCUS_ON_UNMOUNT = "focusScope.autoFocusOnUnmount";
const EVENT_OPTIONS = { bubbles: false, cancelable: true };
interface FocusScopeProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Event handler called on mount, unless the component already has focus. Used for auto-focusing.
* Can be prevented.
*/
onMountHandler?: (event: Event) => void;
/**
* Event handler called on unmount. Used for auto-focusing.
* Can be prevented.
*/
onUnmountHandler?: (event: Event) => void;
}
/**
* FocusScope manages focus on mount and unmount of container.
* This is used to better handle autofocus of elements when mounted and unmounted.
* Example usage:
* - Focus first item in a list when mounted
* - Focus a button when unmounted
*/
const FocusScope = forwardRef<HTMLDivElement, FocusScopeProps>(
(
{
onMountHandler: onMountHandlerCallback,
onUnmountHandler: onUnmountHandlerCallback,
...rest
},
ref,
) => {
const [container, setContainer] = useState<HTMLElement | null>(null);
const onMountHandler = useCallbackRef(onMountHandlerCallback);
const onUnmountHandler = useCallbackRef(onUnmountHandlerCallback);
const composedRefs = useMergeRefs(ref, setContainer);
useEffect(() => {
if (!container) return;
const ownerDocument = container.ownerDocument ?? globalThis?.document;
const hasFocus = container.contains(ownerDocument.activeElement);
if (!hasFocus) {
const mountEvent = new CustomEvent(AUTOFOCUS_ON_MOUNT, EVENT_OPTIONS);
container.addEventListener(AUTOFOCUS_ON_MOUNT, onMountHandler);
container.dispatchEvent(mountEvent);
}
return () => {
container.removeEventListener(AUTOFOCUS_ON_MOUNT, onMountHandler);
/**
* https://github.com/facebook/react/issues/17894
* As usual when dealing with focus and useEffect,
* we need to defer the focus to the next event-loop
* setTimeout makes sure the code is ran after the next render-cycle
*/
setTimeout(() => {
const unmountEvent = new CustomEvent(
AUTOFOCUS_ON_UNMOUNT,
EVENT_OPTIONS,
);
container.addEventListener(AUTOFOCUS_ON_UNMOUNT, onUnmountHandler);
container.dispatchEvent(unmountEvent);
// we need to remove the listener after we `dispatchEvent`
container.removeEventListener(AUTOFOCUS_ON_UNMOUNT, onUnmountHandler);
}, 0);
};
}, [container, onMountHandler, onUnmountHandler]);
return <Slot tabIndex={-1} {...rest} ref={composedRefs} />;
},
);
export { FocusScope, type FocusScopeProps };
@@ -0,0 +1,121 @@
import React, { forwardRef, useCallback, useEffect, useRef } from "react";
import { Slot } from "../../../slot/Slot";
import { composeEventHandlers } from "../../../util/composeEventHandlers";
import { useCallbackRef, useMergeRefs } from "../../../util/hooks";
import { DescendantsManager } from "../../../util/hooks/descendants/descendant";
interface RovingFocusProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "tabIndex"> {
asChild?: boolean;
descendants: DescendantsManager<HTMLDivElement, object>;
onEntryFocus?: (event: Event) => void;
}
const ENTRY_FOCUS = "rovingFocusGroup.onEntryFocus";
const EVENT_OPTIONS = { bubbles: false, cancelable: true };
const RovingFocus = forwardRef<HTMLDivElement, RovingFocusProps>(
(
{
children,
asChild,
descendants,
onKeyDown,
onEntryFocus,
onMouseDown,
onFocus,
...rest
}: RovingFocusProps,
ref,
) => {
const _ref = React.useRef<HTMLDivElement>(null);
const composedRefs = useMergeRefs(ref, _ref);
const handleEntryFocus = useCallbackRef(onEntryFocus);
const isMouseFocusRef = useRef(false);
useEffect(() => {
const node = _ref.current;
if (node) {
node.addEventListener(ENTRY_FOCUS, handleEntryFocus);
return () => node.removeEventListener(ENTRY_FOCUS, handleEntryFocus);
}
}, [handleEntryFocus]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
const loop = false;
const ownerDocument =
_ref?.current?.ownerDocument ?? globalThis?.document;
const idx = descendants
.values()
.findIndex((x) => x.node.isSameNode(ownerDocument.activeElement));
const nextItem = () => {
const next = descendants.nextEnabled(idx, loop);
next && next.node?.focus();
};
const prevItem = () => {
const prev = descendants.prevEnabled(idx, loop);
prev && prev.node?.focus();
};
const firstItem = () => {
const first = descendants.firstEnabled();
first && first.node?.focus();
};
const lastItem = () => {
const last = descendants.lastEnabled();
last && last.node?.focus();
};
const keyMap: Record<string, React.KeyboardEventHandler> = {
ArrowUp: prevItem,
ArrowDown: nextItem,
Home: firstItem,
End: lastItem,
};
const action = keyMap[event.key];
if (action) {
event.preventDefault();
action(event);
}
},
[descendants],
);
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={composedRefs}
{...rest}
tabIndex={descendants.enabledCount() === 0 ? -1 : 0}
style={{ outline: "none", ...rest.style }}
onKeyDown={composeEventHandlers(onKeyDown, handleKeyDown)}
onMouseDown={composeEventHandlers(onMouseDown, () => {
isMouseFocusRef.current = true;
})}
onFocus={composeEventHandlers(onFocus, (event) => {
if (event.target === event.currentTarget) {
const entryFocusEvent = new CustomEvent(ENTRY_FOCUS, EVENT_OPTIONS);
event.currentTarget.dispatchEvent(entryFocusEvent);
if (!entryFocusEvent.defaultPrevented) {
descendants.firstEnabled()?.node.focus({ preventScroll: true });
}
}
isMouseFocusRef.current = false;
})}
>
{children}
</Comp>
);
},
);
export { RovingFocus, type RovingFocusProps };
@@ -0,0 +1,17 @@
import React, { forwardRef } from "react";
import { Slot } from "../../../slot/Slot";
interface SlottedDivProps extends React.HTMLAttributes<HTMLDivElement> {
asChild?: boolean;
}
const SlottedDivElement = forwardRef<HTMLDivElement, SlottedDivProps>(
({ asChild, ...rest }, forwardedRef) => {
const Comp = asChild ? Slot : "div";
return <Comp {...rest} ref={forwardedRef} />;
},
);
type SlottedDivElementRef = React.ElementRef<typeof SlottedDivElement>;
export { SlottedDivElement, type SlottedDivElementRef, type SlottedDivProps };
@@ -48,9 +48,12 @@ export function useTabList() {
End: lastTab,
};
const hasModifiers =
event.shiftKey || event.ctrlKey || event.altKey || event.metaKey;
const action = keyMap[event.key];
if (action) {
if (action && !hasModifiers) {
event.preventDefault();
action(event);
} else if (event.key === "Tab") {
@@ -8,7 +8,7 @@ export default {
title: "ds-react/Timeline",
component: Timeline,
argTypes: {},
} as Meta;
} satisfies Meta<typeof Timeline>;
const DummyLabel = () => {
return (
@@ -77,9 +77,12 @@ export function useToggleItem<P extends UseToggleItemProps>(
End: lastTab,
};
const hasModifiers =
event.shiftKey || event.ctrlKey || event.altKey || event.metaKey;
const action = keyMap[event.key];
if (action) {
if (action && !hasModifiers) {
event.preventDefault();
action(event);
} else if (event.key === "Tab") {
@@ -3,6 +3,7 @@ import React from "react";
import { Alert } from "../alert";
import { Button } from "../button";
import { Search } from "../form/search";
import { VStack } from "../layout/stack";
import Tooltip from "./Tooltip";
export default {
@@ -25,11 +26,11 @@ export default {
placement: {
control: {
type: "radio",
options: ["top", "right", "bottom", "left"],
},
options: ["top", "right", "bottom", "left"],
},
},
} as Meta;
} satisfies Meta<typeof Tooltip>;
export const Demo = () => (
<div>
@@ -45,28 +46,25 @@ export const Demo = () => (
</div>
);
export const Default = {
render: (props) => {
return (
<Tooltip
content="Tooltip example"
open={props?.open}
keys={props?.keys ? ["Cmd", "K"] : undefined}
placement={props?.placement}
arrow={props?.arrow}
delay={props?.delay}
offset={props?.offset}
>
<Button aria-describedby="test123">Tooltip</Button>
</Tooltip>
);
},
args: {
keys: false,
arrow: true,
delay: 150,
},
export const Default = (props) => {
return (
<Tooltip
content="Tooltip example"
open={props?.open}
keys={props?.keys ? ["Cmd", "K"] : undefined}
placement={props?.placement}
arrow={props?.arrow}
delay={props?.delay}
offset={props?.offset}
>
<Button aria-describedby="test123">Tooltip</Button>
</Tooltip>
);
};
Default.args = {
keys: false,
arrow: true,
delay: 150,
};
export const Placement = () => {
@@ -91,11 +89,34 @@ export const Placement = () => {
export const Keys = () => {
return (
<Tooltip
content="Tooltip example Laboris reprehenderit sit sunt nisi velit mollit esse excepteur. Consectetur ullamco quis laboris enim nulla amet proident proident deserunt laborum. Aliqua adipisicing ipsum nisi ipsum nisi pariatur tempor amet aute labore laboris eiusmod adipisicing eu. Et cillum ipsum voluptate ea irure aliquip laboris mollit in. Voluptate tempor do voluptate reprehenderit ea dolor velit ullamco et magna enim ut sit. Pariatur culpa nulla consectetur voluptate id Lorem incididunt magna aliqua sunt ut Lorem. Laborum est quis aute enim et fugiat aute."
content="Tooltip example Laboris reprehenderit sit sunt nisi velit mollit esse excepteur. "
open={true}
keys={["CMD", "K"]}
keys={["CMD", "I"]}
>
<div>Element</div>
</Tooltip>
);
};
export const Chromatic = () => {
return (
<VStack gap="24">
<div>
<h2>Default</h2>
<Default />
</div>
<div>
<h2>Placement</h2>
<Placement />
</div>
<div>
<h2>Keys</h2>
<Keys />
</div>
</VStack>
);
};
Chromatic.parameters = {
chromatic: { disable: false },
};
@@ -0,0 +1,183 @@
import { Meta, StoryObj } from "@storybook/react";
import React from "react";
const fontWeights = [100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const;
interface FontProps {
weight: (typeof fontWeights)[number];
family: "Source Sans Pro" | "Source Sans 3";
cyrillic?: boolean;
samisk?: boolean;
showDefault?: boolean;
mixed?: boolean;
italic?: boolean;
}
const lorem =
"lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.";
const cyrillicAlphabet =
"А Б В Г Д Е Ё Ж З И Й К Л М Н О П Р С Т У Ф Х Ц Ч Ш Щ Ъ Ы Ь Э Ю Я а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я";
const samisk = "á c č đ h i ŋ o š ŧ u z ž";
function seededRandom(seed: number) {
return function () {
seed = (seed * 9301 + 49297) % 233280;
return seed / 233280;
};
}
const shuffle = () => {
const array = (lorem + cyrillicAlphabet + samisk).split("");
const random = seededRandom(99);
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array.join("");
};
const FontComponent = ({ showDefault = true, ...props }: FontProps) => {
return (
<div
style={{
fontWeight: props.weight,
fontFamily: props.family,
fontStyle: props.italic ? "italic" : undefined,
}}
>
{showDefault && (
<p>
lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
minim veniam, quis nostrud exercitation ullamco laboris nisi ut
aliquip ex ea commodo consequat.
</p>
)}
{props?.cyrillic && (
<>
<h3>cyrillic</h3>
<p>{cyrillicAlphabet}</p>
</>
)}
{props?.samisk && (
<>
<h3>samisk</h3>
<p>{samisk}</p>
</>
)}
{props?.mixed && (
<>
<h3>mixed</h3>
<p>{shuffle()}</p>
</>
)}
</div>
);
};
const meta: Meta<typeof FontComponent> = {
title: "ds-react/Typography/Font",
component: FontComponent,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof FontComponent>;
export const Default: Story = {
args: {
weight: 400,
family: "Source Sans 3",
cyrillic: true,
samisk: true,
mixed: true,
italic: false,
},
};
export const Italic: Story = {
args: {
weight: 400,
family: "Source Sans 3",
cyrillic: true,
samisk: true,
mixed: true,
italic: true,
},
};
export const Cyrillic: Story = {
args: {
weight: 400,
family: "Source Sans 3",
cyrillic: true,
showDefault: false,
},
parameters: {
chromatic: { disable: true },
},
};
export const Samisk: Story = {
args: {
weight: 400,
family: "Source Sans 3",
samisk: true,
showDefault: false,
},
parameters: {
chromatic: { disable: true },
},
};
export const Mixed: Story = {
args: {
weight: 400,
family: "Source Sans 3",
showDefault: false,
mixed: true,
},
parameters: {
chromatic: { disable: true },
},
};
export const FontWeight: Story = {
render: () => {
return (
<>
{fontWeights.map((weight) => (
<>
<h2>{weight}</h2>
<FontComponent
key={weight}
weight={weight}
family="Source Sans 3"
/>
</>
))}
</>
);
},
};
export const FontFamily: Story = {
render: (args) => {
return (
<>
<h2>Source Sans Pro</h2>
<FontComponent weight={args.weight} family="Source Sans Pro" mixed />
<h2>Source Sans 3</h2>
<FontComponent weight={args.weight} family="Source Sans 3" mixed />
</>
);
},
args: {
weight: 400,
},
};
@@ -4,7 +4,7 @@
* Utility to consistently call original eventhandler, often from props and internal eventhandler
* @internal
*/
export function composeEventHandlers<T extends React.SyntheticEvent>(
export function composeEventHandlers<T extends React.SyntheticEvent | Event>(
originalEventHandler?: (event: T) => void,
ourEventHandler?: (event: T) => void,
{ checkForDefaultPrevented = true } = {},
@@ -126,9 +126,11 @@ function Input() {
inputMode="numeric"
onKeyDown={(event) => {
if (event.key === "ArrowRight") {
event.preventDefault();
descendants.next(index, false)?.node.focus();
}
if (event.key === "ArrowLeft") {
event.preventDefault();
descendants.prev(index, false)?.node.focus();
}
}}
@@ -0,0 +1,27 @@
import { Dispatch, SetStateAction } from "react";
import { createContext } from "../create-context";
import { createDescendantContext } from "../hooks/descendants/useDescendant";
import { SlottedDivElementRef } from "./SlottedDivElement";
export const [
VirtualFocusDescendantsProvider,
useVirtualFocusDescendantsContext,
useVirtualFocusDescendantInitializer,
useVirtualFocusDescendant,
] = createDescendantContext<
SlottedDivElementRef,
{
handleOnSelect: () => void;
handleOnActive: () => void;
}
>();
export const [
VirtualFocusInternalContextProvider,
useVirtualFocusInternalContext,
] = createContext<{
virtualFocusIdx: number;
setVirtualFocusIdx: Dispatch<SetStateAction<number>>;
loop: boolean;
uniqueId: string;
}>();
@@ -0,0 +1,17 @@
import React, { forwardRef } from "react";
import { Slot } from "../../slot/Slot";
interface SlottedDivProps extends React.HTMLAttributes<HTMLDivElement> {
asChild?: boolean;
}
const SlottedDivElement = forwardRef<HTMLDivElement, SlottedDivProps>(
({ asChild, ...rest }, forwardedRef) => {
const Comp = asChild ? Slot : "div";
return <Comp {...rest} ref={forwardedRef} />;
},
);
type SlottedDivElementRef = React.ElementRef<typeof SlottedDivElement>;
export { SlottedDivElement, type SlottedDivElementRef, type SlottedDivProps };
@@ -0,0 +1,89 @@
import React, { useState } from "react";
import { useId } from "../../util/hooks";
import {
VirtualFocusDescendantsProvider,
VirtualFocusInternalContextProvider,
useVirtualFocusDescendantInitializer,
} from "./Context";
import { VirtualFocusAnchor } from "./parts/VirtualFocusAnchor";
import { VirtualFocusContent } from "./parts/VirtualFocusContent";
import { VirtualFocusItem } from "./parts/VirtualFocusItem";
type VirtualFocusProps = {
children: React.ReactNode;
/**
* Whether to cause focus to loop around when it hits the first or last element
* @default false
**/
loop?: boolean;
};
/**
* A component that manages a virtual focus using the 'up' and 'down'
* arrow keys as well as selection with 'Return'.
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/virtualfocus)
* @see 🏷️ {@link AccordionProps}
*
* @example
* ```jsx
* <VirtualFocus>
* <VirtualFocus.Anchor
* role="combobox"
* onSelect={() => {
* console.log("you selected the anchor");
* }}
* onActive={() => {
* console.log("the anchor is now virtually focused");
* }}
* >
* <input type="text" />
* </VirtualFocus.Anchor>
* <VirtualFocus.Content>
* <VirtualFocus.Item
* onSelect={() => {
* console.log("you selected the item");
* }}
* onActive={() => {
* console.log("the item is now virtually focused");
* }}
* >
* <p>item 1</p>
* </VirtualFocus.Item>
* <VirtualFocus.Item
* onSelect={() => {
* console.log("you selected the item");
* }}
* onActive={() => {
* console.log("the item is now virtually focused");
* }}
* >
* <p>item 2</p>
* </VirtualFocus.Item>
* </VirtualFocus.Content>
* </VirtualFocus>
* ```
*/
export const VirtualFocus = ({ children, loop = false }: VirtualFocusProps) => {
const descendants = useVirtualFocusDescendantInitializer();
const [virtualFocusIdx, setVirtualFocusIdx] = useState(0);
return (
<VirtualFocusInternalContextProvider
virtualFocusIdx={virtualFocusIdx}
setVirtualFocusIdx={setVirtualFocusIdx}
loop={loop}
uniqueId={useId()}
>
<VirtualFocusDescendantsProvider value={descendants}>
{children}
</VirtualFocusDescendantsProvider>
</VirtualFocusInternalContextProvider>
);
};
VirtualFocus.Anchor = VirtualFocusAnchor;
VirtualFocus.Item = VirtualFocusItem;
VirtualFocus.Content = VirtualFocusContent;
export default VirtualFocus;
@@ -0,0 +1,102 @@
import React, { forwardRef } from "react";
import { Slot } from "../../../slot/Slot";
import { composeEventHandlers } from "../../composeEventHandlers";
import { useMergeRefs } from "../../hooks";
import {
useVirtualFocusDescendant,
useVirtualFocusInternalContext,
} from "../Context";
export interface VirtualFocusAnchorProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "id"> {
/**
* The role of the container. This is a limited subset of roles that
* require manual focus management.
*
* Children that are to get focus inside this container element shall be
* pointed to by `aria-activedescendant`.
**/
role:
| "combobox"
| "grid"
| "listbox"
| "menu"
| "menubar"
| "radiogroup"
| "tree"
| "treegrid"
| "tablist";
/**
* The function that is run when the focused element
* is to be selected (eg. do an actual search, change route... etc)
*/
onSelect: () => void;
/**
* The function that is run when the element gets
* virtual focus set to it.
*/
onActive: () => void;
children: React.ReactElement;
/**
* Set this to `0` if you want the Anchor itself
* to be focusable. Since this Anchor is hoisted & merged with
* its first child, you most likely want to keep this as `0`.
* @default 0
*/
tabIndex?: number;
}
/**
* Must have a single child that is an input element.
*/
export const VirtualFocusAnchor = forwardRef<
HTMLInputElement,
VirtualFocusAnchorProps
>(({ onSelect, onActive, children, ...rest }, ref) => {
const { virtualFocusIdx, setVirtualFocusIdx, loop, uniqueId } =
useVirtualFocusInternalContext();
const { register, descendants, index } = useVirtualFocusDescendant({
handleOnActive: () => {
setVirtualFocusIdx(0);
onActive();
},
handleOnSelect: onSelect,
});
const mergedRefs = useMergeRefs(ref, register);
return (
<Slot
ref={mergedRefs}
tabIndex={0}
{...rest}
id={`virtualfocus-${uniqueId}-${index}`}
aria-owns={`virtualfocus-${uniqueId}-content`}
aria-controls={`virtualfocus-${uniqueId}-content`}
aria-activedescendant={`virtualfocus-${uniqueId}-${virtualFocusIdx}`}
onKeyDown={composeEventHandlers(rest.onKeyDown, (event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
const to_focus_descendant = descendants.next(virtualFocusIdx, loop);
if (to_focus_descendant) {
to_focus_descendant.handleOnActive();
}
} else if (event.key === "ArrowUp") {
event.preventDefault();
const to_focus_descendant = descendants.prev(virtualFocusIdx, loop);
if (to_focus_descendant) {
to_focus_descendant.handleOnActive();
}
} else if (event.key === "Enter") {
const curr = descendants.item(virtualFocusIdx);
if (curr?.handleOnSelect) {
curr.handleOnSelect();
}
}
})}
>
{children}
</Slot>
);
});
@@ -0,0 +1,17 @@
import React, { forwardRef } from "react";
import { useVirtualFocusInternalContext } from "../Context";
export interface VirtualFocusContentProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "id"> {}
export const VirtualFocusContent = forwardRef<
HTMLDivElement,
VirtualFocusContentProps
>(({ children, ...rest }, ref) => {
const { uniqueId } = useVirtualFocusInternalContext();
return (
<div ref={ref} {...rest} id={`virtualfocus-${uniqueId}-content`}>
{children}
</div>
);
});
@@ -0,0 +1,60 @@
import React, { forwardRef } from "react";
import { Slot } from "../../../slot/Slot";
import { composeEventHandlers } from "../../composeEventHandlers";
import { useMergeRefs } from "../../hooks";
import {
useVirtualFocusDescendant,
useVirtualFocusInternalContext,
} from "../Context";
export interface VirtualFocusItemProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "id" | "tabIndex"> {
/**
* The function that is run when the element is focused
* (virtually, not actual focus, eg. set a border around an item)
*/
onActive: () => void;
/**
* The function that is run when the focused element
* is to be selected (eg. do an actual search, change route... etc)
*/
onSelect: () => void;
children: React.ReactNode;
}
/**
* Contains an item you want to be iterable via virtual focus.
*/
export const VirtualFocusItem = forwardRef<HTMLElement, VirtualFocusItemProps>(
({ children, onActive, onSelect, ...rest }, ref) => {
const { virtualFocusIdx, setVirtualFocusIdx, uniqueId } =
useVirtualFocusInternalContext();
const { register, index } = useVirtualFocusDescendant({
handleOnActive: () => {
setVirtualFocusIdx(index);
onActive();
},
handleOnSelect: onSelect,
});
const mergedRefs = useMergeRefs(ref, register);
return (
<Slot
ref={mergedRefs}
{...rest}
id={`virtualfocus-${uniqueId}-${index}`}
data-aksel-virtualfocus={virtualFocusIdx === index}
tabIndex={-1}
onClick={composeEventHandlers(rest.onClick, () => {
onSelect();
})}
onMouseMove={composeEventHandlers(rest.onMouseMove, () => {
setVirtualFocusIdx(index);
onActive();
})}
>
{children}
</Slot>
);
},
);
@@ -0,0 +1,381 @@
import { Meta } from "@storybook/react";
import React, {
Dispatch,
ReactNode,
SetStateAction,
forwardRef,
useRef,
useState,
} from "react";
import {
CloudIcon,
HangerIcon,
HourglassIcon,
HouseIcon,
PuzzlePieceIcon,
RulerIcon,
} from "@navikt/aksel-icons";
import { Button } from "../../button";
import { Search } from "../../form/search";
import { VStack } from "../../layout/stack";
import { Popover } from "../../popover";
import { VirtualFocus } from "./VirtualFocus";
export default {
title: "Utilities/VirtualFocus",
component: VirtualFocus,
decorators: [
(Story) => (
<VStack gap="10">
<Button>above</Button>
<Story />
<Button>below</Button>
</VStack>
),
],
parameters: {
chromatic: {
disable: true,
},
},
} satisfies Meta<typeof VirtualFocus>;
const MyAnchor = forwardRef<
HTMLInputElement,
{
children: React.ReactNode;
value: string;
setValue: Dispatch<SetStateAction<string>>;
}
>(({ children, value, setValue }, ref) => {
const searchRef = useRef<HTMLInputElement>(null);
return (
<div style={{ position: "relative" }}>
<VirtualFocus.Anchor
role="combobox"
ref={ref}
onSelect={() => {
console.log(`onSelect(): ${value}`);
}}
onActive={() => {
console.log(`onActive(): Anchor`);
}}
>
<Search
label="Søk på nav sine sider"
autoComplete="off"
value={value}
onChange={(current_input) => {
setValue(current_input);
}}
ref={searchRef}
/>
</VirtualFocus.Anchor>
{children}
</div>
);
});
const MyItem = ({
children,
icon,
setValue,
}: {
children: string;
icon: ReactNode;
setValue: Dispatch<SetStateAction<string>>;
}) => {
return (
<VirtualFocus.Item
onActive={() => {
setValue(children);
console.log(`onActive(): ${children}`);
}}
onSelect={() => {
console.log(`onSelect(): ${children}`);
}}
role="button"
>
<span>
{icon}
<span>{children}</span>
</span>
</VirtualFocus.Item>
);
};
const StoryStyle = () => (
<style>{`
[data-aksel-virtualfocus="true"] {
outline: solid 3px var(--a-border-focus);
border-radius: var(--a-border-radius-medium);
}
`}</style>
);
export const Default = () => {
const [value, setValue] = useState("");
return (
<>
<StoryStyle />
<VirtualFocus>
<MyAnchor setValue={setValue} value={value}>
<VirtualFocus.Content>
<VStack>
<MyItem setValue={setValue} icon={<CloudIcon aria-hidden />}>
{"cloud "}
</MyItem>
<MyItem setValue={setValue} icon={<HangerIcon aria-hidden />}>
{"hanger "}
</MyItem>
<MyItem setValue={setValue} icon={<HourglassIcon aria-hidden />}>
{"hourglass "}
</MyItem>
<MyItem setValue={setValue} icon={<HouseIcon aria-hidden />}>
{"house "}
</MyItem>
<MyItem setValue={setValue} icon={<RulerIcon aria-hidden />}>
{"ruler "}
</MyItem>
<MyItem
setValue={setValue}
icon={<PuzzlePieceIcon aria-hidden />}
>
{"puzzle piece "}
</MyItem>
</VStack>
</VirtualFocus.Content>
</MyAnchor>
</VirtualFocus>
</>
);
};
export const Loop = () => {
const [value, setValue] = useState("");
return (
<>
<StoryStyle />
<VirtualFocus loop>
<MyAnchor setValue={setValue} value={value}>
<VirtualFocus.Content>
<VStack>
<MyItem setValue={setValue} icon={<CloudIcon aria-hidden />}>
{"cloud "}
</MyItem>
<MyItem setValue={setValue} icon={<HangerIcon aria-hidden />}>
{"hanger "}
</MyItem>
<MyItem setValue={setValue} icon={<HourglassIcon aria-hidden />}>
{"hourglass "}
</MyItem>
<MyItem setValue={setValue} icon={<HouseIcon aria-hidden />}>
{"house "}
</MyItem>
<MyItem setValue={setValue} icon={<RulerIcon aria-hidden />}>
{"ruler "}
</MyItem>
<MyItem
setValue={setValue}
icon={<PuzzlePieceIcon aria-hidden />}
>
{"puzzle piece "}
</MyItem>
</VStack>
</VirtualFocus.Content>
</MyAnchor>
</VirtualFocus>
</>
);
};
export const Multiple = () => {
const [value, setValue] = useState("");
const [value2, setValue2] = useState("");
const [value3, setValue3] = useState("");
return (
<>
<StoryStyle />
<VirtualFocus>
<MyAnchor setValue={setValue} value={value}>
<VirtualFocus.Content>
<VStack>
<MyItem setValue={setValue} icon={<CloudIcon aria-hidden />}>
{"cloud "}
</MyItem>
<MyItem setValue={setValue} icon={<HangerIcon aria-hidden />}>
{"hanger "}
</MyItem>
</VStack>
</VirtualFocus.Content>
</MyAnchor>
</VirtualFocus>
<VirtualFocus>
<MyAnchor setValue={setValue2} value={value2}>
<VirtualFocus.Content>
<VStack>
<MyItem setValue={setValue2} icon={<CloudIcon aria-hidden />}>
{"cloud "}
</MyItem>
<MyItem setValue={setValue2} icon={<HangerIcon aria-hidden />}>
{"hanger "}
</MyItem>
</VStack>
</VirtualFocus.Content>
</MyAnchor>
</VirtualFocus>
<VirtualFocus>
<MyAnchor setValue={setValue3} value={value3}>
<VirtualFocus.Content>
<VStack>
<MyItem setValue={setValue3} icon={<CloudIcon aria-hidden />}>
{"cloud "}
</MyItem>
<MyItem setValue={setValue3} icon={<HangerIcon aria-hidden />}>
{"hanger "}
</MyItem>
</VStack>
</VirtualFocus.Content>
</MyAnchor>
</VirtualFocus>
</>
);
};
const MyDropDownSearchVirtualfocus = () => {
const anchorRef = useRef<HTMLInputElement>(null);
const [openState, setOpenState] = useState(false);
const [value, setValue] = useState("");
const items = [
{
icon: <CloudIcon aria-hidden />,
text: "cloud ",
},
{
icon: <HangerIcon aria-hidden />,
text: "hanger ",
},
{
icon: <HourglassIcon aria-hidden />,
text: "hourglass ",
},
{
icon: <HouseIcon aria-hidden />,
text: "house ",
},
{
icon: <RulerIcon aria-hidden />,
text: "ruler ",
},
{
icon: <PuzzlePieceIcon aria-hidden />,
text: "puzzle piece ",
},
];
const filteredItems = items;
return (
<>
<style>
{`
.dropdown-search-virtualfocus {
width: 100%;
}
[data-aksel-virtualfocus="true"] {
outline: solid 3px var(--a-border-focus);
border-radius: var(--a-border-radius-medium);
}
`}
</style>
<MyAnchor setValue={setValue} value={value} ref={anchorRef}>
<Popover
className="dropdown-search-virtualfocus"
anchorEl={anchorRef.current}
open={openState || (!!value.length && !!filteredItems.length)}
onClose={() => {
setOpenState(false);
}}
arrow={false}
offset={0}
placement="bottom-start"
>
<VirtualFocus.Content>
<Popover.Content>
<VStack>
{filteredItems.map((i) => {
return (
<MyItem setValue={setValue} key={i.text} icon={i.icon}>
{i.text}
</MyItem>
);
})}
</VStack>
</Popover.Content>
</VirtualFocus.Content>
</Popover>
</MyAnchor>
</>
);
};
export const WithPopover = () => {
return (
<div>
<VirtualFocus>
<MyDropDownSearchVirtualfocus />
</VirtualFocus>
</div>
);
};
/**
* A simple story without much interactivity (very much incomplete
* but shows a good overview of the components & their relationship)
* This is essentially 1:1 with the JSDoc example for the component.
**/
export const Simple = () => {
return (
<>
<StoryStyle />
<VirtualFocus>
<VirtualFocus.Anchor
role="combobox"
onSelect={() => {
console.log("you selected the anchor");
}}
onActive={() => {
console.log("the anchor is now virtually focused");
}}
>
<input type="text" />
</VirtualFocus.Anchor>
<VirtualFocus.Content>
<VirtualFocus.Item
onSelect={() => {
console.log("you selected the item");
}}
onActive={() => {
console.log("the item is now virtually focused");
}}
>
<p>item 1</p>
</VirtualFocus.Item>
<VirtualFocus.Item
onSelect={() => {
console.log("you selected the item");
}}
onActive={() => {
console.log("the item is now virtually focused");
}}
>
<p>item 2</p>
</VirtualFocus.Item>
</VirtualFocus.Content>
</VirtualFocus>
</>
);
};
+4
View File
@@ -1,5 +1,9 @@
# @navikt/ds-tailwind
## 6.15.0
## 6.14.0
## 6.13.0
## 6.12.0
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tailwind",
"version": "6.13.0",
"version": "6.15.0",
"description": "Tailwind config based on @navikt/ds-tokens",
"author": "Aksel | NAV designsystem team",
"keywords": [
@@ -25,7 +25,7 @@
"directory": "@navikt/core/tailwind"
},
"devDependencies": {
"@navikt/ds-tokens": "^6.13.0",
"@navikt/ds-tokens": "^6.15.0",
"color": "4.2.3",
"lodash": "^4.17.21",
"tailwindcss": "^3.3.3",
+8
View File
@@ -1,5 +1,13 @@
# @navikt/ds-tokens
## 6.15.0
## 6.14.0
### Minor Changes
- Fonts: Added built-in support for cyrillic, cyrillic-ext and latin-ext ([#3044](https://github.com/navikt/aksel/pull/3044))
## 6.13.0
## 6.12.0
+1 -1
View File
@@ -56,7 +56,7 @@
"font": [
{
"name": "--a-font-family",
"value": "'Source Sans Pro', Arial, sans-serif"
"value": "'Source Sans 3', 'Source Sans Pro', Arial, sans-serif"
},
{ "name": "--a-font-weight-regular", "value": "400" },
{ "name": "--a-font-weight-bold", "value": "600" },
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tokens",
"version": "6.13.0",
"version": "6.15.0",
"description": "Design-tokens for NAV designsystem",
"author": "Aksel | NAV designsystem team",
"keywords": [
+3 -1
View File
@@ -22,7 +22,9 @@ module.exports = {
},
},
font: {
family: { value: "'Source Sans Pro', Arial, sans-serif" },
family: {
value: "'Source Sans 3', 'Source Sans Pro', Arial, sans-serif",
},
line: {
height: {
heading: {
@@ -6,7 +6,7 @@ export default {
parameters: {
chromatic: { disable: true },
},
} as Meta;
} satisfies Meta;
const Border = ({ token }) => (
<>
@@ -4,7 +4,7 @@ import tokens from "../docs.json";
export default {
title: "ds-tokens/Colors",
} as Meta;
} satisfies Meta;
/**
* No need to create single Chromatic-story since we want to keep global and semantic colors separated.
@@ -6,7 +6,7 @@ export default {
parameters: {
chromatic: { disable: true },
},
} as Meta;
} satisfies Meta;
const Shadow = ({ token }) => (
<>
@@ -5,7 +5,7 @@ import * as tokens from "../dist/tokens.js";
export default {
title: "ds-tokens/Spacing",
} as Meta;
} satisfies Meta;
export const Spacing = () => {
const allSpacing: { [key: string]: string } = Object.entries(tokens).reduce(
+74 -36
View File
@@ -1,5 +1,43 @@
# Changelog
## 6.15
### @navikt/ds-react
- Combobox: Can no longer remove options with backspace when shouldShowSelectedOptions is false ([#3076](https://github.com/navikt/aksel/pull/3076))
- Refactored Combobox FilteredOptions ([#3073](https://github.com/navikt/aksel/pull/3073))
### @navikt/aksel-icons
- Icons: New icon-series for 'SplitHorizontal' and 'SplitVertical' ([#3072](https://github.com/navikt/aksel/pull/3072))
## 6.14
### @navikt/ds-tokens
- Fonts: Added built-in support for cyrillic, cyrillic-ext and latin-ext ([#3044](https://github.com/navikt/aksel/pull/3044))
### @navikt/ds-react
- Breaking change: Combobox.onChange now receive only value as argument, instead of ChangeEvent ([#2974](https://github.com/navikt/aksel/pull/2974))
- Improved search in Combobox - find hits anywhere in the label ([#2974](https://github.com/navikt/aksel/pull/2974))
* Tabs, ToggleGroup: Rovingfocus now respects shift, alt, ctrl and meta-keys when navigating. ([#3041](https://github.com/navikt/aksel/pull/3041))
* Prevent NullPointer when adding a new/custom option in Combobox single-select ([#3054](https://github.com/navikt/aksel/pull/3054))
* Combobox: Entering an already selected option and pressing enter no longer removes it ([#3061](https://github.com/navikt/aksel/pull/3061))
* Combobox: Description is now connected to the input field via aria-describedby ([#3065](https://github.com/navikt/aksel/pull/3065))
* Prevent Escape in open Combobox from closing Modals ([#3053](https://github.com/navikt/aksel/pull/3053))
### @navikt/ds-css
- Fonts: Added built-in support for cyrillic, cyrillic-ext and latin-ext ([#3044](https://github.com/navikt/aksel/pull/3044))
## 6.13
### @navikt/ds-react
@@ -238,10 +276,6 @@
## 6.0
### @navikt/ds-tailwind
- Tokens: La til nytt brekkpunkt `2xl` for `1440px`. Alle primitives er oppdatert for å reflektere oppdateringen. Brukere av Tailwind vil måtte sjekke at overskriving av `screen: "2xl"` ikke brekker app. ([#2623](https://github.com/navikt/aksel/pull/2623))
### @navikt/ds-tokens
- Tokens: La til nytt brekkpunkt `2xl` for `1440px`. Alle primitives er oppdatert for å reflektere oppdateringen. Brukere av Tailwind vil måtte sjekke at overskriving av `screen: "2xl"` ikke brekker app. ([#2623](https://github.com/navikt/aksel/pull/2623))
@@ -282,9 +316,9 @@
- Textarea: Fjernet div med klassen navds-textarea\_\_wrapper ([#2623](https://github.com/navikt/aksel/pull/2623))
### @navikt/aksel
### @navikt/ds-tailwind
- Chat: Fjernet deprecated props `backgroundColor` og `avatarBgColor`. Bruk prop `variant` som erstatning. ([#2623](https://github.com/navikt/aksel/pull/2623))
- Tokens: La til nytt brekkpunkt `2xl` for `1440px`. Alle primitives er oppdatert for å reflektere oppdateringen. Brukere av Tailwind vil måtte sjekke at overskriving av `screen: "2xl"` ikke brekker app. ([#2623](https://github.com/navikt/aksel/pull/2623))
### @navikt/aksel-icons
@@ -296,6 +330,10 @@
- Textarea: Fjernet div med klassen navds-textarea\_\_wrapper ([#2623](https://github.com/navikt/aksel/pull/2623))
### @navikt/aksel
- Chat: Fjernet deprecated props `backgroundColor` og `avatarBgColor`. Bruk prop `variant` som erstatning. ([#2623](https://github.com/navikt/aksel/pull/2623))
## 5.18
### @navikt/ds-react
@@ -336,10 +374,6 @@
## 5.16
### @navikt/ds-tailwind
- Tailwind: La til ny `maxWidth` for text ([#2652](https://github.com/navikt/aksel/pull/2652))
### @navikt/ds-react
- :sparkles: Combobox: Mulighet for å begrense hvor mange valg bruker kan ta ([#2260](https://github.com/navikt/aksel/pull/2260))
@@ -348,6 +382,10 @@
- :sparkles: Combobox: Mulighet for å begrense hvor mange valg bruker kan ta ([#2260](https://github.com/navikt/aksel/pull/2260))
### @navikt/ds-tailwind
- Tailwind: La til ny `maxWidth` for text ([#2652](https://github.com/navikt/aksel/pull/2652))
### @navikt/aksel-icons
- Ikoner: Nytt ikon `LocationPin` ✨ ([#2672](https://github.com/navikt/aksel/pull/2672))
@@ -474,10 +512,6 @@
## 5.10
### @navikt/ds-tailwind
- typescript types for tailwind preset ([#2486](https://github.com/navikt/aksel/pull/2486))
### @navikt/ds-react
- Gjør lukknapp i Alert til type button ([#2497](https://github.com/navikt/aksel/pull/2497))
@@ -502,6 +536,10 @@
- Primitives: Ny komponent `Page` og `Page.Block` ([#2456](https://github.com/navikt/aksel/pull/2456))
### @navikt/ds-tailwind
- typescript types for tailwind preset ([#2486](https://github.com/navikt/aksel/pull/2486))
## 5.9
### @navikt/ds-react
@@ -963,12 +1001,6 @@
## 4.4
### @navikt/ds-tailwind
- Shadow-tokens er oppdatert ([PR](https://github.com/navikt/aksel/pull/2041))
- Oppdatert text-subtle og icon-subtle tokens til 700-skala (tidligere 600). ([PR](https://github.com/navikt/aksel/pull/2036))
### @navikt/ds-tokens
- Shadow-tokens er oppdatert til mer tydeligere varianter ([PR](https://github.com/navikt/aksel/pull/2041))
@@ -985,6 +1017,12 @@
* Shadow-tokens er oppdatert: Datepicker har nå ikke border, Modal bruker shadow-xlarge, LinkCard bruker shadow-xsmall ([PR](https://github.com/navikt/aksel/pull/2041))
### @navikt/ds-tailwind
- Shadow-tokens er oppdatert ([PR](https://github.com/navikt/aksel/pull/2041))
- Oppdatert text-subtle og icon-subtle tokens til 700-skala (tidligere 600). ([PR](https://github.com/navikt/aksel/pull/2036))
## 4.3
### @navikt/ds-react
@@ -1009,10 +1047,6 @@
## 4.1
### @navikt/ds-tailwind
- :tada: Fargetokens for datavisualisering. ([PR](https://github.com/navikt/aksel/pull/2032))
### @navikt/ds-tokens
- :tada: Fargetokens for datavisualisering. ([PR](https://github.com/navikt/aksel/pull/2032))
@@ -1053,6 +1087,10 @@
- Alle description-felter på fieldsets har nå `text-subtle` som farge. ([PR](https://github.com/navikt/aksel/pull/2036))
### @navikt/ds-tailwind
- :tada: Fargetokens for datavisualisering. ([PR](https://github.com/navikt/aksel/pull/2032))
### @navikt/aksel-icons
- Nye ikoner `ChevronRightLast` og `ChevronLeftLast` ([PR](https://github.com/navikt/aksel/pull/2054))
@@ -1175,10 +1213,6 @@
- CSS nå tilgjengelig som separate filer: Kan lastest fra CDN, minified-versjoner tilgjengelig ([PR](https://github.com/navikt/aksel/pull/1941))
### @navikt/aksel
- :truck: Flyttet Codemods fra `@navikt/ds-codemods` -> `@navikt/aksel`. `@navikt/ds-codemods` regnes nå som deprecated ([PR](https://github.com/navikt/aksel/pull/1952))
### @navikt/aksel-icons
- Oppdatert Statusikoner til å være tydeligere og mer konsistente. ([PR](https://github.com/navikt/aksel/pull/1959))
@@ -1189,6 +1223,10 @@
* Nye ikoner `BulletList` og `NumberList` ([PR](https://github.com/navikt/aksel/pull/1950))
### @navikt/aksel
- :truck: Flyttet Codemods fra `@navikt/ds-codemods` -> `@navikt/aksel`. `@navikt/ds-codemods` regnes nå som deprecated ([PR](https://github.com/navikt/aksel/pull/1952))
## 2.8
### @navikt/ds-react
@@ -1263,10 +1301,6 @@
## 2.5
### @navikt/ds-tailwind
- Breakpoint-tokens er lagt til. Overskriver native tailwind-breakpoints ([PR](https://github.com/navikt/aksel/pull/1832))
### @navikt/ds-tokens
- Breakpoint-tokens lagt til ([PR](https://github.com/navikt/aksel/pull/1832))
@@ -1275,6 +1309,10 @@
- List: Refaktorert nestede lister
### @navikt/ds-tailwind
- Breakpoint-tokens er lagt til. Overskriver native tailwind-breakpoints ([PR](https://github.com/navikt/aksel/pull/1832))
## 2.4
### @navikt/ds-react
@@ -1349,10 +1387,6 @@
## 2.0
### @navikt/ds-tailwind
- Alle token er oppdatert til nytt format. ([Guide](https://aksel.nav.no/grunnleggende/kode/migrering#h76f47744d112))
### @navikt/ds-tokens
- Alle token er oppdatert til nytt format. ([Guide](https://aksel.nav.no/grunnleggende/kode/migrering#h76f47744d112))
@@ -1387,6 +1421,10 @@
* Tokens: Alle komponenter bruker nå semantiske tokens for som standard, med innebygd støtte for komponent-spesifikke tokens.
### @navikt/ds-tailwind
- Alle token er oppdatert til nytt format. ([Guide](https://aksel.nav.no/grunnleggende/kode/migrering#h76f47744d112))
## 1.5
### @navikt/ds-react
+6
View File
@@ -88,3 +88,9 @@ Løsningen forvaltes av team Aksel
<a href="https://github.com/navikt/aksel/graphs/contributors">
<img src="https://contrib.rocks/image?repo=navikt/aksel" />
</a>
## Thanks
<a href="https://www.chromatic.com/"><img src="https://user-images.githubusercontent.com/321738/84662277-e3db4f80-af1b-11ea-88f5-91d67a5e59f6.png" width="153" height="30" alt="Chromatic" /></a>
Thanks to [Chromatic](https://www.chromatic.com/) for providing the visual testing platform that helps us review UI changes and catch visual regressions.
+5 -5
View File
@@ -15,10 +15,10 @@
"playroom"
],
"dependencies": {
"@navikt/aksel-icons": "^6.13.0",
"@navikt/ds-css": "^6.13.0",
"@navikt/ds-react": "^6.13.0",
"@navikt/ds-tailwind": "^6.13.0",
"@navikt/ds-tokens": "^6.13.0"
"@navikt/aksel-icons": "^6.15.0",
"@navikt/ds-css": "^6.15.0",
"@navikt/ds-react": "^6.15.0",
"@navikt/ds-tailwind": "^6.15.0",
"@navikt/ds-tokens": "^6.15.0"
}
}
+1 -1
View File
@@ -11,7 +11,7 @@
"babel-loader": "^9.1.3",
"babel-plugin-preval": "^5.1.0",
"css-loader": "^6.8.1",
"playroom": "^0.37.0",
"playroom": "^0.38.0",
"style-loader": "^3.3.3"
},
"dependencies": {
+4 -1
View File
@@ -53,7 +53,10 @@ module.exports = {
},
{
test: /\.css$/i,
include: [require.resolve("@navikt/ds-css")],
include: [
require.resolve("@navikt/ds-css"),
require.resolve("./src/frameComponent.css"),
],
use: ["style-loader", "css-loader"],
},
],
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more