Compare commits

...
Author SHA1 Message Date
github-actions[bot]andgithub-actions[bot] 085cc532ee Version Packages (#2839)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-11 12:21:05 +02:00
KenandHalvor Haugan deae4ecba5 Datepicker/MonthPicker required bugs (#2838)
* 🐛 Required-prop på monthpicker stoppet ikke de-select av valg

* 🧪 Tester required for datepicker

* 🔥 Fjernet console.logs

* 📝 Changeset

* Update .changeset/fifty-hairs-fix.md

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

* Update @navikt/core/react/src/date/datepicker/datepicker.stories.tsx

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

* 🔥 Disabled-attrb er ikke nødvendig i required demo

* 🐛 Satt defaultSelected-dato til statisk dato

* ♻️ Fjernet unødvendig required-sjekk

---------

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2024-04-11 11:55:27 +02:00
KenandHalvor Haugan cd2adcb655 Erstattet Radix togglegroup med intern implementasjon (#2620)
* 🎉 Ny hook og API 'descendants'

* 📝 Bedre storiers

* 🎨 implementert togglegroup med descendants

* 🎨 Implemented togglegroup.item

* 🎨 Fjernet gammel togglegroup

* 🐛 Fikset keydown

* 🔥 Fjernet comment

* 🎉 RowingFocus resetter nå ved focus-exit

* 🔥 Fjernet radix toggleroup fra dependencies

* 📝 Changeset

* [V6] Deprecated Grid og ContentContainer (#2622)

* 🔥 Fjernet grid css

* 🔥 Fjernet grid tsx

* 🔥 Fjernet content-container fra systemet

* 🐛 la til deprecatePrefix-tag for stylelint

* 📝 Changeset

* Textarea: Fjern wrapper (#2635)

* [V6] Chat-props deprecation (#2631)

* 🎨 Satt opp codemod boilerplate for chat deprecation

* 🐛 v5 -> v6 i codemod

* 🧪 Tester for alias-import av chat

* 🧪 idempotent codemod-test

* 🔥 Fjernet deprecated props fra Chat

* 📝 Changeset

* 📝 Bedre advarsel i chat-codemod

* 📝 v6-migration update

* 🧪 Tester vanlig import av Chat

* 📝 Oppdatert tekster i codemod

* 🔥 Fjernet return fra removeProps

* Update @navikt/aksel/src/codemod/migrations.ts

* [V6] 1440px breakpoint (#2632)

* ✨ La til nytt brekkpunkt 1440px

* 🎉 Bleed støtter 2xl breakpoint

* 🎉 Bleed box 2xl breakpoint

* 🎉 hgrid 2xl breakpoint

* 🎉 resterende primitives støtter 1440px

* 📝 tailwind migreringsdocs

* 🔥 Fjernet navno-mal fra chromatic snapshot

* 📝 Changeset

* 🐛 2xl hgrid css brakk

* 📝 Oppdatert changset med tailwind-ref

* Update @navikt/core/react/src/layout/bleed/Bleed.tsx

* Update @navikt/core/react/src/layout/bleed/Bleed.tsx

* 📝 Mer konsekvent story for breakpoints i box

* 🐛 JSDOC for Bleed matcher nå syntax

* [V6] Fjernet z-index-modal token  (#2637)

* 🔥 Fjernet zindex-modal token

* 📝 Oppdatert docs

* 📝 Changeset

* [V6] ErrorSummary magic (#2642)

* ♻️ Flyttet li-tag fra ErrorSummary-parent til child

* 🏷️ Bedre typer

* 🔒 Heading er nå required i ErrorSummary

* 🎨 Egen prop for ErrorSummary fokus

* 📝 Changeset

* 🎨 Bedre fokushåndtering i ErrorSummary

* ♻️ focusTarget -> focusTargetRef

* 📝 Fikset jsdoc

* 📝 Migreringstekst

* 📝 changeset

* 📝 Migration-text

* 📝 JSDoc-staving

* 📝 Oppdatert migreringsguide for z-index + tailwind

* 🐛 Fikset bruk av z-modal i tailwind

* 🔥 Fjernet descendants-filer

* 📝 Rename context og types til namespace

* 🔥 Fjernet openOnFocus-prop fra date

* [V6] Modal: strengere prop-typer (#2718)

* 🎨 SortState i Table har nå none tilgjengelig (#2726)

* [V6] Oppdatert bruk av action-farger (#2717)

* 💄 Button secondary hover

* 💄 Radio/Checkbox action-farger

* :WIP: combobox action-farger

* 🐛 Tilpasset hover-farge på checkbox i confirmationpanel

* 💄 copybutton hover-text action

* 💄 dropdown, helptext hover-text

* 💄 link og linkpanel actionsfarger

* 💄 Search action-farger

* 💄 select action-farger

* 💄 Stepper action

* 💄 Textarea action

* 💄 Textarea og textfield colors

* 🐛 Riktig hover-bakgrunn på combobox selected option

* 📝 Changeset

* 📝 changeset tokens

* 📝 Dokumentert nye tokens

* [V6] Native ESM-støtte 🎉  (#2677)

* 🎨 Fungerende cjs/esm bundle med use-client

* 🎨 Oppdatert dir-output i for esm

* ⬆️ Bump date-fns og react-day-picker

* 🎨 oppdatert date-fns imports

* 🐛 Endret bruk av reat-day-picker i monthpicker

* 🐛 Fikset rendring av button i Datepicker-row

* ♻️ Bruker date-fns imports ssom støtter v2 og v3

* ⬆️ Synced date-fns versions

* ✨ Astro testmiljø

* 📝 Changeset

* 🎨 Skriver packagejson med type for esm

* 🔥 Fjernet .yarn/* fra astro

* 🎨 La til .yarn i gitignore for astro

* ⏪ Bruker riktig ds-react versjon

* ⬆️ Lik tsconfig som ds-react i aksel-icons

* 🐛 La tilbake provernance

* 🐛 Undo Day/Row fix fra main

* 🔥 Fjernet mulighet til å laste date-fns v2

* 🔥 Fjernet fs-extra dependency

* 🐛 Fikset feil versjon på core/react

* [V6] React-pakker støtter nå native SSR ved å bruke `use-client` 🎉  (#2707)

* 🎉 use-client blir lagt til på export av ds-react og aksel-icons

* 🎉 La til nextjs med appdir for testing

* ⚡ Tom yarn.lock med i git

* 📝 changeset

* 📝 Oppdatert migration-guide

* Update @navikt/aksel-icons/config/index-template.js

* 🎉 ToggleGroup-prop fill

* 🐛 Async-lasting av date-fns er nå v3 esm compliant

v3 av date-fns støtter ikke default export/import, noe som brakk async-lasting da vi brukte .default

* 🧪 Forbedret ToggleGroup-tester

* ⬆️ yarn sync

* 🐛 Fikset import av parts

* 🔥 Fjernet duplikate changesets

* ⬇️ Reversert duplikate endringer som kom fra v6

* 📝 rowing -> roving

* 📝 Bedre test-navn

* 📝 Bedre navngivning for tester

* ⚡ Fjernet useEffect for state update

* 📝 Oppdatert changeset og jsdoc

* ♻️ Inlinet aria-desribedby

* 📝 Demo for fill-prop på aksel.nav.no

* 🐛 Togglegroup krever onChange-prop

* 📝 ToggleGroup story for chromatic

* ⬆️ yarn lock

* 🎉 Bedre visning av eksempel på aksel.nav.no

---------

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2024-04-11 11:29:33 +02:00
Ken 0d4385a545 🐛 Fill-eksempel på Tabs viste ikke funksjon riktig 2024-04-11 11:25:54 +02:00
Ken 6f1b9751f3 🛂 La til feedback på nye gp-sider (#2840) 2024-04-11 07:21:44 +02:00
Ken 7fb97808f0 🚚 Flyttet data-aksel-template attr dypere i dom-treet (#2841) 2024-04-11 07:21:29 +02:00
KenandHalvor Haugan d8ac958de2 Erstattet Radix tabs med intern implementasjon (#2621)
* 🎉 Ny hook og API 'descendants'

* 📝 Bedre storiers

* 🎉 Setup for ny tabs

* 🎨 Setup context

* 🎨 useTabs-hook

* 🎨 Tablist hook

* 🚚 Flyttet tablist i egen mappe

* 🎨 TabList

* 🐛 Fikset import av tabList

* 🎨 Tabs.Tab implementert

* 🎨 Tabs.TabPanel implementert

* 🐛 Fikset import av tabpanel

* 🐛 onOnClick -> onClick

* 🐛 Fikset keydown-håndtering i tablist

* 🐛 Fikset plassering av scrollbuttons i overflow

* ♿ Scrollbutton er nå

* ✨ Fitted variant

* 🐛 Fikset imports

* 🔥 Fjernet radix-tabs import

* 🎨 Panel-children rendres bare hvis selected

* 🎨 lazy-prop for å bare rendre children i panel hvis selected

* 🎨 Tabs.Tab håndterer nå tab-key og tabindex riktig

* 📝 Changeset

* 📝 Changeset

* [V6] Deprecated Grid og ContentContainer (#2622)

* 🔥 Fjernet grid css

* 🔥 Fjernet grid tsx

* 🔥 Fjernet content-container fra systemet

* 🐛 la til deprecatePrefix-tag for stylelint

* 📝 Changeset

* Textarea: Fjern wrapper (#2635)

* [V6] Chat-props deprecation (#2631)

* 🎨 Satt opp codemod boilerplate for chat deprecation

* 🐛 v5 -> v6 i codemod

* 🧪 Tester for alias-import av chat

* 🧪 idempotent codemod-test

* 🔥 Fjernet deprecated props fra Chat

* 📝 Changeset

* 📝 Bedre advarsel i chat-codemod

* 📝 v6-migration update

* 🧪 Tester vanlig import av Chat

* 📝 Oppdatert tekster i codemod

* 🔥 Fjernet return fra removeProps

* Update @navikt/aksel/src/codemod/migrations.ts

* [V6] 1440px breakpoint (#2632)

* ✨ La til nytt brekkpunkt 1440px

* 🎉 Bleed støtter 2xl breakpoint

* 🎉 Bleed box 2xl breakpoint

* 🎉 hgrid 2xl breakpoint

* 🎉 resterende primitives støtter 1440px

* 📝 tailwind migreringsdocs

* 🔥 Fjernet navno-mal fra chromatic snapshot

* 📝 Changeset

* 🐛 2xl hgrid css brakk

* 📝 Oppdatert changset med tailwind-ref

* Update @navikt/core/react/src/layout/bleed/Bleed.tsx

* Update @navikt/core/react/src/layout/bleed/Bleed.tsx

* 📝 Mer konsekvent story for breakpoints i box

* 🐛 JSDOC for Bleed matcher nå syntax

* [V6] Fjernet z-index-modal token  (#2637)

* 🔥 Fjernet zindex-modal token

* 📝 Oppdatert docs

* 📝 Changeset

* [V6] ErrorSummary magic (#2642)

* ♻️ Flyttet li-tag fra ErrorSummary-parent til child

* 🏷️ Bedre typer

* 🔒 Heading er nå required i ErrorSummary

* 🎨 Egen prop for ErrorSummary fokus

* 📝 Changeset

* 🎨 Bedre fokushåndtering i ErrorSummary

* ♻️ focusTarget -> focusTargetRef

* 📝 Fikset jsdoc

* 📝 Migreringstekst

* 📝 changeset

* 📝 Migration-text

* 📝 JSDoc-staving

* 📝 Oppdatert migreringsguide for z-index + tailwind

* 🐛 Fikset bruk av z-modal i tailwind

* 🔥 Fjernet descendants-filer

* 📝 Rename context og types til namespace

* 🔥 Fjernet openOnFocus-prop fra date

* [V6] Modal: strengere prop-typer (#2718)

* 🎨 SortState i Table har nå none tilgjengelig (#2726)

* [V6] Oppdatert bruk av action-farger (#2717)

* 💄 Button secondary hover

* 💄 Radio/Checkbox action-farger

* :WIP: combobox action-farger

* 🐛 Tilpasset hover-farge på checkbox i confirmationpanel

* 💄 copybutton hover-text action

* 💄 dropdown, helptext hover-text

* 💄 link og linkpanel actionsfarger

* 💄 Search action-farger

* 💄 select action-farger

* 💄 Stepper action

* 💄 Textarea action

* 💄 Textarea og textfield colors

* 🐛 Riktig hover-bakgrunn på combobox selected option

* 📝 Changeset

* 📝 changeset tokens

* 📝 Dokumentert nye tokens

* [V6] Native ESM-støtte 🎉  (#2677)

* 🎨 Fungerende cjs/esm bundle med use-client

* 🎨 Oppdatert dir-output i for esm

* ⬆️ Bump date-fns og react-day-picker

* 🎨 oppdatert date-fns imports

* 🐛 Endret bruk av reat-day-picker i monthpicker

* 🐛 Fikset rendring av button i Datepicker-row

* ♻️ Bruker date-fns imports ssom støtter v2 og v3

* ⬆️ Synced date-fns versions

* ✨ Astro testmiljø

* 📝 Changeset

* 🎨 Skriver packagejson med type for esm

* 🔥 Fjernet .yarn/* fra astro

* 🎨 La til .yarn i gitignore for astro

* ⏪ Bruker riktig ds-react versjon

* ⬆️ Lik tsconfig som ds-react i aksel-icons

* 🐛 La tilbake provernance

* 🐛 Undo Day/Row fix fra main

* 🔥 Fjernet mulighet til å laste date-fns v2

* 🔥 Fjernet fs-extra dependency

* 🐛 Fikset feil versjon på core/react

* [V6] React-pakker støtter nå native SSR ved å bruke `use-client` 🎉  (#2707)

* 🎉 use-client blir lagt til på export av ds-react og aksel-icons

* 🎉 La til nextjs med appdir for testing

* ⚡ Tom yarn.lock med i git

* 📝 changeset

* 📝 Oppdatert migration-guide

* Update @navikt/aksel-icons/config/index-template.js

* 🚚 rename fitted -> fill in tabs

* 🐛 Async-lasting av date-fns er nå v3 esm compliant

v3 av date-fns støtter ikke default export/import, noe som brakk async-lasting da vi brukte .default

* 🧪 Forbedret Tabs-tester

Lagt til flere test-caser, men implementert testing av keyboard-nav og tabbing

* ⬆️ yarn sync

* ⬆️ Yarn lock

* ⬇️ Reversert duplikate endringer som kom fra v6

* 🎨 Aria-hidden blir nå satt på ikon hvis label allerede er satt på tab

* Update @navikt/core/react/src/tabs/Tabs.types.ts

* 📝 Testnavn for tabs

* ⚡ Skipper unødvendig memo for tab-context, punktum etter all jsdoc

* 🔥 Fjernet TabsContextProps

* 📝 Fjernet comma på changeset

* 📝 Fill-demo for aksel.nav.no

* 📝 Changeset for lazy-prop

* Update @navikt/core/react/src/tabs/parts/tabpanel/TabPanel.tsx

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

* 🐛 Beholder  attr fra radix med i ny implementasjon

* ⚡ Flyttet fokus-state update ut av useeffect, fjernet unødvendig klasse for hidden

* ⚡ Unngår unødvendig sjekk for disabled i onFocus for useTab2

* ♻️ tab og tabpanel støtter nå egne id-er

* Update @navikt/core/react/src/tabs/useTabs.ts

* 🐛 I none tilfeller førte setFocusedValue utenfor useEffect til infinite re-renders

* Update @navikt/core/react/src/tabs/parts/tab/Tab.tsx

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

* 🐛 bruker-id var satt på feil attrb

* 📝 Dokumentert custom-ids for tabs

* ⚡ Chroamtic-format for tabs-stories

* ♻️ Omskrevet tabs-stories for ny syntax

* 📝 Bruker bold markdown istedenfor note syntax

* 📝 Flyttet warning til newline

* 📝 Bruker bare ett linjeskift for jsdoc

---------

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2024-04-10 13:36:11 +02:00
dependabot[bot]andKen b417c74374 ⬆️ Bump vite from 5.1.6 to 5.1.7 (#2836)
* ⬆️ Bump vite from 5.1.6 to 5.1.7

Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.1.6 to 5.1.7.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.1.7/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.1.7/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

* ⬆️ bumpet vite til 5.1.7 i shadow-dom demo

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ken <ken.aleksander@gmail.com>
2024-04-10 12:24:54 +02:00
Halvor Haugan ff1d3534a6 🆙 Oppgrader Chromatic (#2837) 2024-04-10 12:13:48 +02:00
KenandHalvor Haugan 57d015209d [aksel.nav.no] Oppdatert ny GP-struktur og frontend (#2778)
* 🎉 Animert hero-select for nye gp-sider

* 🎨 Implementert keyboard-handler for tema-hero

* ♿ Gjemmer elementer ikke synlige ved hero-select

* 🎉 Ny hero for gp-side

* 🎨 Oppdatet gp-forside

* 🧪 Stories for nye komponenter

* 🐛 tabindex -> tabIndex

* ♻️ Oppdatert queriy definition

* 💄 Justert for mobilvisning

* 🎨 Bruker prictogram-data fra sanity i cards

* 🎉 Bruker faktiske innholdstyper og undertema nå

* 🔥 Fjernet TODO

* 🎨 Oppdatert go-forside til å støtte riktekst description

* 🐛 stories var ikke oppdatert

* 🎉 Bruker hero-cards i tema-hero

* 🐛 stories var ikke oppdatert

* 🐛 Manglet image i response-type

* ♻️ Omstrukturert modalvariant av dialog

* 🎉 Dynamisk hero for gp-sider

* 💫 Dynamisk skalering er nå animert

* 💫 Justert exit-animasjon

* 🔥 Fjernet gammel inline style

* ♻️ Forenklet Static hero callbacks

* 🔥 Fjernet hasRefs sjekk etter query for gp-artikler

* 🎉 Oppdatert serverside interface for gp-tema

* 🎉 Visninger for filtervalg på gp-tema

* 🐛 Valgt element ble ikke fokusert ved hero-åpning

* 🎉 Oppdatert artikkelkort for nye gp-sider

* 💄 Justert spacing for heading/desc seksjoner

* 🎉 Fikset lasting av data og chips

* 🎉 Fungerende chip-navigering

* ⚰️ Fjernet gammel kode

* 🚚 rename tema-hero filer

* 📝 Dokumentert count articles funksjoner

* 🧪 tester for count-articles

* 🚚 Flyttet cardgrid ut til egen fil

* 🔍 lagt til seo for tema

* ⬇️ reversert til prod-dataset

* 🎉 Artikler på gp-forside er sortert etter mest populære

* 🎨 satt description på undertema til required

* 🎉 Panel for forhåndsvisning av undertema/innholdstype på artikkelside

* 🎉 Lagt til forklarings-felt i ny god-praksis struktur

* 🐛 Fjernet chiplist for lister uten noe data

* 🎉 Tilpasset temaheader for kontrast

* 💄 normal -> semibold for statichero title

* ♿ Wrappet lister i ul > li

* 🎉 Oppdatert til ul > li på artikkel-lister

* 🐛 Cards strakk seg ikke til høyde etter wrapping i li

* 🐛 Manglet main-element og skip-link støtte

* 🔍 Bedre meta-description

* 🔥 Fjernet kommentar

* 🎨 Gjort klar sitemap for ny god-praksis

* ♿ Bedre semantikk for temasider

* 🎨 Justert tema header

* 🎨 Oppdatert akselchevron

* 💫 Justert page-animasjon

* 🎨 La til artikkel-chips

* ♻️ Link chip -> temalink

* 🎨 Tema-tagging på gp-artikler

* 💄 Gradient og illustrasjon på tema-hero

* 💄 La til bildestøtte i hero-tittel

* 🐛 stories brakk

* 💄 Satt god praksis heading til text- default for bedre kontrast

* 💄 z-index problem på intro hero

* 💄 Småjusteringer design

* 🐛 z-index stacking av cube-illustrasjon nå bak tekst

* 📝 Lagt til kommentar

* 🚚 Flyttet over støtte for wc definering i react

* 🐛 Brukte feil temahenter i sitemap som brakk playwright

* ♿ uu-oppdateringer etter review

* ♻️ Explanation -> description for undertema og tema

* 💄 Oppdatert Hero for teamsider

* 💄 Blur-bg på tekst i hero, no underline på hover for komponentsforside

* ♻️ Fokus beholdet på select-knapp ved åpning av tema-dialog

* 📝 Commentarer på escapeKeyDown hook

* 💄 Gjennbruker gradient for alle hero-headers

* 📝 Skrivefeil

* 📝 Kommentert useGpViews

* 📝 Kommentert endringer

* ⬆️ Bumpet sanity til v3.36

* ♻️ Fjernet Modal variant av tema hero.

Bruker samme dialog på mobil og desktop

* 🎨 aria headings -> h3 for gp-forside lenke

* 📝 Lowercase i html for artikkelcards

* Update aksel.nav.no/website/pages/gp/index.tsx

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

* Update aksel.nav.no/website/components/website-modules/AnimatedChevron.tsx

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

* Update aksel.nav.no/website/components/layout/god-praksis-page/cards/GpArticleCard.tsx

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

* Update aksel.nav.no/website/components/layout/god-praksis-page/hero/tema-hero/parts/HeroCardList.tsx

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

* 🚚 ArticleView -> ArticleSections

* 🚚 bosheight -> dialogheight

* 🐛 Import feilet av GpCardGrid etter navneendring

* 🐛 hero navigasjon var ikke sortert etter temanavn

* 🔥 Fjernet explanation-felt for innholdstyper

* ♻️ Ryddet opp i kommentarer fra github

* ♻️ Bruker SanityImageSource for Image-type

* 💄 Selected-variant av herocard. Inverted-pictogram i sanity

* 📝 view -> queryView

* ♻️ Skrevet om ArticleSections for å unngå repetert kode

* ⬆️ yarn lock

* ♻️ Fjernet unødvendig react-fragment

---------

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2024-04-10 10:15:56 +02:00
Ken 5969859024 🐛 Unngå at global Escape-event lukker ikon-sidebar
Dette gjelder ved escape i Søk(modal) og søkefelt på ikonside
2024-04-10 09:05:51 +02:00
github-actions[bot]andgithub-actions[bot] 9bbc3e6813 Version Packages (#2835)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-09 15:25:38 +02:00
Ken 5250b47ca4 💄 Border-radius på FileUpload.Item er nå rund (#2834) 2024-04-09 15:20:40 +02:00
Ken fc7709ae2a 🐛 Søk filtrerte ut beste treff fra 'nyeste artikler' 2024-04-09 09:40:20 +02:00
github-actions[bot]andgithub-actions[bot] 2364e689ed Version Packages (#2831)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-09 09:12:06 +02:00
Halvor Haugan d57c0b2c2c Justering av eksempler på Checkbox (#2833) 2024-04-09 09:06:58 +02:00
Ken 868bb543a5 🐛 Preview brakk for god-praksis
Siden 'userState' ikke hentes fra Sanity, blir den undefined ved lasting av previewdata
2024-04-09 07:33:16 +02:00
Torkjel SkjetneandHalvor Haugan 8f9ed7a404 FileUpload.Item: 💥 Endret API og støtte for å sette ID på knappen (#2824)
Co-authored-by: Halvor Haugan <halvor.haugan@nav.no>
2024-04-08 09:16:53 +02:00
Halvor HauganandKen 5ea6255f6d Justeringer av eksempler på Radio og Checkbox med mer (#2830)
Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>
2024-04-04 20:54:12 +02:00
github-actions[bot]andgithub-actions[bot] 491fccbe80 Version Packages (#2828)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-03 11:56:35 +02:00
Ken cca2a8263f 💄 Fjernet Illustrasjon fra bloggsider (#2827) 2024-04-03 11:50:12 +02:00
Ken 3140ec2765 🔥 Fjernet role=gridcell fra datoknapper (#2826)
Siden knappene allerede er i en table og da også td er det unødvendig med gricell
2024-04-03 11:37:05 +02:00
Halvor Haugan d8fce83ae3 Oppgrader til Storybook 8 (#2825) 2024-04-03 10:03:44 +02:00
github-actions[bot]andgithub-actions[bot] f88b00daf6 Version Packages (#2821)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-03 09:45:19 +02:00
Ken 6d0baea5c1 👽 Oppdatert om Aksel lenke i footer
Side er flyttet fra god-praksis til standalone-side url
Er opprettet automatisk redirect slik at eldre lenker ikke brekker
2024-04-03 09:25:54 +02:00
Ken 880e77b9c2 ♻️ Fjernet max-krav for antall tegn i Relaterte lenker-module
Siden lenketeksten ofte refererer til en sidetittel skaper det utfordringer da de ofte er litt lengre
2024-04-03 09:03:57 +02:00
Ken 6d16373f85 👽 Oppdatert skriv for aksel lenke i footer
Side er flyttet fra god-praksis til standalone-side url
Er opprettet automatisk redirect slik at eldre lenker ikke brekker
2024-04-03 08:42:08 +02:00
Halvor Haugan ae078254dc Modal: Fjern tomt element (#2819) 2024-04-02 10:20:00 +02:00
204 changed files with 6311 additions and 5428 deletions

No files matched your search

+2 -2
View File
@@ -1,4 +1,3 @@
import { mergeConfig } from "vite";
import turbosnap from "vite-plugin-turbosnap";
export default {
@@ -7,7 +6,7 @@ export default {
stories: () => ["../@navikt/**/*.stories.@(js|jsx|ts|tsx|mdx)", "./*.mdx"],
addons: [
"@storybook/addon-a11y",
"@xfinx/storybook-addon-html",
"@whitespace/storybook-addon-html",
"@storybook/addon-interactions",
{
name: "@storybook/addon-storysource",
@@ -42,6 +41,7 @@ export default {
},
async viteFinal(config, { configType }) {
const { mergeConfig } = await import("vite");
return mergeConfig(config, {
plugins:
configType === "PRODUCTION"
+10
View File
@@ -1,5 +1,15 @@
# @navikt/aksel-icons
## 6.5.0
## 6.4.1
## 6.4.0
## 6.3.6
## 6.3.5
## 6.3.4
## 6.3.3
@@ -28,7 +28,7 @@
"postcss": "^8.4.31",
"postcss-url": "^10.1.3",
"typescript": "^5.1.6",
"vite": "^5.0.12",
"vite": "^5.1.7",
"vite-plugin-singlefile": "^1.0.0",
"vite-plugin-svgr": "^4.2.0"
}
+4 -4
View File
@@ -3,12 +3,12 @@ import React from "react";
import * as AkselIcons from "@navikt/aksel-icons";
import { StarFillIcon } from "@navikt/aksel-icons";
const meta = {
const meta: Meta<typeof StarFillIcon> = {
title: "aksel-icons/Icons",
} satisfies Meta<typeof StarFillIcon>;
};
export default meta;
type Story = StoryObj<typeof meta>;
type Story = StoryObj<typeof StarFillIcon>;
export const Icons: Story = {
render: () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-icons",
"version": "6.3.4",
"version": "6.5.0",
"author": "Aksel | NAV",
"homepage": "https://aksel.nav.no/ikoner",
"repository": {
+10
View File
@@ -1,5 +1,15 @@
# @navikt/aksel-stylelint
## 6.5.0
## 6.4.1
## 6.4.0
## 6.3.6
## 6.3.5
## 6.3.4
## 6.3.3
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-stylelint",
"version": "6.3.4",
"version": "6.5.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.3.4",
"@navikt/ds-tokens": "^6.3.4",
"@navikt/ds-css": "^6.5.0",
"@navikt/ds-tokens": "^6.5.0",
"concurrently": "7.2.1",
"postcss-selector-parser": "^6.0.13",
"postcss-value-parser": "^4.2.0",
+35
View File
@@ -1,5 +1,40 @@
# @navikt/aksel
## 6.5.0
### Patch Changes
- Updated dependencies []:
- @navikt/ds-css@6.5.0
## 6.4.1
### Patch Changes
- Updated dependencies [[`5250b47ca`](https://github.com/navikt/aksel/commit/5250b47ca410f9cd57c5350bacc77b1b2ec9b2c0)]:
- @navikt/ds-css@6.4.1
## 6.4.0
### Patch Changes
- Updated dependencies []:
- @navikt/ds-css@6.4.0
## 6.3.6
### Patch Changes
- Updated dependencies []:
- @navikt/ds-css@6.3.6
## 6.3.5
### Patch Changes
- Updated dependencies []:
- @navikt/ds-css@6.3.5
## 6.3.4
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel",
"version": "6.3.4",
"version": "6.5.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.3.4",
"@navikt/ds-css": "6.5.0",
"axios": "1.6.0",
"chalk": "4.1.0",
"clipboardy": "^2.3.0",
+14
View File
@@ -1,5 +1,19 @@
# @navikt/ds-css
## 6.5.0
## 6.4.1
### Patch Changes
- FileUpload: Item-illustrasjon er nå radius-full ([#2834](https://github.com/navikt/aksel/pull/2834))
## 6.4.0
## 6.3.6
## 6.3.5
## 6.3.4
### Patch Changes
+1 -1
View File
@@ -182,7 +182,7 @@
.navds-file-item__icon {
background-color: var(--a-surface-neutral-subtle);
color: var(--a-icon-default);
border-radius: var(--a-border-radius-xlarge);
border-radius: var(--a-border-radius-full);
min-height: 3rem;
min-width: 3rem;
display: grid;
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-css",
"version": "6.3.4",
"version": "6.5.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.3.4",
"@navikt/ds-tokens": "^6.5.0",
"cssnano": "6.0.0",
"fast-glob": "3.2.11",
"lodash": "4.17.21",
+9 -1
View File
@@ -7,7 +7,7 @@
.navds-tabs__tablist {
display: flex;
max-width: 100%;
width: auto;
width: 100%;
scroll-behavior: smooth;
-ms-overflow-style: none; /* for Internet Explorer, Edge */
scrollbar-width: none; /* for Firefox */
@@ -83,6 +83,10 @@
gap: var(--a-spacing-1);
}
.navds-tabs__tab-inner > * {
display: inline-flex;
}
.navds-tabs__tab-inner svg {
flex-shrink: 0;
}
@@ -117,6 +121,10 @@
font-size: 1.5rem;
}
.navds-tabs__tab--fill {
flex: 1 1 100%;
}
.navds-tabs__tabpanel:focus-visible {
outline: 2px solid transparent;
box-shadow: inset 0 0 0 2px var(--a-border-focus);
+4
View File
@@ -4,6 +4,10 @@
gap: var(--a-spacing-2);
}
.navds-toggle-group__wrapper--fill {
justify-items: initial;
}
.navds-toggle-group {
border-radius: var(--a-border-radius-medium);
background-color: var(--ac-toggle-group-bg, var(--a-surface-transparent));
+64
View File
@@ -1,5 +1,69 @@
# @navikt/ds-react
## 6.5.0
### Minor Changes
- Tabs: Ny prop `lazy` som rendrer innhold i TabPanel selv når panel er skjult (har fortsatt display:none) ([#2621](https://github.com/navikt/aksel/pull/2621))
- ToggleGroup: Erstattet bruk av dependency `@radix-ui/react-toggle-group` med egen implementasjon. ([#2620](https://github.com/navikt/aksel/pull/2620))
- Tabs: La til ny prop `fill` som lar Tabs.Tab-elementer strekke seg over tilgjengelig bredde. ([#2621](https://github.com/navikt/aksel/pull/2621))
- Tabs: Erstattet bruk av dependency `@radix-ui/react-tabs` med egen implementasjon. ([#2621](https://github.com/navikt/aksel/pull/2621))
### Patch Changes
- DatePicker/MonthPicker: Valgte datoer får nå `aria-pressed` for å bedre indikere valg for skjermleser. ([#2838](https://github.com/navikt/aksel/pull/2838))
- DatePicker/MonthPicker: `required`-prop stoppet ikke de-select av allerede valgt dato. ([#2838](https://github.com/navikt/aksel/pull/2838))
- ToggleGroup: La til ny prop `fill` som strekker ToggleGroup til å ta opp all tilgjengelig bredde. ([#2620](https://github.com/navikt/aksel/pull/2620))
- Updated dependencies []:
- @navikt/ds-tokens@6.5.0
- @navikt/aksel-icons@6.5.0
## 6.4.1
### Patch Changes
- Updated dependencies []:
- @navikt/ds-tokens@6.4.1
- @navikt/aksel-icons@6.4.1
## 6.4.0
### Minor Changes
- FileUpload.Item: :boom: Endret API og støtte for å sette ID på knappen ([#2824](https://github.com/navikt/aksel/pull/2824))
### Patch Changes
- Updated dependencies []:
- @navikt/ds-tokens@6.4.0
- @navikt/aksel-icons@6.4.0
## 6.3.6
### Patch Changes
- DatePicker: Fjernet role gridcell fra dato-knapper. ([#2826](https://github.com/navikt/aksel/pull/2826))
- Updated dependencies []:
- @navikt/ds-tokens@6.3.6
- @navikt/aksel-icons@6.3.6
## 6.3.5
### Patch Changes
- Modal: Fjern tomt element ([#2819](https://github.com/navikt/aksel/pull/2819))
- Updated dependencies []:
- @navikt/ds-tokens@6.3.5
- @navikt/aksel-icons@6.3.5
## 6.3.4
### Patch Changes
+3 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-react",
"version": "6.3.4",
"version": "6.5.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",
@@ -562,10 +562,8 @@
},
"dependencies": {
"@floating-ui/react": "0.25.4",
"@navikt/aksel-icons": "^6.3.4",
"@navikt/ds-tokens": "^6.3.4",
"@radix-ui/react-tabs": "1.0.0",
"@radix-ui/react-toggle-group": "1.0.0",
"@navikt/aksel-icons": "^6.5.0",
"@navikt/ds-tokens": "^6.5.0",
"clsx": "^2.1.0",
"date-fns": "^3.0.0",
"react-day-picker": "8.10.0"
@@ -33,6 +33,7 @@ export interface CopyButtonProps
/**
* Text shown when button is clicked.
* Only set if used with `text`-prop.
* @default "Kopiert!"
*/
activeText?: string;
/**
@@ -1,6 +1,8 @@
import { Meta, StoryObj } from "@storybook/react";
import { expect, userEvent, within } from "@storybook/test";
import { isSameDay } from "date-fns";
import React, { useId, useState } from "react";
import { act } from "react-dom/test-utils";
import { Button } from "../../button";
import { HGrid } from "../../layout/grid";
import { VStack } from "../../layout/stack";
@@ -440,6 +442,43 @@ export const WeekDayClick = () => {
);
};
export const Required = {
render: () => {
const { datepickerProps } = useDatepicker({
defaultSelected: new Date("Apr 10 2024"),
required: true,
});
return (
<div style={{ height: "20rem" }}>
<DatePicker.Standalone {...datepickerProps} />
</div>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button10 = canvas.getByRole("button", { pressed: true });
await act(async () => {
await userEvent.click(button10);
});
expect(button10.ariaPressed).toBe("true");
const button17 = canvas.getByText("17").closest("button");
expect(button17?.ariaPressed).toBe("false");
await act(async () => {
button17 && (await userEvent.click(button17));
});
expect(button17?.ariaPressed).toBe("true");
expect(button10.ariaPressed).toBe("false");
},
};
export const ModalDemo = () => {
const { datepickerProps, inputProps } = useDatepicker({
fromDate: new Date("Aug 23 2019"),
@@ -20,8 +20,11 @@ const DayButton = (props: DayProps) => {
name="day"
ref={buttonRef}
{...dayRender.buttonProps}
role={undefined}
aria-label={dateTime}
aria-hidden={dayRender.activeModifiers.outside}
aria-selected={undefined}
aria-pressed={!!dayRender.activeModifiers.selected}
/>
);
};
@@ -229,12 +229,16 @@ export const useDatepicker = (
/* Only allow de-selecting if not required */
const handleDayClick: DayClickEventHandler = (day, { selected }) => {
if (selected && required) {
return;
}
if (day && !selected) {
handleOpen(false);
anchorRef?.focus();
}
if (!required && selected) {
if (selected) {
updateDate(undefined);
setInputValue("");
updateValidation({ isValidDate: false, isEmpty: true });
@@ -225,13 +225,17 @@ export const useMonthpicker = (
/* Only allow de-selecting if not required */
const handleMonthClick = (month?: Date) => {
if (!month && required) {
return;
}
if (month) {
handleOpen(false);
setYear(month);
anchorRef?.focus();
}
if (!required && !month) {
if (!month) {
updateMonth(undefined);
updateValidation({ isValidMonth: false, isEmpty: true });
setInputValue("");
@@ -66,6 +66,7 @@ export const MonthButton = ({
type="button"
onClick={() => onSelect(isSelected ? undefined : month)}
disabled={isDisabled}
aria-pressed={!!isSelected}
className={cl("navds-date__month-button", {
"rdp-day_today": dateIsInCurrentMonth(month, year),
"rdp-day_selected": isSelected,
@@ -1,6 +1,8 @@
import { Meta, StoryFn, StoryObj } from "@storybook/react";
import { expect, userEvent, within } from "@storybook/test";
import { setYear } from "date-fns";
import React, { useId, useState } from "react";
import { act } from "react-dom/test-utils";
import { Button } from "../../button";
import { useMonthpicker } from "../hooks";
import { DateInputProps } from "../parts/DateInput";
@@ -121,25 +123,41 @@ export const UseMonthpickerFormat = () => {
);
};
export const Required = () => {
const { inputProps, monthpickerProps } = useMonthpicker({
locale: "nb",
defaultSelected: new Date(),
disabled: [new Date("Apr 1 2022")],
required: true,
});
export const Required = {
render: () => {
const { monthpickerProps } = useMonthpicker({
defaultSelected: new Date("Apr 10 2024"),
required: true,
});
return (
<div style={{ height: "20rem" }}>
<MonthPicker {...monthpickerProps}>
<MonthPicker.Input
{...inputProps}
label="Velg måned"
variant="monthpicker"
/>
</MonthPicker>
</div>
);
return (
<div style={{ height: "20rem" }}>
<MonthPicker.Standalone {...monthpickerProps} />
</div>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const buttonApr = canvas.getByRole("button", { pressed: true });
await act(async () => {
await userEvent.click(buttonApr);
});
expect(buttonApr.ariaPressed).toBe("true");
const buttonSep = canvas.getByText("september").closest("button");
expect(buttonSep?.ariaPressed).toBe("false");
await act(async () => {
buttonSep && (await userEvent.click(buttonSep));
});
expect(buttonSep?.ariaPressed).toBe("true");
expect(buttonApr.ariaPressed).toBe("false");
},
};
export const UserControlled = () => {
@@ -193,7 +211,6 @@ export const Chromatic: Story = {
<DisabledMonths />
<UseMonthpicker />
<UseMonthpickerFormat />
<Required />
<UserControlled />
<FollowYear />
</div>
@@ -16,7 +16,7 @@ export const CheckboxGroupContext = createContext<CheckboxGroupState | null>(
export interface CheckboxGroupProps
extends Omit<
FieldsetProps,
"onChange" | "errorPropagation" | "defaultValue"
"onChange" | "errorPropagation" | "defaultValue" | "nativeReadOnly"
> {
/**
* Collection of `<Checkbox/>`.
@@ -27,7 +27,7 @@ export interface CheckboxGroupProps
*/
value?: any[];
/**
* Default checked checkboxes on render.
* Default checked checkboxes.
*/
defaultValue?: any[];
/**
@@ -44,10 +44,10 @@ export interface CheckboxGroupProps
*
* @example
* ```jsx
* <CheckboxGroup legend="Hvor vil du sitte?">
* <Checkbox value="Bakerst">Bakerst</Checkbox>
* <Checkbox value="Midterst">Midterst</Checkbox>
* <Checkbox value="Fremst">Fremst</Checkbox>
* <CheckboxGroup legend="Transportmiddel">
* <Checkbox value="car">Bil</Checkbox>
* <Checkbox value="taxi">Drosje</Checkbox>
* <Checkbox value="public">Kollektivt</Checkbox>
* </CheckboxGroup>
* ```
*/
@@ -14,7 +14,7 @@ export interface CheckboxProps
*/
errorId?: string;
/**
* Checkbox label
* Checkbox label.
*/
children: React.ReactNode;
/**
@@ -22,7 +22,7 @@ export interface CheckboxProps
*/
hideLabel?: boolean;
/**
* The value of the HTML element.
* Checkbox value.
*/
value?: any;
/**
@@ -31,7 +31,7 @@ export interface CheckboxProps
*/
indeterminate?: boolean;
/**
* Adds a description to extend labeling of Checkbox.
* Adds a description to extend the labeling.
*/
description?: string;
}
@@ -89,8 +89,8 @@ interface FileUploadComponent
* ```jsx
* <FileUpload.Item file={file} status="uploading" />
* <FileUpload.Item file={file} status="downloading" />
* <FileUpload.Item file={file} itemAction="retry" onRetry={...} />
* <FileUpload.Item file={file} itemAction="delete" onDelete={...} />
* <FileUpload.Item file={file} button={{ action:"retry", onClick:... }} />
* <FileUpload.Item file={file} button={{ action:"delete", onClick:... }} />
* ```
*/
Item: typeof Item;
@@ -57,51 +57,63 @@ export const States: StoryObj<typeof FileUpload.Item> = {
file={fileTxt}
error="Error og uploading"
status="uploading"
onRetry={() => onRetry(fileTxt)}
onDelete={() => onDelete(fileTxt)}
button={{
onClick: () => onDelete(fileTxt),
action: "delete",
}}
/>
<h2>Item Actions</h2>
<h3>status + delete</h3>
<FileUpload.Item
file={fileDocx}
onDelete={() => onDelete(fileDocx)}
onRetry={() => onRetry(fileDocx)}
status="uploading"
itemAction="delete"
button={{
onClick: () => onDelete(fileDocx),
action: "delete",
}}
/>
<h3>status + retry</h3>
<FileUpload.Item
file={fileDocx}
onDelete={() => onDelete(fileDocx)}
onRetry={() => onRetry(fileDocx)}
status="downloading"
itemAction="retry"
button={{
onClick: () => onRetry(fileDocx),
action: "retry",
}}
/>
<h3>retry</h3>
<FileUpload.Item
file={fileCsv}
onRetry={() => onRetry(fileCsv)}
itemAction="retry"
button={{
onClick: () => onRetry(fileCsv),
action: "retry",
}}
/>
<h3>delete</h3>
<FileUpload.Item
file={filePptx}
onDelete={() => onDelete(filePptx)}
itemAction="delete"
button={{
onClick: () => onDelete(filePptx),
action: "delete",
}}
/>
<h3>retry + error</h3>
<FileUpload.Item
file={fileCsv}
error="Error og onRetry"
onRetry={() => onRetry(fileCsv)}
itemAction="retry"
button={{
onClick: () => onRetry(fileCsv),
action: "retry",
}}
/>
<h3>delete + error</h3>
<FileUpload.Item
file={filePptx}
error="Error og onDelete"
onDelete={() => onDelete(filePptx)}
itemAction="delete"
button={{
onClick: () => onDelete(filePptx),
action: "delete",
}}
/>
</div>
),
@@ -25,6 +25,7 @@ const MAX_SIZE = MAX_SIZE_MB * 1024 * 1024;
const CustomItem = ({
index,
onDelete,
...props
}: FileUploadItemProps & {
index: number;
@@ -45,7 +46,10 @@ const CustomItem = ({
<FileUpload.Item
{...props}
status={loading ? "uploading" : "idle"}
itemAction="delete"
button={{
action: "delete",
onClick: onDelete,
}}
as="li"
/>
);
@@ -165,7 +169,10 @@ export const Single: StoryFn = () => {
key={file.file.name}
file={file.file}
error={file.error ? errors[file.reasons[0]] : undefined}
onDelete={removeFile}
button={{
action: "delete",
onClick: removeFile,
}}
/>
))}
</VStack>
@@ -194,12 +201,17 @@ export const Translation = () => (
<FileUpload.Dropzone label="Last opp bilder" onSelect={console.log} />
<FileUpload.Item
file={{ name: "eksempel.png", size: 200000 }}
onDelete={() => null}
button={{
action: "delete",
onClick: () => null,
}}
/>
<FileUpload.Item
file={{ name: "eksempel.png", size: 200000 }}
itemAction="retry"
onRetry={() => null}
button={{
action: "retry",
onClick: () => null,
}}
/>
<FileUpload.Item
file={{ name: "eksempel.png", size: 200000 }}
@@ -12,12 +12,13 @@ import ItemIcon from "./ItemIcon";
import ItemName from "./ItemName";
import { formatFileSize } from "./utils/format-file-size";
export interface FileItemBaseProps {
export interface FileUploadItemProps
extends React.HTMLAttributes<HTMLDivElement> {
/**
* Overrides html-tag
* @default "div"
*/
as?: "div" | "li";
as?: ("div" | "li") & React.ElementType;
/**
* Either a native File or file metadata.
*/
@@ -43,37 +44,20 @@ export interface FileItemBaseProps {
* @default "idle"
*/
status?: "downloading" | "uploading" | "idle";
/**
* Props for the action button.
*/
button?: {
action: "delete" | "retry";
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
id?: string;
};
/**
* i18n-API for customizing texts and labels
*/
translations?: ComponentTranslation<"FileUpload">["item"];
onRetry?: (event: MouseEvent<HTMLButtonElement>) => void;
onDelete?: (event: MouseEvent<HTMLButtonElement>) => void;
}
type FileItemActionDelete = {
onDelete: (event: MouseEvent<HTMLButtonElement>) => void;
itemAction: "delete";
};
type FileItemActionRetry = {
onRetry: (event: MouseEvent<HTMLButtonElement>) => void;
itemAction: "retry";
};
type FileItemActionNone = {
itemAction?: "none";
};
type FileItemConditionalProps =
| FileItemActionDelete
| FileItemActionRetry
| FileItemActionNone;
export type FileUploadItemProps = FileItemBaseProps &
FileItemConditionalProps &
React.HTMLAttributes<HTMLDivElement>;
export const Item: OverridableComponent<FileUploadItemProps, HTMLDivElement> =
forwardRef(
(
@@ -81,16 +65,14 @@ export const Item: OverridableComponent<FileUploadItemProps, HTMLDivElement> =
as: Component = "div",
file,
status = "idle",
onDelete,
onRetry,
error,
className,
href,
onFileClick,
itemAction = "delete",
button,
translations,
...rest
},
}: FileUploadItemProps,
ref,
) => {
const context = useFileUploadTranslation(false);
@@ -148,14 +130,14 @@ export const Item: OverridableComponent<FileUploadItemProps, HTMLDivElement> =
</div>
</div>
{status === "idle" && (
{status === "idle" && button && (
<ItemButton
file={file}
onRetry={onRetry}
onDelete={onDelete}
action={itemAction}
retryTitle={translate("item.retryButtonTitle")}
deleteTitle={translate("item.deleteButtonTitle")}
{...button}
title={translate(
button.action === "retry"
? "item.retryButtonTitle"
: "item.deleteButtonTitle",
)}
/>
)}
</div>
@@ -1,52 +1,27 @@
import React from "react";
import { ArrowsCirclepathIcon, TrashIcon } from "@navikt/aksel-icons";
import { Button } from "../../../../button";
import { FileItem } from "./Item.types";
interface Props {
file: FileItem;
onRetry?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDelete?: (event: React.MouseEvent<HTMLButtonElement>) => void;
action: "delete" | "retry" | "none";
retryTitle: string;
deleteTitle: string;
action: "delete" | "retry";
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
id?: string;
title: string;
}
const ItemButton = ({
onRetry,
onDelete,
action,
retryTitle,
deleteTitle,
}: Props) => {
if (action === "none") {
return null;
}
const ItemButton = ({ action, onClick, id, title }: Props) => {
const Icon = action === "delete" ? TrashIcon : ArrowsCirclepathIcon;
if (onRetry && action === "retry") {
return (
<Button
className="navds-file-item__button"
type="button"
variant="tertiary-neutral"
onClick={onRetry}
icon={<ArrowsCirclepathIcon title={retryTitle} />}
/>
);
}
if (onDelete && action === "delete") {
return (
<Button
className="navds-file-item__button"
type="button"
variant="tertiary-neutral"
onClick={onDelete}
icon={<TrashIcon title={deleteTitle} />}
/>
);
}
return null;
return (
<Button
id={id}
className="navds-file-item__button"
type="button"
variant="tertiary-neutral"
onClick={onClick}
icon={<Icon title={title} />}
/>
);
};
export default ItemButton;
@@ -18,7 +18,7 @@ export const RadioGroupContext =
export interface RadioGroupProps
extends Omit<
FieldsetProps,
"onChange" | "errorPropagation" | "defaultValue"
"onChange" | "errorPropagation" | "defaultValue" | "nativeReadOnly"
> {
/**
* Collection of `<Radio />`-elements
+2 -2
View File
@@ -9,11 +9,11 @@ export interface RadioProps
*/
children: React.ReactNode;
/**
* The value of the HTML element.
* Radio value.
*/
value: any;
/**
* Adds a description to extend labeling of Radio.
* Adds a description to extend the labeling.
*/
description?: string;
}
+2 -3
View File
@@ -5,8 +5,7 @@ import { FieldsetContext } from "./fieldset/context";
export interface FormFieldProps {
/**
* Adds a description to extend labeling of the field.
* Error message for element.
* Error message.
*/
error?: React.ReactNode;
/**
@@ -24,7 +23,7 @@ export interface FormFieldProps {
*/
disabled?: boolean;
/**
* Adds a description to extend labeling of a field.
* Adds a description to extend the labeling.
*/
description?: React.ReactNode;
/**
@@ -41,16 +41,14 @@ export const PosterVariants = {
render: () => (
<VStack gap="6" align="start">
<GuidePanel>
If you exclude the <code>poster</code> prop, you will get the poster
variant on mobile (&lt;480px) and the non-poster variant otherwise.
If you exclude the `poster` prop, you will get the poster variant on
mobile (&lt;480px) and the non-poster variant otherwise.
</GuidePanel>
<GuidePanel poster>
Use the <code>poster</code> prop to get the poster variant on all
viewports.
Use the `poster` prop to get the poster variant on all viewports.
</GuidePanel>
<GuidePanel poster={false}>
Set <code>poster=false</code> to get the non-poster variant on all
viewports.
Set `poster=false` to get the non-poster variant on all viewports.
</GuidePanel>
</VStack>
),
+5 -1
View File
@@ -238,7 +238,11 @@ export const Modal = forwardRef<HTMLDialogElement, ModalProps>(
level="1"
id={ariaLabelId}
>
<span className="navds-modal__header-icon">{header.icon}</span>
{header.icon && (
<span className="navds-modal__header-icon">
{header.icon}
</span>
)}
{header.heading}
</Heading>
</ModalHeader>
@@ -146,6 +146,7 @@ export const WithUseState: StoryFn = () => {
);
};
WithUseState.storyName = "With useState";
WithUseState.parameters = { chromatic: { disable: true } };
export const EmptyHeader: StoryFn = () => (
<Modal open onClose={() => null} aria-label="Modal with empty header">
@@ -225,6 +226,7 @@ export const WithTooltip: StoryFn = () => {
</div>
);
};
WithTooltip.parameters = { chromatic: { disable: true } }; // The tooltip behaves unpredictably in Chromatic
export const WithSrOnlyElement: StoryFn = () => (
<Modal
@@ -330,12 +332,6 @@ ChromaticViewportTesting.parameters = {
height: 400,
},
},
desktop: {
viewport: {
width: 1280,
height: 960,
},
},
},
},
};
-66
View File
@@ -1,66 +0,0 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import cl from "clsx";
import React, { forwardRef, useContext } from "react";
import { BodyShort } from "../typography";
import { OverridableComponent } from "../util/types";
import { TabsContext } from "./context";
export interface TabProps
extends Omit<React.HTMLAttributes<HTMLButtonElement>, "children"> {
/**
* Tab label
*/
label?: React.ReactNode;
/**
* Tab Icon
*/
icon?: React.ReactNode;
/**
* Value for state-handling
*/
value: string;
}
export const Tab: OverridableComponent<TabProps, HTMLButtonElement> =
forwardRef(
(
{ className, as: Component = "button", label, icon, value, ...rest },
ref,
) => {
const context = useContext(TabsContext);
if (!label && !icon) {
console.error("<Tabs.Tab/> needs label/icon");
return null;
}
return (
<RadixTabs.Trigger value={value} asChild>
<Component
ref={ref}
className={cl(
"navds-tabs__tab",
`navds-tabs__tab--${context?.size ?? "medium"}`,
`navds-tabs__tab-icon--${context?.iconPosition}`,
className,
{
"navds-tabs__tab--icon-only": icon && !label,
},
)}
{...rest}
>
<BodyShort
as="span"
className="navds-tabs__tab-inner"
size={context?.size}
>
{icon}
{label}
</BodyShort>
</Component>
</RadixTabs.Trigger>
);
},
);
export default Tab;
-128
View File
@@ -1,128 +0,0 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import cl from "clsx";
import React, {
forwardRef,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { ChevronLeftIcon, ChevronRightIcon } from "@navikt/aksel-icons";
import { debounce } from "../util";
import { useMergeRefs } from "../util/hooks/useMergeRefs";
import { TabsContext } from "./context";
export interface TabListProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* `<Tabs.Tab />` elements
*/
children: React.ReactNode;
}
export const TabList = forwardRef<HTMLDivElement, TabListProps>(
({ className, ...rest }, ref) => {
const context = useContext(TabsContext);
const listRef = useRef<HTMLDivElement | null>(null);
const mergedRef = useMergeRefs(listRef, ref);
const [displayScroll, setDisplayScroll] = useState({
start: false,
end: false,
});
const updateScrollButtonState = useMemo(
() =>
debounce(() => {
if (!listRef?.current) return;
const { scrollWidth, clientWidth } = listRef.current;
const scrollLeft = listRef.current.scrollLeft;
// use 1 for the potential rounding error with browser zooms.
const showStartScroll = scrollLeft > 1;
const showEndScroll = scrollLeft < scrollWidth - clientWidth - 1;
setDisplayScroll((oldDisplayScroll) =>
showStartScroll === oldDisplayScroll.start &&
showEndScroll === oldDisplayScroll.end
? oldDisplayScroll
: { start: showStartScroll, end: showEndScroll },
);
}),
[],
);
useEffect(() => {
const handleResize = () => updateScrollButtonState();
const win = listRef.current?.ownerDocument ?? document ?? window;
win.addEventListener("resize", handleResize);
let resizeObserver;
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(listRef.current);
}
return () => {
win.removeEventListener("resize", handleResize);
if (resizeObserver) {
resizeObserver.disconnect();
}
};
}, [updateScrollButtonState]);
useEffect(() => {
updateScrollButtonState();
});
useEffect(() => {
return () => {
updateScrollButtonState.clear();
};
}, [updateScrollButtonState]);
const ScrollButton = ({
dir,
hidden,
}: {
dir: 1 | -1;
hidden: boolean;
}) => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className={cl("navds-tabs__scroll-button", {
"navds-tabs__scroll-button--hidden": hidden,
})}
onClick={() => {
if (!listRef.current) return;
listRef.current.scrollLeft += dir * 100;
}}
>
{dir === -1 ? (
<ChevronLeftIcon title="scroll tilbake" />
) : (
<ChevronRightIcon title="scroll neste" />
)}
</div>
);
const showSteppers = displayScroll.end || displayScroll.start;
return (
<div className="navds-tabs__tablist-wrapper">
{showSteppers && (
<ScrollButton dir={-1} hidden={!displayScroll.start} />
)}
<RadixTabs.TabsList
{...rest}
ref={mergedRef}
onScroll={updateScrollButtonState}
loop={context?.loop}
className={cl("navds-tabs__tablist", className)}
/>
{showSteppers && <ScrollButton dir={1} hidden={!displayScroll.end} />}
</div>
);
},
);
export default TabList;
-26
View File
@@ -1,26 +0,0 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import cl from "clsx";
import React, { forwardRef } from "react";
export interface TabPanelProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Tab panel content
*/
children: React.ReactNode;
/**
* Value for state-handling
*/
value: string;
}
const TabPanel = forwardRef<HTMLDivElement, TabPanelProps>(
({ className, ...rest }, ref) => (
<RadixTabs.TabsContent
{...rest}
ref={ref}
className={cl("navds-tabs__tabpanel", className)}
/>
),
);
export default TabPanel;
@@ -0,0 +1,24 @@
import { createContext } from "../util/create-context";
import { createDescendantContext } from "../util/hooks/descendants/useDescendant";
import { TabsProps } from "./Tabs.types";
import { useTabs } from "./useTabs";
export const [
TabsDescendantsProvider,
useTabsDescendantsContext,
useTabsDescendants,
useTabsDescendant,
] = createDescendantContext<HTMLButtonElement, { value: string }>();
type TabsProviderProps = ReturnType<typeof useTabs> &
Pick<
TabsProps,
"selectionFollowsFocus" | "loop" | "size" | "iconPosition" | "fill"
>;
/* State context */
export const [TabsProvider, useTabsContext] = createContext<TabsProviderProps>({
name: "TabsContext",
hookName: "useTabsContext",
providerName: "TabsProvider",
});
+236 -116
View File
@@ -1,7 +1,8 @@
import { Meta } from "@storybook/react";
import { Meta, StoryObj } from "@storybook/react";
import React, { useState } from "react";
import { DishwasherIcon, FreezerIcon, MugIcon } from "@navikt/aksel-icons";
import { Tabs } from ".";
import { VStack } from "../layout/stack";
export default {
title: "ds-react/Tabs",
@@ -20,41 +21,44 @@ export default {
},
},
},
parameters: {
chromatic: { disable: true },
},
} satisfies Meta<typeof Tabs>;
const Panel = () => {
return (
<>
<Tabs.Panel
value="test1"
className="panel"
style={{ background: "var(--a-gray-50)", height: 100 }}
>
Innholdspanel for Skap-tab
</Tabs.Panel>
<Tabs.Panel
value="test2"
className="panel"
style={{
background: "var(--a-green-50)",
height: 100,
}}
>
Innholdspanel for Oppvaskmaskin-tab
</Tabs.Panel>
<Tabs.Panel
value="test3"
className="panel"
style={{ background: "var(--a-red-50)", height: 100 }}
>
Innholdspanel for Fryser-tab
</Tabs.Panel>
<style>{`.panel[data-state="active"] { display:grid; place-content: center;}`}</style>
</>
);
};
type Story = StoryObj<typeof Tabs>;
export const Default = {
const Panel = () => (
<>
<Tabs.Panel
value="test1"
className="panel"
style={{ background: "var(--a-gray-50)", height: 100 }}
>
Innholdspanel for Skap-tab
</Tabs.Panel>
<Tabs.Panel
value="test2"
className="panel"
style={{
background: "var(--a-green-50)",
height: 100,
}}
>
Innholdspanel for Oppvaskmaskin-tab
</Tabs.Panel>
<Tabs.Panel
value="test3"
className="panel"
style={{ background: "var(--a-red-50)", height: 100 }}
>
Innholdspanel for Fryser-tab
</Tabs.Panel>
<style>{`.panel[data-state="active"] { display:grid; place-content: center;}`}</style>
</>
);
export const Default: Story = {
render: (props) => {
return (
<Tabs
@@ -65,13 +69,17 @@ export const Default = {
iconPosition={props?.iconPosition}
>
<Tabs.List>
<Tabs.Tab value="test1" icon={<MugIcon />} label="Skap" />
<Tabs.Tab value="test1" icon={<MugIcon aria-hidden />} label="Skap" />
<Tabs.Tab
value="test2"
label="Oppvaskmaskin"
icon={<DishwasherIcon />}
icon={<DishwasherIcon aria-hidden />}
/>
<Tabs.Tab
value="test3"
icon={<FreezerIcon aria-hidden />}
label="Fryser"
/>
<Tabs.Tab value="test3" icon={<FreezerIcon />} label="Fryser" />
</Tabs.List>
<Panel />
</Tabs>
@@ -84,108 +92,220 @@ export const Default = {
},
};
export const Small = () => {
return (
<Tabs defaultValue="test2" size="small">
<Tabs.List>
<Tabs.Tab value="test1" icon={<MugIcon />} label="Skap" />
<Tabs.Tab
value="test2"
label="Oppvaskmaskin"
icon={<DishwasherIcon />}
/>
<Tabs.Tab value="test3" icon={<FreezerIcon />} label="Fryser" />
</Tabs.List>
<Panel />
</Tabs>
);
};
export const Small = () => (
<Tabs defaultValue="test2" size="small">
<Tabs.List>
<Tabs.Tab value="test1" icon={<MugIcon aria-hidden />} label="Skap" />
<Tabs.Tab
value="test2"
label="Oppvaskmaskin"
icon={<DishwasherIcon aria-hidden />}
/>
<Tabs.Tab
value="test3"
icon={<FreezerIcon aria-hidden />}
label="Fryser"
/>
</Tabs.List>
<Panel />
</Tabs>
);
export const Controlled = () => {
const [activeValue, setActiveValue] = useState("test1");
return (
<Tabs value={activeValue} onChange={setActiveValue}>
<Tabs.List>
<Tabs.Tab value="test1" icon={<MugIcon />} label="Skap" />
<Tabs.Tab value="test1" icon={<MugIcon aria-hidden />} label="Skap" />
<Tabs.Tab
value="test2"
label="Oppvaskmaskin"
icon={<DishwasherIcon />}
icon={<DishwasherIcon aria-hidden />}
/>
<Tabs.Tab
value="test3"
icon={<FreezerIcon aria-hidden />}
label="Fryser"
/>
<Tabs.Tab value="test3" icon={<FreezerIcon />} label="Fryser" />
</Tabs.List>
<Panel />
</Tabs>
);
};
export const IconPosition = () => {
return (
<div className="colgap">
<Tabs defaultValue="test2" size="small">
<Tabs.List>
<Tabs.Tab value="test1" icon={<MugIcon />} label="Skap" />
<Tabs.Tab
value="test2"
label="Oppvaskmaskin"
icon={<DishwasherIcon />}
/>
<Tabs.Tab value="test3" icon={<FreezerIcon />} label="Fryser" />
</Tabs.List>
<Panel />
</Tabs>
<Tabs defaultValue="test2" size="small" iconPosition="top">
<Tabs.List style={{ margin: "0 auto" }}>
<Tabs.Tab value="test1" icon={<MugIcon />} label="Skap" />
<Tabs.Tab
value="test2"
label="Oppvaskmaskin"
icon={<DishwasherIcon />}
/>
<Tabs.Tab value="test3" icon={<FreezerIcon />} label="Fryser" />
</Tabs.List>
<Panel />
</Tabs>
</div>
);
};
export const Icon = () => {
return (
<div className="colgap">
<Tabs defaultValue="test2">
<Tabs.List style={{ margin: "0 auto" }}>
<Tabs.Tab value="test1" icon={<MugIcon />} />
<Tabs.Tab value="test2" icon={<DishwasherIcon />} />
<Tabs.Tab value="test3" icon={<FreezerIcon />} />
</Tabs.List>
<Panel />
</Tabs>
<Tabs defaultValue="test2" size="small" iconPosition="top">
<Tabs.List style={{ margin: "0 auto" }}>
<Tabs.Tab value="test1" icon={<MugIcon />} />
<Tabs.Tab value="test2" icon={<DishwasherIcon />} />
<Tabs.Tab value="test3" icon={<FreezerIcon />} />
</Tabs.List>
<Panel />
</Tabs>
</div>
);
};
export const Overflow = () => {
return (
<Tabs defaultValue="test2" style={{ maxWidth: 300 }}>
export const IconPosition = () => (
<VStack gap="4">
<Tabs defaultValue="test2" size="small">
<Tabs.List>
<Tabs.Tab value="test1" icon={<MugIcon />} label="Skap" />
<Tabs.Tab value="test1" icon={<MugIcon aria-hidden />} label="Skap" />
<Tabs.Tab
value="test2"
label="Oppvaskmaskin"
icon={<DishwasherIcon />}
icon={<DishwasherIcon aria-hidden />}
/>
<Tabs.Tab
value="test3"
icon={<FreezerIcon aria-hidden />}
label="Fryser"
/>
<Tabs.Tab value="test3" icon={<FreezerIcon />} label="Fryser" />
</Tabs.List>
<Panel />
</Tabs>
);
<Tabs defaultValue="test2" size="small" iconPosition="top">
<Tabs.List style={{ margin: "0 auto" }}>
<Tabs.Tab value="test1" icon={<MugIcon aria-hidden />} label="Skap" />
<Tabs.Tab
value="test2"
label="Oppvaskmaskin"
icon={<DishwasherIcon aria-hidden />}
/>
<Tabs.Tab
value="test3"
icon={<FreezerIcon aria-hidden />}
label="Fryser"
/>
</Tabs.List>
<Panel />
</Tabs>
</VStack>
);
export const Icon = () => (
<VStack gap="4">
<Tabs defaultValue="test2">
<Tabs.List style={{ margin: "0 auto" }}>
<Tabs.Tab value="test1" icon={<MugIcon title="Skap" />} />
<Tabs.Tab
value="test2"
icon={<DishwasherIcon title="Oppvaskmaskin" />}
/>
<Tabs.Tab value="test3" icon={<FreezerIcon title="Fryser" />} />
</Tabs.List>
<Panel />
</Tabs>
<Tabs defaultValue="test2" size="small" iconPosition="top">
<Tabs.List style={{ margin: "0 auto" }}>
<Tabs.Tab value="test1" icon={<MugIcon title="Skap" />} />
<Tabs.Tab
value="test2"
icon={<DishwasherIcon title="Oppvaskmaskin" />}
/>
<Tabs.Tab value="test3" icon={<FreezerIcon title="Fryser" />} />
</Tabs.List>
<Panel />
</Tabs>
</VStack>
);
export const Overflow = () => (
<Tabs defaultValue="test2" style={{ maxWidth: 300 }}>
<Tabs.List>
<Tabs.Tab value="test1" icon={<MugIcon aria-hidden />} label="Skap" />
<Tabs.Tab
value="test2"
label="Oppvaskmaskin"
icon={<DishwasherIcon aria-hidden />}
/>
<Tabs.Tab
value="test3"
icon={<FreezerIcon aria-hidden />}
label="Fryser"
/>
</Tabs.List>
<Panel />
</Tabs>
);
export const Fill = () => (
<Tabs defaultValue="test2" fill>
<Tabs.List>
<Tabs.Tab value="test1" icon={<MugIcon aria-hidden />} label="Skap" />
<Tabs.Tab
value="test2"
icon={<DishwasherIcon aria-hidden />}
label="Oppvaskmaskin"
/>
<Tabs.Tab
value="test3"
icon={<FreezerIcon aria-hidden />}
label="Fryser"
/>
</Tabs.List>
<Panel />
</Tabs>
);
Fill.parameters = {
layout: "fullscreen",
};
export const CustomIds = () => (
<Tabs defaultValue="test2">
<Tabs.List>
<Tabs.Tab
value="test1"
label="Skap"
id="custom-tabid-1"
aria-controls="custom-tabpanelid-1"
/>
<Tabs.Tab
value="test2"
label="Oppvaskmaskin"
id="custom-tabid-2"
aria-controls="custom-tabpanelid-2"
/>
</Tabs.List>
<Tabs.Panel
value="test1"
className="panel"
id="custom-tabpanelid-1"
aria-labelledby="custom-tabid-1"
>
Innholdspanel for Skap-tab
</Tabs.Panel>
<Tabs.Panel
value="test2"
className="panel"
id="custom-tabpanelid-2"
aria-labelledby="custom-tabid-2"
>
Innholdspanel for Oppvaskmaskin-tab
</Tabs.Panel>
</Tabs>
);
export const Chromatic = {
render: () => (
<VStack gap="6" align="center">
<div>
<h2>Small</h2>
<Small />
</div>
<div>
<h2>Controlled</h2>
<Controlled />
</div>
<div>
<h2>IconPosition</h2>
<IconPosition />
</div>
<div>
<h2>Icon</h2>
<Icon />
</div>
<div>
<h2>Overflow</h2>
<Overflow />
</div>
<div>
<h2>Fill</h2>
<div style={{ minWidth: 600 }}>
<Fill />
</div>
</div>
</VStack>
),
parameters: {
chromatic: { disable: false, delay: 300 },
},
};
+99 -37
View File
@@ -1,4 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { describe, expect, test } from "vitest";
import { Tabs } from "./Tabs";
@@ -39,72 +40,133 @@ const TestTabs = ({
describe("Tabs", () => {
test("sets default value correctly", () => {
render(<TestTabs defaultValue="tab2" />);
const tab = screen.getByTestId("tab2");
const panel = screen.getByTestId("tabpanel2");
const tab2 = screen.getByTestId("tab2");
const panel2 = screen.getByTestId("tabpanel2");
expect(tab).toHaveAttribute("aria-selected", "true");
expect(panel).toHaveTextContent("Tabpanel 2");
expect(tab2).toHaveAttribute("aria-selected", "true");
expect(panel2).toHaveTextContent("Tabpanel 2");
});
test("label-connection between tab and tabpanel is correct", async () => {
render(<TestTabs defaultValue="tab2" />);
const tab = screen.getByTestId("tab2");
const panel = screen.getByTestId("tabpanel2");
const tab2 = screen.getByTestId("tab2");
const panel2 = screen.getByTestId("tabpanel2");
const controlsId = tab.getAttribute("aria-controls");
const panelLabelledBy = panel.getAttribute("aria-labelledby");
const controlsId = tab2.getAttribute("aria-controls");
const panelLabelledBy = panel2.getAttribute("aria-labelledby");
expect(controlsId).toEqual(panel.id);
expect(tab.id).toEqual(panelLabelledBy);
expect(controlsId).toEqual(panel2.id);
expect(tab2.id).toEqual(panelLabelledBy);
});
test("sets correct attributes on active tab", () => {
render(<TestTabs defaultValue="tab2" />);
const tab = screen.getByTestId("tab2");
const tab2 = screen.getByTestId("tab2");
expect(tab).toHaveAttribute("aria-selected", "true");
expect(tab).toHaveAttribute("role", "tab");
expect(tab).toHaveAttribute("aria-controls");
expect(tab).toHaveAttribute("tabindex", "-1");
expect(tab2).toHaveAttribute("aria-selected", "true");
expect(tab2).toHaveAttribute("role", "tab");
expect(tab2).toHaveAttribute("aria-controls");
expect(tab2).toHaveAttribute("tabindex", "0");
});
test("sets correct attributes on idle tab", () => {
render(<TestTabs defaultValue="tab2" />);
const tab = screen.getByTestId("tab1");
const tab1 = screen.getByTestId("tab1");
expect(tab).toHaveAttribute("aria-selected", "false");
expect(tab).toHaveAttribute("role", "tab");
expect(tab).toHaveAttribute("aria-controls");
expect(tab).toHaveAttribute("tabindex", "-1");
expect(tab1).toHaveAttribute("aria-selected", "false");
expect(tab1).toHaveAttribute("role", "tab");
expect(tab1).toHaveAttribute("aria-controls");
expect(tab1).toHaveAttribute("tabindex", "-1");
});
test("sets correct attributes on active tabpanel", () => {
render(<TestTabs defaultValue="tab2" />);
const panel = screen.getByTestId("tabpanel2");
const panel2 = screen.getByTestId("tabpanel2");
expect(panel).toHaveAttribute("aria-labelledby");
expect(panel).toHaveAttribute("role", "tabpanel");
expect(panel).toHaveAttribute("tabindex", "0");
expect(panel).toHaveTextContent("Tabpanel 2");
expect(panel).toHaveStyle({ display: "block" });
expect(panel2).toHaveAttribute("aria-labelledby");
expect(panel2).toHaveAttribute("role", "tabpanel");
expect(panel2).toHaveAttribute("tabindex", "0");
expect(panel2).toHaveTextContent("Tabpanel 2");
expect(panel2).toHaveStyle({ display: "block" });
});
test("sets correct attributes on idle tabpanel", () => {
render(<TestTabs defaultValue="tab1" />);
const panel = screen.getByTestId("tabpanel2");
const panel2 = screen.getByTestId("tabpanel2");
expect(panel).toHaveAttribute("aria-labelledby");
expect(panel).toHaveAttribute("role", "tabpanel");
expect(panel).toHaveAttribute("tabindex", "0");
expect(panel).toBeEmptyDOMElement();
expect(panel).toHaveStyle({ display: "none" });
expect(panel2).toHaveAttribute("aria-labelledby");
expect(panel2).toHaveAttribute("role", "tabpanel");
expect(panel2).toHaveAttribute("tabindex", "0");
expect(panel2).toBeEmptyDOMElement();
expect(panel2).toHaveStyle({ display: "none" });
});
test("sets tabindex to 0 when focused", () => {
render(<TestTabs defaultValue="tab2" />);
const tab = screen.getByTestId("tab2");
render(<TestTabs defaultValue="tab1" />);
const tab2 = screen.getByTestId("tab2");
fireEvent.focus(tab);
expect(tab).toHaveAttribute("tabindex", "0");
fireEvent.focus(tab2);
expect(tab2).toHaveAttribute("tabindex", "0");
});
test("rowing tabindex keydown moves focus", () => {
render(<TestTabs defaultValue="tab1" />);
const tab1 = screen.getByTestId("tab1");
expect(tab1).toHaveAttribute("tabindex", "0");
fireEvent.keyDown(tab1, { key: "ArrowRight" });
expect(tab1).toHaveAttribute("tabindex", "-1");
expect(screen.getByTestId("tab2")).toHaveAttribute("tabindex", "0");
expect(screen.getByTestId("tab2")).toHaveAttribute(
"aria-selected",
"false",
);
});
test("selectionFollowsFocus moves active tabs", () => {
render(<TestTabs defaultValue="tab1" selectionFollowsFocus />);
const tab1 = screen.getByTestId("tab1");
expect(tab1).toHaveAttribute("tabindex", "0");
fireEvent.keyDown(tab1, { key: "ArrowRight" });
expect(screen.getByTestId("tab2")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("tab2")).toHaveAttribute("tabindex", "0");
});
test("tabbing from tabs moves focus to tabPanel", async () => {
render(<TestTabs defaultValue="tab1" />);
const tab1 = screen.getByTestId("tab1");
tab1.focus();
await userEvent.tab();
expect(screen.getByTestId("tabpanel1")).toHaveFocus();
});
test("shift+tab back to tablist should focus selected tab", async () => {
render(<TestTabs defaultValue="tab1" />);
const tab1 = screen.getByTestId("tab1");
/* Move focus to tab2 */
fireEvent.keyDown(tab1, { key: "ArrowRight" });
expect(screen.getByTestId("tab2")).toHaveFocus();
/* Move focus to tabPanel */
// eslint-disable-next-line testing-library/no-unnecessary-act
await act(async () => {
/* Tablist handles tabbing with setTimeout, so we need to use act */
await userEvent.tab();
});
expect(screen.getByTestId("tabpanel1")).toHaveFocus();
/* Move focus back to tablist, now tab1 should have focus */
// eslint-disable-next-line testing-library/no-unnecessary-act
await act(async () => {
await userEvent.tab({ shift: true });
});
expect(screen.getByTestId("tab1")).toHaveFocus();
});
});
+48 -70
View File
@@ -1,48 +1,15 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import cl from "clsx";
import React, { HTMLAttributes, forwardRef } from "react";
import { OverridableComponent } from "../util/types";
import Tab, { TabProps } from "./Tab";
import TabList, { TabListProps } from "./TabList";
import TabPanel, { TabPanelProps } from "./TabPanel";
import { TabsContext } from "./context";
export interface TabsProps
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "dir"> {
children: React.ReactNode;
/**
* Changes padding and font-size.
* @default "medium"
*/
size?: "medium" | "small";
/**
* onChange callback for selected Tab.
*/
onChange?: (value: string) => void;
/**
* Controlled selected value.
*/
value?: string;
/**
* If not controlled, a default-value needs to be set.
*/
defaultValue?: string;
/**
* Automatically activates tab on focus/navigation.
* @default false
*/
selectionFollowsFocus?: boolean;
/**
* Loops back to start when navigating past last item.
* @default false
*/
loop?: boolean;
/**
* Icon position in Tab.
* @default "left"
*/
iconPosition?: "left" | "top";
}
import React, { forwardRef } from "react";
import {
TabsDescendantsProvider,
TabsProvider,
useTabsDescendants,
} from "./Tabs.context";
import { TabsProps } from "./Tabs.types";
import Tab from "./parts/tab/Tab";
import TabList from "./parts/tablist/TabList";
import TabPanel from "./parts/tabpanel/TabPanel";
import { useTabs } from "./useTabs";
interface TabsComponent
extends React.ForwardRefExoticComponent<
@@ -52,19 +19,15 @@ interface TabsComponent
* @see 🏷️ {@link TabProps}
* @see [🤖 OverridableComponent](https://aksel.nav.no/grunnleggende/kode/overridablecomponent) support
*/
Tab: OverridableComponent<TabProps, HTMLButtonElement>;
Tab: typeof Tab;
/**
* @see 🏷️ {@link TabListProps}
*/
List: React.ForwardRefExoticComponent<
TabListProps & React.RefAttributes<HTMLDivElement>
>;
List: typeof TabList;
/**
* @see 🏷️ {@link TabPanelProps}
*/
Panel: React.ForwardRefExoticComponent<
TabPanelProps & React.RefAttributes<HTMLDivElement>
>;
Panel: typeof TabPanel;
}
/**
@@ -98,33 +61,48 @@ export const Tabs = forwardRef<HTMLDivElement, TabsProps>(
{
className,
children,
onChange,
size = "medium",
defaultValue = "",
value,
onChange,
id,
selectionFollowsFocus = false,
loop = false,
loop = true,
iconPosition = "left",
fill = false,
...rest
},
ref,
) => {
const descendants = useTabsDescendants();
const tabsContext = useTabs({ defaultValue, value, onChange, id });
/**
* TabsProvider handles memoization of context values, so we can safely skip it here.
*/
const context = {
...tabsContext,
selectionFollowsFocus,
loop,
size,
iconPosition,
fill,
};
return (
<RadixTabs.Root
{...rest}
ref={ref}
className={cl("navds-tabs", className, `navds-tabs--${size}`)}
activationMode={selectionFollowsFocus ? "automatic" : "manual"}
onValueChange={onChange}
>
<TabsContext.Provider
value={{
size,
loop,
iconPosition,
}}
>
{children}
</TabsContext.Provider>
</RadixTabs.Root>
<TabsDescendantsProvider value={descendants}>
<TabsProvider {...context}>
<div
ref={ref}
{...rest}
id={id}
className={cl("navds-tabs", className, `navds-tabs--${size}`)}
>
{children}
</div>
</TabsProvider>
</TabsDescendantsProvider>
);
},
) as TabsComponent;
+43
View File
@@ -0,0 +1,43 @@
import { HTMLAttributes } from "react";
export interface TabsProps
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "dir"> {
children: React.ReactNode;
/**
* Changes padding and font-size.
* @default "medium"
*/
size?: "medium" | "small";
/**
* onChange callback for selected Tab.
*/
onChange?: (value: string) => void;
/**
* Controlled selected value.
*/
value?: string;
/**
* If not controlled, a default-value needs to be set.
*/
defaultValue?: string;
/**
* Automatically activates tab on focus/navigation.
* @default false
*/
selectionFollowsFocus?: boolean;
/**
* Loops back to start when navigating past last item.
* @default true
*/
loop?: boolean;
/**
* Icon position in Tab.
* @default "left"
*/
iconPosition?: "left" | "top";
/**
* Stretches each tab to fill avaliable space in container.
* @default false
*/
fill?: boolean;
}
-9
View File
@@ -1,9 +0,0 @@
import { createContext } from "react";
interface TabsContextProps {
size: "medium" | "small";
loop: boolean;
iconPosition: "left" | "top";
}
export const TabsContext = createContext<TabsContextProps | null>(null);
+11 -4
View File
@@ -1,5 +1,12 @@
"use client";
export { default as TabsTab, type TabProps } from "./Tab";
export { default as TabsList, type TabListProps } from "./TabList";
export { default as TabsPanel, type TabPanelProps } from "./TabPanel";
export { default as Tabs, type TabsProps } from "./Tabs";
export { default as Tabs } from "./Tabs";
export { type TabsProps } from "./Tabs.types";
export { default as TabsTab, type TabProps } from "./parts/tab/Tab";
export {
default as TabsList,
type TabListProps,
} from "./parts/tablist/TabList";
export {
default as TabsPanel,
type TabPanelProps,
} from "./parts/tabpanel/TabPanel";
@@ -0,0 +1,93 @@
import cl from "clsx";
import React, { forwardRef } from "react";
import { BodyShort } from "../../../typography";
import { OverridableComponent } from "../../../util";
import { useTabsContext } from "../../Tabs.context";
import { useTab } from "./useTab";
export interface TabProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
/**
* Tab label.
*/
label?: React.ReactNode;
/**
* Tab Icon.
*/
icon?: React.ReactNode;
/**
* Value for state-handling.
*/
value: string;
/**
* Overrides auto-generated id.
*
* **Warning**: Tab generates an id if not provided. If you need to override it,
* make sure to also include the correct `aria-controls` id for the TabPanel it controls.
*/
id?: string;
}
export const Tab: OverridableComponent<TabProps, HTMLButtonElement> =
forwardRef(
(
{
className,
as: Component = "button",
label,
icon,
value,
onClick,
onFocus,
disabled,
id,
...rest
},
ref: React.ForwardedRef<HTMLButtonElement>,
) => {
const tabCtx = useTab({ value, onClick, onFocus, disabled }, ref);
const ctx = useTabsContext();
if (!label && !icon) {
console.error("<Tabs.Tab/> needs label and/or icon");
return null;
}
return (
<Component
ref={tabCtx.ref}
{...rest}
className={cl(
"navds-tabs__tab",
`navds-tabs__tab--${ctx?.size ?? "medium"}`,
`navds-tabs__tab-icon--${ctx?.iconPosition}`,
className,
{
"navds-tabs__tab--icon-only": icon && !label,
"navds-tabs__tab--fill": ctx.fill,
},
)}
role="tab"
type="button"
aria-selected={tabCtx.isSelected}
data-state={tabCtx.isSelected ? "active" : "inactive"}
tabIndex={tabCtx.isFocused ? 0 : -1}
aria-controls={rest["aria-controls"] ?? tabCtx.controlsId}
id={id ?? tabCtx.id}
onFocus={tabCtx.onFocus}
onClick={tabCtx.onClick}
>
<BodyShort
as="span"
className="navds-tabs__tab-inner"
size={ctx?.size}
>
<span aria-hidden={!!label}>{icon}</span>
<span>{label}</span>
</BodyShort>
</Component>
);
},
);
export default Tab;
@@ -0,0 +1,52 @@
import { composeEventHandlers } from "../../../util/composeEventHandlers";
import { mergeRefs } from "../../../util/hooks/useMergeRefs";
import { useTabsContext, useTabsDescendant } from "../../Tabs.context";
export interface UseTabProps {
/**
* If `true`, the `Tab` won't be toggleable
* @default false
*/
disabled?: boolean;
onClick?: React.MouseEventHandler;
onFocus?: React.FocusEventHandler;
value: string;
}
export function useTab<P extends UseTabProps>(
{ value, disabled = false, onFocus: _onFocus, onClick }: P,
ref: React.ForwardedRef<HTMLButtonElement>,
) {
const {
id,
setSelectedValue,
selectionFollowsFocus,
focusedValue,
setFocusedValue,
selectedValue,
makeTabId,
makeTabPanelId,
} = useTabsContext();
const { register } = useTabsDescendant({
disabled,
value,
});
const isSelected = value === selectedValue;
const onFocus = () => {
setFocusedValue(value);
selectionFollowsFocus && setSelectedValue(value);
};
return {
ref: mergeRefs([register, ref]),
isSelected,
isFocused: focusedValue === value,
id: makeTabId(id, value),
controlsId: makeTabPanelId(id, value),
onClick: composeEventHandlers(onClick, () => setSelectedValue(value)),
onFocus: disabled ? undefined : composeEventHandlers(_onFocus, onFocus),
};
}
@@ -0,0 +1,29 @@
import cl from "clsx";
import React from "react";
import { ChevronLeftIcon, ChevronRightIcon } from "@navikt/aksel-icons";
interface ScrollButtonProps {
hidden: boolean;
onClick: () => void;
dir: "left" | "right";
}
function ScrollButton({ hidden, onClick, dir }: ScrollButtonProps) {
return (
<div
className={cl("navds-tabs__scroll-button", {
"navds-tabs__scroll-button--hidden": hidden,
})}
onClick={onClick}
aria-hidden
>
{dir === "left" ? (
<ChevronLeftIcon title="scroll tilbake" />
) : (
<ChevronRightIcon title="scroll neste" />
)}
</div>
);
}
export default ScrollButton;
@@ -0,0 +1,56 @@
/* eslint-disable jsx-a11y/interactive-supports-focus */
import cl from "clsx";
import React, { forwardRef, useRef } from "react";
import { composeEventHandlers } from "../../../util/composeEventHandlers";
import { useMergeRefs } from "../../../util/hooks/useMergeRefs";
import ScrollButton from "./ScrollButtons";
import { useScrollButtons } from "./useScrollButtons";
import { useTabList } from "./useTabList";
export interface TabListProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* <Tabs.Tab /> elements.
*/
children: React.ReactNode;
}
export const TabList = forwardRef<HTMLDivElement, TabListProps>(
({ className, onKeyDown, ...rest }, ref) => {
const { onKeyDown: _onKeyDown } = useTabList();
const listRef = useRef<HTMLDivElement>(null);
const mergedRef = useMergeRefs(listRef, ref);
const scrollCtx = useScrollButtons(listRef);
return (
<div className="navds-tabs__tablist-wrapper">
{scrollCtx.show && (
<ScrollButton
dir="left"
hidden={!scrollCtx.start}
onClick={scrollCtx.scrollLeft}
/>
)}
<div
ref={mergedRef}
{...rest}
onScroll={scrollCtx.update}
className={cl("navds-tabs__tablist", className)}
role="tablist"
aria-orientation="horizontal"
onKeyDown={composeEventHandlers(onKeyDown, _onKeyDown)}
/>
{scrollCtx.show && (
<ScrollButton
dir="right"
hidden={!scrollCtx.end}
onClick={scrollCtx.scrollRight}
/>
)}
</div>
);
},
);
export default TabList;
@@ -0,0 +1,69 @@
import { useEffect, useMemo, useState } from "react";
import { debounce } from "../../../util";
export function useScrollButtons(listRef: React.RefObject<HTMLDivElement>) {
const [displayScroll, setDisplayScroll] = useState({
start: false,
end: false,
});
const updateScrollButtonState = useMemo(
() =>
debounce(() => {
if (!listRef?.current) return;
const { scrollWidth, clientWidth } = listRef.current;
const scrollLeft = listRef.current.scrollLeft;
// use 1 for the potential rounding error with browser zooms.
const showStartScroll = scrollLeft > 1;
const showEndScroll = scrollLeft < scrollWidth - clientWidth - 1;
setDisplayScroll((oldDisplayScroll) =>
showStartScroll === oldDisplayScroll.start &&
showEndScroll === oldDisplayScroll.end
? oldDisplayScroll
: { start: showStartScroll, end: showEndScroll },
);
}),
[listRef],
);
useEffect(() => {
const handleResize = () => updateScrollButtonState();
const win = listRef.current?.ownerDocument ?? document ?? window;
win.addEventListener("resize", handleResize);
let resizeObserver;
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(listRef.current);
}
return () => {
win.removeEventListener("resize", handleResize);
resizeObserver && resizeObserver.disconnect();
updateScrollButtonState.clear();
};
}, [listRef, updateScrollButtonState]);
useEffect(() => {
updateScrollButtonState();
});
return {
update: updateScrollButtonState,
start: displayScroll.start,
end: displayScroll.end,
show: displayScroll.end || displayScroll.start,
scrollLeft: () => {
if (listRef.current) {
listRef.current.scrollLeft -= 100;
}
},
scrollRight: () => {
if (listRef.current) {
listRef.current.scrollLeft += 100;
}
},
};
}
@@ -0,0 +1,68 @@
import { useCallback } from "react";
import { useTabsContext, useTabsDescendantsContext } from "../../Tabs.context";
/**
* TabList hook to manage multiple tab buttons,
* and ensures only one tab is selected at a time.
*/
export function useTabList() {
const { focusedValue, loop, selectedValue, setFocusedValue } =
useTabsContext();
const descendants = useTabsDescendantsContext();
/**
* Implements rowing-tabindex for horizontal tabs
*/
const onKeyDown = useCallback(
(event: React.KeyboardEvent) => {
/**
* Tabs.Tab is registered with its prop 'value'.
* We can then use it to find the current focuses descendant
*/
const idx = descendants
.values()
.findIndex((x) => x.value === focusedValue);
const nextTab = () => {
const next = descendants.nextEnabled(idx, loop);
next && next.node?.focus();
};
const prevTab = () => {
const prev = descendants.prevEnabled(idx, loop);
prev && prev.node?.focus();
};
const firstTab = () => {
const first = descendants.firstEnabled();
first && first.node?.focus();
};
const lastTab = () => {
const last = descendants.lastEnabled();
last && last.node?.focus();
};
const keyMap: Record<string, React.KeyboardEventHandler> = {
ArrowLeft: prevTab,
ArrowRight: nextTab,
Home: firstTab,
End: lastTab,
};
const action = keyMap[event.key];
if (action) {
event.preventDefault();
action(event);
} else if (event.key === "Tab") {
/**
* Imperative focus during keydown is risky so we prevent React's batching updates
* to avoid potential bugs. See: https://github.com/facebook/react/issues/20332
*/
selectedValue && setTimeout(() => setFocusedValue(selectedValue));
}
},
[descendants, focusedValue, loop, selectedValue, setFocusedValue],
);
return { onKeyDown };
}
@@ -0,0 +1,50 @@
import cl from "clsx";
import React, { forwardRef } from "react";
import { useTabPanel } from "./useTabPanel";
export interface TabPanelProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Tab panel content.
*/
children: React.ReactNode;
/**
* Value for state-handling.
*/
value: string;
/**
* If true, will only render children when selected.
* @default true
*/
lazy?: boolean;
/**
* Overrides auto-generated id.
*
* **Warning**: TabPanel generates an id if not provided. If you need to override it,
* make sure to also include the correct `aria-labelledby` id for the Tab that labels it.
*/
id?: string;
}
const TabPanel = forwardRef<HTMLDivElement, TabPanelProps>(
({ className, value, children, lazy = true, id, ...rest }, ref) => {
const ctx = useTabPanel({ value });
return (
<div
ref={ref}
{...rest}
className={cl("navds-tabs__tabpanel", className)}
role="tabpanel"
tabIndex={0}
aria-labelledby={rest["aria-labelledby"] ?? ctx.labelledbyId}
id={id ?? ctx.id}
hidden={ctx.hidden}
data-state={!ctx.hidden ? "active" : "inactive"}
>
{lazy && ctx.hidden ? null : children}
</div>
);
},
);
export default TabPanel;
@@ -0,0 +1,18 @@
import { useTabsContext } from "../../Tabs.context";
type TabPanelProps = {
value: string;
};
/**
* Tabs hook for managing the visible/hidden state of Tabs.Panel
*/
export function useTabPanel({ value }: TabPanelProps) {
const { id, selectedValue, makeTabId, makeTabPanelId } = useTabsContext();
return {
labelledbyId: makeTabId(id, value),
hidden: selectedValue !== value,
id: makeTabPanelId(id, value),
};
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useState } from "react";
import { useId } from "../util";
import { useControllableState } from "../util/hooks/useControllableState";
import { TabsProps } from "./Tabs.types";
export function useTabs({
onChange,
value,
defaultValue = "",
id,
}: Pick<TabsProps, "onChange" | "value" | "defaultValue" | "id">) {
const [focusedValue, setFocusedValue] = useState(defaultValue);
const [selectedValue, setSelectedValue] = useControllableState({
defaultValue,
value,
onChange,
});
/**
* Sync focused `value` with controlled `selectedValue`
*/
useEffect(() => {
if (value != null) {
setFocusedValue(value);
}
}, [value]);
/**
* Scope ids for better tracking
*/
const uuid = useId();
return {
id: `tabs-${id ?? uuid}`,
selectedValue,
setSelectedValue,
focusedValue,
setFocusedValue,
makeTabId,
makeTabPanelId,
};
}
function makeTabId(id: string, value: string) {
return `${id}--tab-${value}`;
}
function makeTabPanelId(id: string, value: string) {
return `${id}--tabpanel-${value}`;
}
@@ -0,0 +1,31 @@
import { createContext as ReactCreateContext } from "react";
import { createContext } from "../util/create-context";
import { createDescendantContext } from "../util/hooks/descendants/useDescendant";
import { ToggleGroupProps } from "./ToggleGroup.types";
import { useToggleGroup } from "./useToggleGroup";
interface ToggleContextProps {
size: "medium" | "small";
}
export const ToggleGroupContext = ReactCreateContext<ToggleContextProps | null>(
null,
);
export const [
ToggleGroupDescendantsProvider,
useToggleGroupDescendantsContext,
useToggleGroupDescendants,
useToggleGroupDescendant,
] = createDescendantContext<HTMLButtonElement, { value: string }>();
type ToggleGroupProviderProps = ReturnType<typeof useToggleGroup> &
Pick<ToggleGroupProps, "size">;
/* State context */
export const [ToggleGroupProvider, useToggleGroupContext] =
createContext<ToggleGroupProviderProps>({
name: "ToggleGroupContext",
hookName: "useToggleGroupContext",
providerName: "ToggleGroupProvider",
});
@@ -5,6 +5,7 @@ import {
EnvelopeOpenIcon,
InboxUpIcon,
} from "@navikt/aksel-icons";
import { VStack } from "../layout/stack";
import ToggleGroup from "./ToggleGroup";
const meta: Meta<typeof ToggleGroup> = {
@@ -22,7 +23,11 @@ const meta: Meta<typeof ToggleGroup> = {
control: { type: "radio" },
},
},
parameters: {
chromatic: { disable: true },
},
};
export default meta;
const Items = (icon?: boolean, both?: boolean) => (
@@ -71,6 +76,7 @@ export const Default = (props) => {
</ToggleGroup>
);
};
Default.args = {
icon: true,
text: true,
@@ -81,7 +87,7 @@ export const Compositions = () => {
const [activeValue, setActiveValue] = useState("ulest");
return (
<div className="colgap">
<VStack gap="6">
<ToggleGroup value={activeValue} onChange={setActiveValue}>
{Items()}
</ToggleGroup>
@@ -91,7 +97,10 @@ export const Compositions = () => {
<ToggleGroup value={activeValue} onChange={setActiveValue}>
{Items(true)}
</ToggleGroup>
</div>
<ToggleGroup fill value={activeValue} onChange={setActiveValue}>
{Items(true)}
</ToggleGroup>
</VStack>
);
};
@@ -99,7 +108,7 @@ export const Variants = () => {
const [activeValue, setActiveValue] = useState("ulest");
return (
<div className="colgap">
<VStack gap="6">
<ToggleGroup
variant="action"
value={activeValue}
@@ -114,7 +123,7 @@ export const Variants = () => {
>
{Items(true, true)}
</ToggleGroup>
</div>
</VStack>
);
};
@@ -122,7 +131,7 @@ export const Small = () => {
const [activeValue, setActiveValue] = useState("ulest");
return (
<div className="colgap">
<VStack gap="6">
<ToggleGroup size="small" value={activeValue} onChange={setActiveValue}>
{Items()}
</ToggleGroup>
@@ -132,6 +141,58 @@ export const Small = () => {
<ToggleGroup size="small" value={activeValue} onChange={setActiveValue}>
{Items(true)}
</ToggleGroup>
</div>
</VStack>
);
};
export const Chromatic = {
render: () => (
<VStack gap="6">
<div>
<h2>Text</h2>
<ToggleGroup value="ulest" onChange={console.log}>
{Items()}
</ToggleGroup>
</div>
<div>
<h2>Icon</h2>
<ToggleGroup value="ulest" onChange={console.log}>
{Items(true)}
</ToggleGroup>
</div>
<div>
<h2>Text + icon</h2>
<ToggleGroup value="ulest" onChange={console.log}>
{Items(true, true)}
</ToggleGroup>
</div>
<div style={{ minWidth: 600 }}>
<h2>Fill</h2>
<ToggleGroup value="ulest" onChange={console.log} fill>
{Items(true, true)}
</ToggleGroup>
</div>
<div>
<h2>Small</h2>
<ToggleGroup value="ulest" onChange={console.log} size="small">
{Items(true, true)}
</ToggleGroup>
</div>
<div>
<h2>Small + fill</h2>
<ToggleGroup value="ulest" onChange={console.log} size="small" fill>
{Items(true, true)}
</ToggleGroup>
</div>
<div>
<h2>Neutral</h2>
<ToggleGroup value="ulest" onChange={console.log} variant="neutral">
{Items(true, true)}
</ToggleGroup>
</div>
</VStack>
),
parameters: {
chromatic: { disable: false },
},
};
@@ -1,4 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { describe, expect, test } from "vitest";
import { ToggleGroup } from "./ToggleGroup";
@@ -20,36 +21,76 @@ const TestToggleGroup = ({ value, onChange, defaultValue }: any) => (
describe("ToggleGroup", () => {
test("sets default value correctly", () => {
render(<TestToggleGroup defaultValue="toggle2" />);
const toggle = screen.getByTestId("toggle2");
const toggle2 = screen.getByTestId("toggle2");
expect(toggle).toHaveAttribute("aria-checked", "true");
expect(toggle2).toHaveAttribute("aria-checked", "true");
});
test("sets correct attributes on active toggle", () => {
render(<TestToggleGroup defaultValue="toggle2" />);
const toggle = screen.getByTestId("toggle2");
const toggle2 = screen.getByTestId("toggle2");
expect(toggle).toHaveAttribute("aria-checked", "true");
expect(toggle).toHaveAttribute("role", "radio");
expect(toggle).toHaveAttribute("type", "button");
expect(toggle).toHaveAttribute("tabindex", "-1");
expect(toggle2).toHaveAttribute("aria-checked", "true");
expect(toggle2).toHaveAttribute("role", "radio");
expect(toggle2).toHaveAttribute("type", "button");
expect(toggle2).toHaveAttribute("tabindex", "0");
});
test("sets correct attributes on idle toggle", () => {
render(<TestToggleGroup defaultValue="toggle1" />);
const toggle = screen.getByTestId("toggle2");
const toggle2 = screen.getByTestId("toggle2");
expect(toggle).toHaveAttribute("aria-checked", "false");
expect(toggle).toHaveAttribute("role", "radio");
expect(toggle).toHaveAttribute("type", "button");
expect(toggle).toHaveAttribute("tabindex", "-1");
expect(toggle2).toHaveAttribute("aria-checked", "false");
expect(toggle2).toHaveAttribute("role", "radio");
expect(toggle2).toHaveAttribute("type", "button");
expect(toggle2).toHaveAttribute("tabindex", "-1");
});
test("sets tabindex to 0 when focused", () => {
render(<TestToggleGroup defaultValue="toggle2" />);
const toggle = screen.getByTestId("toggle2");
const toggle2 = screen.getByTestId("toggle2");
fireEvent.focus(toggle);
expect(toggle).toHaveAttribute("tabindex", "0");
fireEvent.focus(toggle2);
expect(toggle2).toHaveAttribute("tabindex", "0");
});
test("roving tabindex keydown moves focus", () => {
render(<TestToggleGroup defaultValue="toggle1" />);
const toggle1 = screen.getByTestId("toggle1");
expect(toggle1).toHaveAttribute("tabindex", "0");
fireEvent.keyDown(toggle1, { key: "ArrowRight" });
expect(toggle1).toHaveAttribute("tabindex", "-1");
expect(screen.getByTestId("toggle2")).toHaveAttribute("tabindex", "0");
expect(screen.getByTestId("toggle2")).toHaveAttribute(
"aria-checked",
"false",
);
});
test("Space selects focused toggle-item", async () => {
render(<TestToggleGroup defaultValue="toggle1" />);
const toggle1 = screen.getByTestId("toggle1");
expect(toggle1).toHaveAttribute("tabindex", "0");
fireEvent.keyDown(toggle1, { key: "ArrowRight" });
expect(toggle1).toHaveAttribute("tabindex", "-1");
expect(screen.getByTestId("toggle2")).toHaveAttribute("tabindex", "0");
expect(screen.getByTestId("toggle2")).toHaveAttribute(
"aria-checked",
"false",
);
// eslint-disable-next-line testing-library/no-unnecessary-act
await act(async () => {
await userEvent.keyboard(" ");
});
expect(screen.getByTestId("toggle2")).toHaveAttribute(
"aria-checked",
"true",
);
});
});
@@ -1,55 +1,24 @@
import * as RadixToggleGroup from "@radix-ui/react-toggle-group";
import cl from "clsx";
import React, { HTMLAttributes, forwardRef, useState } from "react";
import React, { forwardRef } from "react";
import { Label } from "../typography";
import { useId } from "../util/hooks";
import ToggleItem, { ToggleGroupItemProps } from "./ToggleItem";
import { ToggleGroupContext } from "./context";
export interface ToggleGroupProps
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "dir"> {
/**
* Toggles.Item elements
*/
children: React.ReactNode;
/**
* Changes padding and font-size
* @default "medium"
*/
size?: "medium" | "small";
/**
* Controlled selected value
*/
value?: string;
/**
* If not controlled, a default-value needs to be set
*/
defaultValue?: string;
/**
* Callback for selected toggle
*/
onChange: (value: string) => void;
/**
* Label describing ToggleGroup
*/
label?: React.ReactNode;
/**
* Changes design and interaction-visuals
* @default "action"
*/
variant?: "action" | "neutral";
}
import { useId } from "../util";
import {
ToggleGroupDescendantsProvider,
ToggleGroupProvider,
useToggleGroupDescendants,
} from "./ToggleGroup.context";
import { ToggleGroupProps } from "./ToggleGroup.types";
import ToggleItem from "./parts/ToggleItem";
import { useToggleGroup } from "./useToggleGroup";
interface ToggleGroupComponent
extends React.ForwardRefExoticComponent<
ToggleGroupProps & React.RefAttributes<HTMLDivElement>
> {
/**
* @see 🏷️ {@link ToggleGroupItemProps}
* @see 🏷️ {@link ToggleItem}
*/
Item: React.ForwardRefExoticComponent<
ToggleGroupItemProps & React.RefAttributes<HTMLButtonElement>
>;
Item: typeof ToggleItem;
}
/**
@@ -77,70 +46,74 @@ export const ToggleGroup = forwardRef<HTMLDivElement, ToggleGroupProps>(
label,
value,
defaultValue,
"aria-describedby": desc,
"aria-describedby": userDescribedby,
variant = "action",
fill = false,
...rest
},
ref,
) => {
const [groupValue, setGroupValue] = useState(defaultValue);
const labelId = useId();
const descendants = useToggleGroupDescendants();
const handleValueChange = (v: string) => {
if (v !== "") {
setGroupValue(v);
onChange?.(v);
}
const toggleGroupContext = useToggleGroup({
defaultValue,
value,
onChange,
});
/**
* ToggleGroupProvider handles memoization.
*/
const context = {
...toggleGroupContext,
size,
};
if (!value && !defaultValue) {
console.error("ToggleGroup without value/defaultvalue is not allowed");
}
const labelId = useId();
const describeBy = cl({
[desc ?? ""]: !!desc,
[labelId ?? ""]: !!label,
});
if (!value && !defaultValue) {
console.error("ToggleGroup without value or defaultvalue is not allowed");
}
if (!value && !defaultValue) {
console.error("ToggleGroup needs either a value or defaultValue");
}
return (
<ToggleGroupContext.Provider
value={{
size,
}}
>
<div className={cl("navds-toggle-group__wrapper", className)}>
{label && (
<Label
size={size}
className="navds-toggle-group__label"
id={labelId}
>
{label}
</Label>
)}
<RadixToggleGroup.Root
{...rest}
onValueChange={handleValueChange}
value={value ?? groupValue}
defaultValue={defaultValue}
ref={ref}
className={cl(
"navds-toggle-group",
`navds-toggle-group--${size}`,
`navds-toggle-group--${variant}`,
)}
{...(describeBy && { "aria-describedby": describeBy })}
role="radiogroup"
type="single"
<ToggleGroupDescendantsProvider value={descendants}>
<ToggleGroupProvider {...context}>
<div
className={cl("navds-toggle-group__wrapper", className, {
"navds-toggle-group__wrapper--fill": fill,
})}
>
{children}
</RadixToggleGroup.Root>
</div>
</ToggleGroupContext.Provider>
{label && (
<Label
size={size}
className="navds-toggle-group__label"
id={labelId}
>
{label}
</Label>
)}
<div
{...rest}
ref={ref}
className={cl(
"navds-toggle-group",
`navds-toggle-group--${size}`,
`navds-toggle-group--${variant}`,
)}
aria-describedby={
cl(userDescribedby, !!label && labelId) || undefined
}
role="radiogroup"
>
{children}
</div>
</div>
</ToggleGroupProvider>
</ToggleGroupDescendantsProvider>
);
},
) as ToggleGroupComponent;
@@ -0,0 +1,40 @@
import { HTMLAttributes } from "react";
export interface ToggleGroupProps
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "dir"> {
/**
* Toggles.Item elements.
*/
children: React.ReactNode;
/**
* Changes padding and font-size.
* @default "medium"
*/
size?: "medium" | "small";
/**
* Controlled selected value.
*/
value?: string;
/**
* If not controlled, a default-value needs to be set.
*/
defaultValue?: string;
/**
* Callback for selected toggle.
*/
onChange: (value: string) => void;
/**
* Label describing ToggleGroup.
*/
label?: React.ReactNode;
/**
* Changes design and interaction-visuals.
* @default "action"
*/
variant?: "action" | "neutral";
/**
* Stretch each button to fill avaliable space in container.
* @default false
*/
fill?: boolean;
}
@@ -1,41 +0,0 @@
import * as RadixToggleGroup from "@radix-ui/react-toggle-group";
import cl from "clsx";
import React, { forwardRef, useContext } from "react";
import { BodyShort } from "../typography/BodyShort";
import { ToggleGroupContext } from "./context";
export interface ToggleGroupItemProps
extends React.HTMLAttributes<HTMLButtonElement> {
/**
* Content
*/
children: React.ReactNode;
/**
* Value for state-handling
*/
value: string;
}
const ToggleItem = forwardRef<HTMLButtonElement, ToggleGroupItemProps>(
({ className, children, ...rest }, ref) => {
const context = useContext(ToggleGroupContext);
return (
<RadixToggleGroup.Item
{...rest}
ref={ref}
className={cl("navds-toggle-group__button", className)}
>
<BodyShort
as="span"
className="navds-toggle-group__button-inner"
size={context?.size}
>
{children}
</BodyShort>
</RadixToggleGroup.Item>
);
},
);
export default ToggleItem;
@@ -1,9 +0,0 @@
import { createContext } from "react";
interface ToggleContextProps {
size: "medium" | "small";
}
export const ToggleGroupContext = createContext<ToggleContextProps | null>(
null,
);
+3 -2
View File
@@ -1,6 +1,7 @@
"use client";
export { default as ToggleGroup, type ToggleGroupProps } from "./ToggleGroup";
export { default as ToggleGroup } from "./ToggleGroup";
export { type ToggleGroupProps } from "./ToggleGroup.types";
export {
default as ToggleGroupItem,
type ToggleGroupItemProps,
} from "./ToggleItem";
} from "./parts/ToggleItem";
@@ -0,0 +1,55 @@
import cl from "clsx";
import React, { forwardRef } from "react";
import { BodyShort } from "../../typography/BodyShort";
import { useToggleGroupContext } from "../ToggleGroup.context";
import { useToggleItem } from "./useToggleItem";
export interface ToggleGroupItemProps
extends React.HTMLAttributes<HTMLButtonElement> {
/**
* Content.
*/
children: React.ReactNode;
/**
* Value for state-handling.
*/
value: string;
}
const ToggleItem = forwardRef<HTMLButtonElement, ToggleGroupItemProps>(
(
{ className, children, value, onClick, onFocus, onKeyDown, ...rest },
forwardedRef,
) => {
const itemCtx = useToggleItem(
{ value, onClick, onFocus, disabled: false, onKeyDown },
forwardedRef,
);
const ctx = useToggleGroupContext();
return (
<button
{...rest}
ref={itemCtx.ref}
className={cl("navds-toggle-group__button", className)}
type="button"
role="radio"
aria-checked={itemCtx.isSelected}
tabIndex={itemCtx.isFocused ? 0 : -1}
onClick={itemCtx.onClick}
onFocus={itemCtx.onFocus}
onKeyDown={itemCtx.onKeyDown}
>
<BodyShort
as="span"
className="navds-toggle-group__button-inner"
size={ctx?.size}
>
{children}
</BodyShort>
</button>
);
},
);
export default ToggleItem;
@@ -0,0 +1,104 @@
import { useCallback } from "react";
import { composeEventHandlers } from "../../util/composeEventHandlers";
import { mergeRefs } from "../../util/hooks/useMergeRefs";
import {
useToggleGroupContext,
useToggleGroupDescendant,
} from "../ToggleGroup.context";
export interface UseToggleItemProps {
/**
* If `true`, the `ToggleItem` won't be toggleable
* @default false
*/
disabled?: boolean;
onClick?: React.MouseEventHandler;
onFocus?: React.FocusEventHandler;
onKeyDown?: React.KeyboardEventHandler;
value: string;
}
export function useToggleItem<P extends UseToggleItemProps>(
{
value,
disabled = false,
onFocus: _onFocus,
onClick,
onKeyDown: _onKeyDown,
}: P,
ref: React.ForwardedRef<HTMLButtonElement>,
) {
const { setSelectedValue, setFocusedValue, selectedValue, focusedValue } =
useToggleGroupContext();
const { register, descendants } = useToggleGroupDescendant({
disabled,
value,
});
const isSelected = value === selectedValue;
const onFocus = () => setFocusedValue(value);
/**
* Implements roving-tabindex for horizontal tabs
*/
const onKeyDown = useCallback(
(event: React.KeyboardEvent) => {
/**
* ToggleGroup.Item is registered with its prop 'value'.
* We can then use it to find the current focuses descendant
*/
const idx = descendants
.values()
.findIndex((x) => x.value === focusedValue);
const nextTab = () => {
const next = descendants.nextEnabled(idx, false);
next && next.node?.focus();
};
const prevTab = () => {
const prev = descendants.prevEnabled(idx, false);
prev && prev.node?.focus();
};
const firstTab = () => {
const first = descendants.firstEnabled();
first && first.node?.focus();
};
const lastTab = () => {
const last = descendants.lastEnabled();
last && last.node?.focus();
};
const keyMap: Record<string, React.KeyboardEventHandler> = {
ArrowLeft: prevTab,
ArrowRight: nextTab,
Home: firstTab,
End: lastTab,
};
const action = keyMap[event.key];
if (action) {
event.preventDefault();
action(event);
} else if (event.key === "Tab") {
/**
* Imperative focus during keydown is risky so we prevent React's batching updates
* to avoid potential bugs. See: https://github.com/facebook/react/issues/20332
*/
selectedValue && setTimeout(() => setFocusedValue(selectedValue));
}
},
[descendants, focusedValue, selectedValue, setFocusedValue],
);
return {
ref: mergeRefs([register, ref]),
isSelected,
isFocused: focusedValue === value,
onClick: composeEventHandlers(onClick, () => setSelectedValue(value)),
onFocus: disabled ? undefined : composeEventHandlers(_onFocus, onFocus),
onKeyDown: composeEventHandlers(_onKeyDown, onKeyDown),
};
}
@@ -0,0 +1,33 @@
import { useEffect, useState } from "react";
import { useControllableState } from "../util/hooks/useControllableState";
import { ToggleGroupProps } from "./ToggleGroup.types";
export function useToggleGroup({
onChange,
value,
defaultValue = "",
}: Pick<ToggleGroupProps, "onChange" | "value" | "defaultValue">) {
const [focusedValue, setFocusedValue] = useState(defaultValue);
const [selectedValue, setSelectedValue] = useControllableState({
defaultValue,
value,
onChange,
});
/**
* Sync focused `value` with controlled `selectedValue`
*/
useEffect(() => {
if (value != null) {
setFocusedValue(value);
}
}, [value]);
return {
selectedValue,
setSelectedValue,
focusedValue,
setFocusedValue,
};
}
@@ -4,18 +4,17 @@ import React from "react";
import { VStack } from "../../layout/stack";
import BodyLong from "../BodyLong";
const meta = {
const meta: Meta<typeof BodyLong> = {
title: "ds-react/Typography/BodyLong",
component: BodyLong,
decorators: [(story) => <div style={{ maxWidth: "700px" }}>{story()}</div>],
parameters: {
chromatic: { disable: true },
},
} satisfies Meta<typeof BodyLong>;
};
export default meta;
type Story = StoryObj<typeof meta>;
type Story = StoryObj<typeof BodyLong>;
const lorem =
"Hvis du ikke bor sammen med begge foreldrene dine, kan du ha rett til barnebidrag fra en eller begge foreldre mens du fullfører videregående skole eller tilsvarende.";
@@ -4,18 +4,17 @@ import React from "react";
import { VStack } from "../../layout/stack";
import { BodyShort } from "../BodyShort";
const meta = {
const meta: Meta<typeof BodyShort> = {
title: "ds-react/Typography/BodyShort",
component: BodyShort,
decorators: [(story) => <div style={{ maxWidth: "250px" }}>{story()}</div>],
parameters: {
chromatic: { disable: true },
},
} satisfies Meta<typeof BodyShort>;
};
export default meta;
type Story = StoryObj<typeof meta>;
type Story = StoryObj<typeof BodyShort>;
const lorem = "Du må gjøre en filtrering for å se brukere i listen.";
@@ -4,18 +4,17 @@ import React from "react";
import { VStack } from "../../layout/stack";
import Detail from "../Detail";
const meta = {
const meta: Meta<typeof Detail> = {
title: "ds-react/Typography/Detail",
component: Detail,
decorators: [(story) => <div style={{ maxWidth: "200px" }}>{story()}</div>],
parameters: {
chromatic: { disable: true },
},
} satisfies Meta<typeof Detail>;
};
export default meta;
type Story = StoryObj<typeof meta>;
type Story = StoryObj<typeof Detail>;
const lorem = "Du må gjøre en filtrering for å se brukere i listen.";
@@ -4,17 +4,16 @@ import React from "react";
import { VStack } from "../../layout/stack";
import ErrorMessage from "../ErrorMessage";
const meta = {
const meta: Meta<typeof ErrorMessage> = {
title: "ds-react/Typography/ErrorMessage",
component: ErrorMessage,
parameters: {
chromatic: { disable: true },
},
} satisfies Meta<typeof ErrorMessage>;
};
export default meta;
type Story = StoryObj<typeof meta>;
type Story = StoryObj<typeof ErrorMessage>;
const lorem =
"Du må fylle ut: Oppgi årsaken til at du har ventet mer enn 6 måneder med å søke om refusjon";
@@ -4,18 +4,17 @@ import { default as React } from "react";
import { VStack } from "../../layout/stack";
import Heading from "../Heading";
const meta = {
const meta: Meta<typeof Heading> = {
title: "ds-react/Typography/Heading",
component: Heading,
decorators: [(story) => <div style={{ maxWidth: "300px" }}>{story()}</div>],
parameters: {
chromatic: { disable: true },
},
} satisfies Meta<typeof Heading>;
};
export default meta;
type Story = StoryObj<typeof meta>;
type Story = StoryObj<typeof Heading>;
const lorem = "Hva kan vi hjelpe deg med?";
@@ -4,18 +4,17 @@ import React from "react";
import { VStack } from "../../layout/stack";
import Label from "../Label";
const meta = {
const meta: Meta<typeof Label> = {
title: "ds-react/Typography/Label",
component: Label,
decorators: [(story) => <div style={{ maxWidth: "400px" }}>{story()}</div>],
parameters: {
chromatic: { disable: true },
},
} satisfies Meta<typeof Label>;
};
export default meta;
type Story = StoryObj<typeof meta>;
type Story = StoryObj<typeof Label>;
const lorem =
"Oppgi årsaken til at du har ventet mer enn 6 måneder med å søke om refusjon";
+10
View File
@@ -1,5 +1,15 @@
# @navikt/ds-tailwind
## 6.5.0
## 6.4.1
## 6.4.0
## 6.3.6
## 6.3.5
## 6.3.4
## 6.3.3
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tailwind",
"version": "6.3.4",
"version": "6.5.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.3.4",
"@navikt/ds-tokens": "^6.5.0",
"color": "4.2.3",
"lodash": "^4.17.21",
"tailwindcss": "^3.3.3",
+10
View File
@@ -1,5 +1,15 @@
# @navikt/ds-tokens
## 6.5.0
## 6.4.1
## 6.4.0
## 6.3.6
## 6.3.5
## 6.3.4
## 6.3.3
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tokens",
"version": "6.3.4",
"version": "6.5.0",
"description": "Design-tokens for NAV designsystem",
"author": "Aksel | NAV designsystem team",
"keywords": [
+127 -95
View File
@@ -1,5 +1,33 @@
# Changelog
## 6.5
### @navikt/ds-react
- Tabs: Ny prop `lazy` som rendrer innhold i TabPanel selv når panel er skjult (har fortsatt display:none) ([#2621](https://github.com/navikt/aksel/pull/2621))
- ToggleGroup: Erstattet bruk av dependency `@radix-ui/react-toggle-group` med egen implementasjon. ([#2620](https://github.com/navikt/aksel/pull/2620))
- Tabs: La til ny prop `fill` som lar Tabs.Tab-elementer strekke seg over tilgjengelig bredde. ([#2621](https://github.com/navikt/aksel/pull/2621))
- Tabs: Erstattet bruk av dependency `@radix-ui/react-tabs` med egen implementasjon. ([#2621](https://github.com/navikt/aksel/pull/2621))
* DatePicker/MonthPicker: Valgte datoer får nå `aria-pressed` for å bedre indikere valg for skjermleser. ([#2838](https://github.com/navikt/aksel/pull/2838))
* DatePicker/MonthPicker: `required`-prop stoppet ikke de-select av allerede valgt dato. ([#2838](https://github.com/navikt/aksel/pull/2838))
* ToggleGroup: La til ny prop `fill` som strekker ToggleGroup til å ta opp all tilgjengelig bredde. ([#2620](https://github.com/navikt/aksel/pull/2620))
## 6.4
### @navikt/ds-css
- FileUpload: Item-illustrasjon er nå radius-full ([#2834](https://github.com/navikt/aksel/pull/2834))
### @navikt/ds-react
- FileUpload.Item: :boom: Endret API og støtte for å sette ID på knappen ([#2824](https://github.com/navikt/aksel/pull/2824))
## 6.3
### @navikt/ds-css
@@ -10,6 +38,10 @@
### @navikt/ds-react
- DatePicker: Fjernet role gridcell fra dato-knapper. ([#2826](https://github.com/navikt/aksel/pull/2826))
* Modal: Fjern tomt element ([#2819](https://github.com/navikt/aksel/pull/2819))
- Switch: Fikset edgecase der checked Switch var visuelt unchecked ([#2810](https://github.com/navikt/aksel/pull/2810))
- FileUpload.Dropzone: id prop settes på button ([#2817](https://github.com/navikt/aksel/pull/2817))
@@ -28,16 +60,16 @@
## 6.2
### @navikt/ds-react
- Select: Fjern 'multiple' fra SelectProps ([#2782](https://github.com/navikt/aksel/pull/2782))
### @navikt/aksel-icons
- Ikoner: Nytt ikon `archive` ([#2772](https://github.com/navikt/aksel/pull/2772))
- Ikoner: Nytt ikon `opensource` ([#2787](https://github.com/navikt/aksel/pull/2787))
### @navikt/ds-react
- Select: Fjern 'multiple' fra SelectProps ([#2782](https://github.com/navikt/aksel/pull/2782))
## 6.1
### @navikt/ds-css
@@ -60,9 +92,11 @@
## 6.0
### @navikt/ds-tailwind
### @navikt/aksel-icons
- 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))
- ESM: ds-react og aksel-icons støtter nå native ESM 🎉 ([#2623](https://github.com/navikt/aksel/pull/2623))
* SSR: ds-react og aksel-icons støtter nå `use client` og kan brukes i nextjs app-router uten lokal re-eksportering. ([#2623](https://github.com/navikt/aksel/pull/2623))
### @navikt/ds-css
@@ -76,6 +110,10 @@
- Textarea: Fjernet div med klassen navds-textarea\_\_wrapper ([#2623](https://github.com/navikt/aksel/pull/2623))
### @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))
@@ -104,19 +142,13 @@
* SSR: ds-react og aksel-icons støtter nå `use client` og kan brukes i nextjs app-router uten lokal re-eksportering. ([#2623](https://github.com/navikt/aksel/pull/2623))
### @navikt/aksel-stylelint
- 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))
### @navikt/aksel-icons
### @navikt/aksel-stylelint
- ESM: ds-react og aksel-icons støtter nå native ESM 🎉 ([#2623](https://github.com/navikt/aksel/pull/2623))
* SSR: ds-react og aksel-icons støtter nå `use client` og kan brukes i nextjs app-router uten lokal re-eksportering. ([#2623](https://github.com/navikt/aksel/pull/2623))
- Textarea: Fjernet div med klassen navds-textarea\_\_wrapper ([#2623](https://github.com/navikt/aksel/pull/2623))
## 5.18
@@ -158,32 +190,32 @@
## 5.16
### @navikt/ds-tailwind
### @navikt/aksel-icons
- Tailwind: La til ny `maxWidth` for text ([#2652](https://github.com/navikt/aksel/pull/2652))
- Ikoner: Nytt ikon `LocationPin` ✨ ([#2672](https://github.com/navikt/aksel/pull/2672))
### @navikt/ds-css
- :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/ds-react
- :sparkles: Combobox: Mulighet for å begrense hvor mange valg bruker kan ta ([#2260](https://github.com/navikt/aksel/pull/2260))
### @navikt/aksel-icons
- Ikoner: Nytt ikon `LocationPin` ✨ ([#2672](https://github.com/navikt/aksel/pull/2672))
## 5.15
### @navikt/ds-react
- HelpText: HelpText-state ble ikke riktig oppdatert ved klikk ([#2643](https://github.com/navikt/aksel/pull/2643))
### @navikt/aksel-icons
- Ikoner: Synket ikoner med Figma. Lagt til nytt ikon `FigureChild` ([#2629](https://github.com/navikt/aksel/pull/2629))
### @navikt/ds-react
- HelpText: HelpText-state ble ikke riktig oppdatert ved klikk ([#2643](https://github.com/navikt/aksel/pull/2643))
## 5.14
### @navikt/ds-css
@@ -224,6 +256,10 @@
## 5.12
### @navikt/aksel-icons
- Ikoner: Oppdatert ikonpakke med diverse bugdfixes og oppdateringer av stroke ([#2538](https://github.com/navikt/aksel/pull/2538))
### @navikt/ds-css
- ExpansionCard: Divider mellom header og innhold ([#2566](https://github.com/navikt/aksel/pull/2566))
@@ -244,11 +280,11 @@
- Stylelint: Støtter nå stylelint v16 ([#2591](https://github.com/navikt/aksel/pull/2591))
## 5.11
### @navikt/aksel-icons
- Ikoner: Oppdatert ikonpakke med diverse bugdfixes og oppdateringer av stroke ([#2538](https://github.com/navikt/aksel/pull/2538))
## 5.11
- Ikoner: Nytt ikon `PushPin` ([#2503](https://github.com/navikt/aksel/pull/2503))
### @navikt/ds-css
@@ -290,16 +326,8 @@
- :wheelchair: Textarea: Forsinkelse ved live-opplesning av gjenstående tegn ([#2502](https://github.com/navikt/aksel/pull/2502))
### @navikt/aksel-icons
- Ikoner: Nytt ikon `PushPin` ([#2503](https://github.com/navikt/aksel/pull/2503))
## 5.10
### @navikt/ds-tailwind
- typescript types for tailwind preset ([#2486](https://github.com/navikt/aksel/pull/2486))
### @navikt/ds-css
- :bug: Textarea: Teller flyttet ut av tekstfeltet for å unngå overlapp og misforståelser ([#2483](https://github.com/navikt/aksel/pull/2483))
@@ -308,6 +336,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))
### @navikt/ds-react
- Gjør lukknapp i Alert til type button ([#2497](https://github.com/navikt/aksel/pull/2497))
@@ -370,6 +402,10 @@
## 5.7
### @navikt/aksel-icons
- Ikoner: Nye ikoner `ChevronLeftFirstCircle` og `ChevronRightLastCircle` ([#2365](https://github.com/navikt/aksel/pull/2365))
### @navikt/ds-css
- :lipstick: Chips.Toggle: Ikon i uvalgt tilstand ([#2395](https://github.com/navikt/aksel/pull/2395))
@@ -410,11 +446,15 @@
- :lipstick: CopyButton: Justert padding, gap og animasjon ([#2355](https://github.com/navikt/aksel/pull/2355))
## 5.6
### @navikt/aksel-icons
- Ikoner: Nye ikoner `ChevronLeftFirstCircle` og `ChevronRightLastCircle` ([#2365](https://github.com/navikt/aksel/pull/2365))
- Ikoner: SVG export setter nå `height="1em"`, `width="1em"` og `fill="currentColor"`. ([#2300](https://github.com/navikt/aksel/pull/2300))
## 5.6
- Ikoner: Nye ikoner `book` og `books` ([#2313](https://github.com/navikt/aksel/pull/2313))
* Ikoner: Nye ikoner `ClipboardCheckmark` og `ClipboardLink` ([#2307](https://github.com/navikt/aksel/pull/2307))
### @navikt/ds-css
@@ -466,14 +506,6 @@
- Remove tabIndex="-1" on Popover to fix VoiceOver navigation issue inside Popover ([#2303](https://github.com/navikt/aksel/pull/2303))
### @navikt/aksel-icons
- Ikoner: SVG export setter nå `height="1em"`, `width="1em"` og `fill="currentColor"`. ([#2300](https://github.com/navikt/aksel/pull/2300))
- Ikoner: Nye ikoner `book` og `books` ([#2313](https://github.com/navikt/aksel/pull/2313))
* Ikoner: Nye ikoner `ClipboardCheckmark` og `ClipboardLink` ([#2307](https://github.com/navikt/aksel/pull/2307))
## 5.5
### @navikt/ds-css
@@ -514,6 +546,10 @@
## 5.3
### @navikt/aksel-icons
- :bug: SVG import map er nå riktig path ([#2250](https://github.com/navikt/aksel/pull/2250))
### @navikt/ds-css
- Alert: 12px -> 8px gap mellom tekst og ikon ved `size="small"` ([#2240](https://github.com/navikt/aksel/pull/2240))
@@ -550,11 +586,11 @@
* :wheelchair: Textarea: Skjermleser-spesifikk tekst leses opp sammenhengende ([#2216](https://github.com/navikt/aksel/pull/2216))
## 5.2
### @navikt/aksel-icons
- :bug: SVG import map er nå riktig path ([#2250](https://github.com/navikt/aksel/pull/2250))
## 5.2
- Ikoner: Oppdatert ikonpakke ([#2197](https://github.com/navikt/aksel/pull/2197))
### @navikt/ds-css
@@ -572,10 +608,6 @@
- Hovering over combobox dropdown will move selection/focus in the list, so we don't end up with a split focus, and reversely when moving focus while hovering ([#2193](https://github.com/navikt/aksel/pull/2193))
### @navikt/aksel-icons
- Ikoner: Oppdatert ikonpakke ([#2197](https://github.com/navikt/aksel/pull/2197))
## 5.1
### @navikt/ds-css
@@ -785,18 +817,18 @@
## 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-css
- :lipstick: Chat: fjernet border, satt avatar svg til 24x24px, byttet om "subtle" og "neutral" ([#2077](https://github.com/navikt/aksel/pull/2077))
* 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))
### @navikt/ds-tokens
- Shadow-tokens er oppdatert til mer tydeligere varianter ([PR](https://github.com/navikt/aksel/pull/2041))
@@ -831,9 +863,9 @@
## 4.1
### @navikt/ds-tailwind
### @navikt/aksel-icons
- :tada: Fargetokens for datavisualisering. ([PR](https://github.com/navikt/aksel/pull/2032))
- Nye ikoner `ChevronRightLast` og `ChevronLeftLast` ([PR](https://github.com/navikt/aksel/pull/2054))
### @navikt/ds-css
@@ -845,6 +877,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/ds-tokens
- :tada: Fargetokens for datavisualisering. ([PR](https://github.com/navikt/aksel/pull/2032))
@@ -879,10 +915,6 @@
- Deprecated klassenavn `navds-chips--icon-left` ([PR](https://github.com/navikt/aksel/pull/2035))
### @navikt/aksel-icons
- Nye ikoner `ChevronRightLast` og `ChevronLeftLast` ([PR](https://github.com/navikt/aksel/pull/2054))
## 4.0
### @navikt/ds-css
@@ -935,6 +967,10 @@
## 3.2
### @navikt/aksel-icons
- Nye ikoner `FileParagraph` og `FilePlus` ([PR](https://github.com/navikt/aksel/pull/1998))
### @navikt/ds-css
- Datepicker: Fikset small-variant av datepicker-input. ([PR](https://github.com/navikt/aksel/pull/1993))
@@ -953,10 +989,6 @@
* Ny komponent CopyButton! Erstatter `CopyToClipboard` som nå er tagget som deprecated ([PR](https://github.com/navikt/aksel/pull/1982))
### @navikt/aksel-icons
- Nye ikoner `FileParagraph` og `FilePlus` ([PR](https://github.com/navikt/aksel/pull/1998))
## 3.1
### @navikt/ds-css
@@ -989,6 +1021,16 @@
## 2.9
### @navikt/aksel-icons
- Oppdatert Statusikoner til å være tydeligere og mer konsistente. ([PR](https://github.com/navikt/aksel/pull/1959))
* Fikset `CheckmarkIcon`-bug
- Fikset `CheckmarkCircleIcon`-bug ([PR](https://github.com/navikt/aksel/pull/1956))
* Nye ikoner `BulletList` og `NumberList` ([PR](https://github.com/navikt/aksel/pull/1950))
### @navikt/ds-css
- CSS nå tilgjengelig som separate filer: Kan lastest fra CDN, minified-versjoner tilgjengelig ([PR](https://github.com/navikt/aksel/pull/1941))
@@ -1001,17 +1043,19 @@
- :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/aksel-icons
- Oppdatert Statusikoner til å være tydeligere og mer konsistente. ([PR](https://github.com/navikt/aksel/pull/1959))
- :tada: Nye ikoner `HandKnot` og `HandKnotFilled` ([PR](https://github.com/navikt/aksel/pull/1928))
* Fikset `CheckmarkIcon`-bug
* Oppdatert `InformationSquare`-ikon ([PR](https://github.com/navikt/aksel/pull/1921))
- Fikset `CheckmarkCircleIcon`-bug ([PR](https://github.com/navikt/aksel/pull/1956))
- Oppdatert `VideoSlack`, `Questionmark` og `Information`-ikoner ([PR](https://github.com/navikt/aksel/pull/1909))
* Nye ikoner `BulletList` og `NumberList` ([PR](https://github.com/navikt/aksel/pull/1950))
* Nye ikoner `VideoSlash` og `VideoSlashFill`
## 2.8
- Fikset Sourcemap-bug
### @navikt/ds-css
@@ -1047,26 +1091,8 @@
* Ny komponent ExpansionCard! ([PR](https://github.com/navikt/aksel/pull/1820))
### @navikt/aksel-icons
- :tada: Nye ikoner `HandKnot` og `HandKnotFilled` ([PR](https://github.com/navikt/aksel/pull/1928))
* Oppdatert `InformationSquare`-ikon ([PR](https://github.com/navikt/aksel/pull/1921))
- Oppdatert `VideoSlack`, `Questionmark` og `Information`-ikoner ([PR](https://github.com/navikt/aksel/pull/1909))
* Nye ikoner `VideoSlash` og `VideoSlashFill`
- Fikset Sourcemap-bug
## 2.7
### @navikt/ds-css
- Tabs: La til focus-markering for Tabs.Panel ([PR](https://github.com/navikt/aksel/pull/1863))
* ToggleGroup: Fikset token-bug
### @navikt/aksel-icons
- Nye ikoner `CaretUpDownFilledDown`, `CaretUpDownFilledUp`, `CloudDown`, `CloudDownFill`, `CloudUp`, `CloudUpFill`, `HddDown`, `HddDownFill`, `HddUp`, `HddUpFill`, `Inbox`, `InboxDown`, `InboxDownFill`, `InboxFill`, `InboxUp`, `InboxUpFill`, `ShoppingBasket`, `ShoppingBasketFill`
@@ -1075,6 +1101,12 @@
- Ny ikonpakke med for core icons 3! `@navikt/aksel-icons` ([PR](https://github.com/navikt/aksel/pull/1847))
### @navikt/ds-css
- Tabs: La til focus-markering for Tabs.Panel ([PR](https://github.com/navikt/aksel/pull/1863))
* ToggleGroup: Fikset token-bug
## 2.6
### @navikt/ds-react
@@ -1171,10 +1203,6 @@
## 2.0
### @navikt/ds-tailwind
- Alle token er oppdatert til nytt format. ([Guide](https://aksel.nav.no/grunnleggende/kode/migrering#h76f47744d112))
### @navikt/ds-css
- Radio: Fikset default visuell error-state ([PR](https://github.com/navikt/aksel/pull/1737))
@@ -1187,6 +1215,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))
### @navikt/ds-tokens
- Alle token er oppdatert til nytt format. ([Guide](https://aksel.nav.no/grunnleggende/kode/migrering#h76f47744d112))
+5 -5
View File
@@ -15,10 +15,10 @@
"playroom"
],
"dependencies": {
"@navikt/aksel-icons": "^6.3.4",
"@navikt/ds-css": "^6.3.4",
"@navikt/ds-react": "^6.3.4",
"@navikt/ds-tailwind": "^6.3.4",
"@navikt/ds-tokens": "^6.3.4"
"@navikt/aksel-icons": "^6.5.0",
"@navikt/ds-css": "^6.5.0",
"@navikt/ds-react": "^6.5.0",
"@navikt/ds-tailwind": "^6.5.0",
"@navikt/ds-tokens": "^6.5.0"
}
}
+5 -5
View File
@@ -12,10 +12,10 @@ function getAbsolutePath(value: string): any {
}
const config: StorybookConfig = {
storyIndexers: (indexers) => {
experimental_indexers: (indexers) => {
const csfIndexer = async (fileName: string, opts) => {
const code = readFileSync(fileName, "utf-8").toString();
return loadCsf(code, { ...opts, fileName }).parse();
return loadCsf(code, { ...opts, fileName }).parse().indexInputs;
};
const exampleIndexer = async (fileName: string, opts) => {
@@ -32,18 +32,18 @@ const config: StorybookConfig = {
.split("pages/eksempler/")[1]
.replace(".tsx", "")}" };\n`;
return loadCsf(code, { ...opts, fileName }).parse();
return loadCsf(code, { ...opts, fileName }).parse().indexInputs;
};
return [
...(indexers || []),
{
test: /(stories|story)\.[tj]sx?$/,
indexer: csfIndexer,
createIndex: csfIndexer,
},
{
test: /pages\/eksempler\/|(".+")|(.+.tsx)$/,
indexer: exampleIndexer,
createIndex: exampleIndexer,
},
];
},
@@ -0,0 +1,24 @@
import { useEffect } from "react";
/**
* SSR-safe hook that listens for the Escape keydown event and calls the provided callback.
* @param onEscapeKeyDown callback for when Escape key is pressed
* @param deps additional dependencies to keep the hook up-to-date
*/
export function useEscapeKeydown(
onEscapeKeyDown: (event: KeyboardEvent) => void,
deps: React.DependencyList = [],
) {
useEffect(() => {
const ownerDocument: Document = globalThis?.document;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onEscapeKeyDown(event);
}
};
ownerDocument.addEventListener("keydown", handleKeyDown);
return () => ownerDocument.removeEventListener("keydown", handleKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onEscapeKeyDown, ...deps]);
}
@@ -32,7 +32,7 @@ const Footer = () => {
heading="Snarveier"
links={[
{
url: "/god-praksis/artikler/skriv-for-aksel",
url: "/side/skriv-for-aksel",
text: "Skriv for Aksel",
},
{
@@ -50,7 +50,7 @@ const Footer = () => {
heading="Om nettstedet"
links={[
{
url: "/god-praksis/artikler/om-aksel",
url: "/side/om-aksel",
text: "Hva er Aksel?",
},
{
@@ -0,0 +1,149 @@
import { useMemo } from "react";
import { BodyLong, Heading } from "@navikt/ds-react";
import GpArticleCard from "@/layout/god-praksis-page/cards/GpArticleCard";
import {
GpSlugQueryResponse,
ParsedGPArticle,
} from "@/layout/god-praksis-page/interface";
import { useGpViews } from "@/layout/god-praksis-page/useGpViews";
type GpArticleCardProps = {
articles: ParsedGPArticle[];
undertemaList: GpSlugQueryResponse["tema"]["undertema"];
};
export function ArticleSections({
articles,
undertemaList,
}: GpArticleCardProps) {
const queryState = useGpViews();
const sections: [
GpSlugQueryResponse["tema"]["undertema"][0],
ParsedGPArticle[],
][] = useMemo(() => {
const getUndertemaNameFromTema = (undertema: string) =>
undertemaList.find((ut) => ut.title === undertema) ?? null;
const groupedByUndertema = articles.reduce((map, article) => {
const undertema = getUndertemaNameFromTema(article.undertema);
if (!undertema) {
return map;
}
const articlesForUndertema = map.get(undertema) || [];
articlesForUndertema.push(article);
map.set(undertema, articlesForUndertema);
return map;
}, new Map<GpSlugQueryResponse["tema"]["undertema"][0], ParsedGPArticle[]>());
if (queryState.view === "none") {
return [...groupedByUndertema.entries()];
} else if (queryState.view === "undertema") {
const currentUndertema = getUndertemaNameFromTema(queryState.undertema);
if (!currentUndertema) {
return [];
}
const utArticles = groupedByUndertema.get(currentUndertema);
if (!utArticles) {
return [];
}
return [[currentUndertema, utArticles]];
} else if (queryState.view === "innholdstype") {
const filteredMap = new Map();
for (const [undertema, utArticles] of groupedByUndertema) {
const filteredArticles = utArticles.filter(
(article) => article.innholdstype === queryState.innholdstype,
);
if (filteredArticles.length > 0) {
filteredMap.set(undertema, filteredArticles);
}
}
if (filteredMap.size === 0) {
return [];
}
return [...filteredMap.entries()];
} else if (queryState.view === "both") {
const matchingArticles = articles.filter(
(article) =>
article.undertema === queryState.undertema &&
article.innholdstype === queryState.innholdstype,
);
if (matchingArticles.length === 0) {
return [];
}
return [
[
{
title: `Artikler for ${queryState.undertema} og ${queryState.innholdstype}`,
},
matchingArticles,
],
];
}
return [];
}, [articles, undertemaList, queryState]);
const SectionTag =
queryState.view === "undertema" || queryState.view === "both"
? "div"
: "section";
return sections.map(([ut, utArticles]) => (
<SectionTag
key={ut.title}
aria-label={
["none", "innholdstype"].includes(queryState.view)
? `Undertema ${ut.title}`
: undefined
}
>
<IntroSection title={ut.title} description={ut.description} />
<GpCardGrid>
{utArticles.map((article) => (
<li key={article.slug}>
<GpArticleCard
href={`${article.slug}`.replace("god-praksis", "gp")}
innholdstype={article.innholdstype}
undertema={article.undertema}
publishedAt={article.publishedAt}
description={article.description}
>
{article.heading}
</GpArticleCard>
</li>
))}
</GpCardGrid>
</SectionTag>
));
}
function IntroSection({
title,
description,
}: {
title: string;
description?: string;
}) {
return (
<>
<Heading level="2" size="medium" className="mb-2 text-aksel-heading">
{title}
</Heading>
{description && <BodyLong spacing>{description}</BodyLong>}
</>
);
}
export function GpCardGrid({ children }: { children: React.ReactNode }) {
return (
<ul className="grid grid-cols-1 gap-3 *:grid md:grid-cols-2 md:gap-6">
{children}
</ul>
);
}
@@ -0,0 +1,44 @@
export function FallbackPictogram() {
return (
<svg
aria-hidden
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_4376_826)">
<rect
x="14.25"
width="20.6625"
height="20.6625"
rx="1.76601"
transform="rotate(45 14.25 0)"
className="fill-teal-200 group-aria-[current]:fill-teal-500"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M31.9835 10.9238L38.9205 16.7453L27.8505 30.2028L18.4766 34.4401L20.9189 24.374L31.9835 10.9238ZM22.984 25.4054L32.2776 14.1079L35.7675 17.0366L26.4459 28.3685L21.75 30.4912L22.984 25.4054Z"
className="fill-text-default group-aria-[current]:fill-text-on-inverted"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M34.8732 7.43541L34.9073 7.40063C36.5368 5.73522 39.2262 5.47011 41.0842 7.06044L41.1244 7.09484L41.3513 7.30836L41.3882 7.34644C43.03 9.04376 43.0583 11.719 41.5681 13.5305L38.0109 17.8515L31.074 12.0302L34.6618 7.67111L34.8732 7.43541ZM36.3686 9.13798L36.5155 8.97419C37.378 8.09268 38.7282 8.00555 39.6211 8.76981L39.771 8.91079C40.5762 9.74316 40.6324 11.1263 39.8305 12.1011L37.7174 14.6679L34.2275 11.7393L36.3686 9.13798Z"
className="fill-text-default group-aria-[current]:fill-text-on-inverted"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.2246 12.9243H26.25C26.8713 12.9243 27.375 12.4206 27.375 11.7993C27.375 11.178 26.8713 10.6743 26.25 10.6743H9.84961C8.81408 10.6743 7.97461 11.5138 7.97461 12.5493V41.716C7.97461 42.7515 8.81408 43.591 9.84961 43.591H35.9496C36.9851 43.591 37.8246 42.7515 37.8246 41.716V25.125C37.8246 24.5037 37.3209 24 36.6996 24C36.0783 24 35.5746 24.5037 35.5746 25.125V41.341H10.2246V12.9243Z"
className="fill-text-default group-aria-[current]:fill-text-on-inverted"
/>
</g>
<defs>
<clipPath id="clip0_4376_826">
<rect width="48" height="48" fill="white" />
</clipPath>
</defs>
</svg>
);
}
@@ -1,43 +0,0 @@
import dynamic from "next/dynamic";
import { Box, Page, VStack } from "@navikt/ds-react";
import Footer from "@/layout/footer/Footer";
import { GpEntryPageProps } from "@/layout/god-praksis-page/interface";
import useInitialState from "@/layout/god-praksis-page/useInitialState";
import Header from "@/layout/header/Header";
import Hero from "./hero/Hero";
const ArticleList = dynamic(() => import("./articles/ArticleList"), {
ssr: false,
});
const ChipGroup = dynamic(() => import("./chips/ChipGroup"), {
ssr: false,
});
function GodPraksisPage(props: GpEntryPageProps) {
const { initialData } = useInitialState(props.initialArticles);
return (
/* TODO: Add surface-subtle to page-component props */
<Page
footer={<Footer />}
footerPosition="belowFold"
className="bg-surface-subtle"
>
<Header variant="subtle" />
<Box paddingBlock="10">
<Page.Block width="xl" gutters>
<VStack gap="10">
<VStack gap="6">
<Hero tema={props.tema} heroNav={props.heroNav} />
<ChipGroup data={props.chipsData} showTema={!!props.tema} />
</VStack>
<ArticleList initialArticles={initialData} />
</VStack>
</Page.Block>
</Box>
</Page>
);
}
export default GodPraksisPage;
@@ -1,91 +0,0 @@
import cl from "clsx";
import Link from "next/link";
import {
ChevronRightIcon,
FileFillIcon,
TagFillIcon,
} from "@navikt/aksel-icons";
import { BodyLong, Detail, Heading, VStack } from "@navikt/ds-react";
import { useFormatedDate } from "@/hooks/useFormatedDate";
import { GpArticleT } from "@/layout/god-praksis-page/interface";
import styles from "./articles.module.css";
/**
* TODO:
* - Implement reference-design from Figma (WIP)
* - Implement better screen-reader ux
* - - Only Heading should be a Link(<a>) with ::after for click-area
* - - (see `components/sanity-modules/cards/ArtikkelCard.tsx`)
*/
export const ArticleCard = ({
heading,
ingress,
slug,
publishedAt,
group,
delay,
innholdstype,
currentUndertema,
}: GpArticleT & {
group: "initial" | "lazy";
delay?: number;
}) => {
const date = useFormatedDate(publishedAt ?? undefined);
const tDelay = delay ? { transitionDuration: `${delay}ms` } : undefined;
return (
<Link
href={`/${
slug.replace("god-praksis/", "gp/") /* TODO: Remove replace */
}`}
className={cl(
"w-full flex-shrink overflow-hidden text-ellipsis rounded-large bg-surface-default p-5 shadow-small transition-shadow ease-out hover:shadow-large focus:outline-none focus-visible:shadow-focus",
{
[styles.animatedFade]: group === "initial",
[styles.animatedFadeLazy]: group === "lazy",
},
)}
style={tDelay}
>
<VStack justify="space-between" className="relative min-h-full">
<div>
<Heading
size="small"
level="2"
className="text-aksel-heading underline"
>
{heading}
</Heading>
{/* TODO: Can do this serverside in initialProps (saves data sent to user) */}
{date && (
<Detail textColor="subtle" className="mt-1">
{date}
</Detail>
)}
{ingress && (
<BodyLong className={cl("mt-2 line-clamp-2", styles.lineClamp)}>
{ingress}
</BodyLong>
)}
</div>
<div className="mt-6 flex items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-3 font-semibold">
{currentUndertema && (
<span className="flex items-center gap-05 text-teal-700">
<TagFillIcon aria-hidden />
<span>{currentUndertema}</span>
</span>
)}
<span className="flex items-center gap-05 text-violet-600">
<FileFillIcon aria-hidden />
<span>{innholdstype}</span>
</span>
</div>
<ChevronRightIcon aria-hidden className="flex-shrink-0 text-2xl" />
</div>
</VStack>
</Link>
);
};
@@ -1,56 +0,0 @@
import cl from "clsx";
import { Heading } from "@navikt/ds-react";
import { GpArticleT } from "@/layout/god-praksis-page/interface";
import { ArticleCard } from "./ArticleCard";
import styles from "./articles.module.css";
type ArticleGridT = {
initialData: GpArticleT[];
data: GpArticleT[];
loaded: boolean;
};
/**
* TODO:
* - Does this need to be a separate component from `ArticleList`?
* - Don`t use `loaded` as a prop here. Parent-component rendering this should just not render it if !loaded
*/
function ArticleGrid({ data, initialData, loaded }: ArticleGridT) {
if (!initialData) {
/**
* TODO:
* - Add content for emptystate here
*/
return null;
}
if (!loaded) {
return null;
}
return (
<div className={styles.animatedFade}>
<Heading level="2" size="medium" className="text-aksel-heading">
Siste
</Heading>
<div className="grid gap-6">
<div className={cl("pt-5", styles.cardGrid)}>
{initialData.map((article, idx) => (
<ArticleCard key={article._id + idx} group="initial" {...article} />
))}
{data.map((article, idx) => (
<ArticleCard
key={idx}
group="lazy"
delay={((idx % 3) + 1) * 300}
{...article}
/>
))}
</div>
</div>
</div>
);
}
export default ArticleGrid;
@@ -1,110 +0,0 @@
import cl from "clsx";
import { useRouter } from "next/router";
import useSWRInfinite from "swr/infinite";
import { ChevronDownCircleIcon } from "@navikt/aksel-icons";
import { Button } from "@navikt/ds-react";
import ErrorBoundary from "@/error-boundary";
import {
GP_LAZYLOADED_ARTICLES,
GpArticleT,
} from "@/layout/god-praksis-page/interface";
import useGpQuery from "@/layout/god-praksis-page/useGpQuery";
import ArticleGrid from "./ArticleGrid";
import styles from "./articles.module.css";
const getKey = ({
input: { pageIndex, previousPageData },
innholdstypeQuery,
undertemaQuery,
temaQuery,
}: {
input: { pageIndex: number; previousPageData: any[] };
innholdstypeQuery: string | null;
undertemaQuery: string | null;
temaQuery: string | null;
}) => {
if (previousPageData && previousPageData.length < 3) {
return null;
}
return `/api/god-praksis/v2?page=${pageIndex}${
temaQuery ? `&tema=${temaQuery}` : ""
}${innholdstypeQuery ? `&innholdstype=${innholdstypeQuery}` : ""}${
undertemaQuery ? `&undertema=${undertemaQuery}` : ""
}`;
};
type ArticleListT = {
initialArticles: GpArticleT[];
};
/**
* TODO:
* - Handle errors
* - Refactor this component to be more generic.
* - - Idea: Pull lazy-loading out to a hook that handles loading, button and errors
*
* - Better names for styles-css
* - Grid-layout is experimental, check with designer
*/
function ArticleList({ initialArticles }: ArticleListT) {
const router = useRouter();
const { innholdstypeQuery, undertemaQuery, temaQuery } = useGpQuery();
const {
data = [],
size,
setSize,
isValidating,
} = useSWRInfinite<GpArticleT[]>(
(pageIndex, previousPageData) =>
initialArticles.length < 9
? null
: getKey({
input: {
pageIndex: pageIndex + GP_LAZYLOADED_ARTICLES,
previousPageData: previousPageData ?? [],
},
innholdstypeQuery,
undertemaQuery,
temaQuery,
}),
(query) => fetch(query).then((res) => res.json()),
{
revalidateFirstPage: false,
initialSize: 0,
},
);
const atEndOfLazy = data && data[data.length - 1]?.length < 3;
return (
<div>
<ArticleGrid
initialData={initialArticles}
data={data.flat()}
loaded={router.isReady}
/>
{!atEndOfLazy && initialArticles.length === 9 && router.isReady && (
<div className={cl("flex justify-center pt-8", styles.animatedFade)}>
<Button
variant="tertiary-neutral"
onClick={() => setSize(size + 1)}
disabled={isValidating}
icon={<ChevronDownCircleIcon aria-hidden />}
>
Last flere artikler
</Button>
</div>
)}
</div>
);
}
export default function Component(props: ArticleListT) {
return (
<ErrorBoundary boundaryName="GpArticleList">
<ArticleList {...props} />
</ErrorBoundary>
);
}
@@ -1,66 +0,0 @@
.animatedFade {
animation: fadeUpKeyFrames 600ms forwards;
}
.animatedFadeLazy {
animation: fadeUpKeyFramesLazy 600ms forwards;
transition: none;
}
@keyframes fadeUpKeyFrames {
0% {
opacity: 0;
transform: translate3d(0, 1rem, 0);
}
}
@keyframes fadeUpKeyFramesLazy {
0% {
opacity: 0;
transform: translateY(2rem);
}
}
.cardGrid {
display: grid;
}
@media (max-width: 767px) {
.cardGrid {
gap: var(--a-spacing-4);
}
}
@media (min-width: 768px) and (max-width: 1279px) {
.cardGrid {
gap: var(--a-spacing-4);
grid-template-columns: repeat(2, 1fr);
}
.cardGrid > :is(:nth-child(5n + 1)) {
grid-column: span 2 / span 2;
}
}
@media (min-width: 1280px) {
.cardGrid {
gap: var(--a-spacing-6);
grid-template-columns: repeat(3, 1fr);
--text-clamp-lines: 2;
}
.cardGrid > :is(:nth-child(6n + 4), :nth-child(6n + 6)) {
grid-column: span 2 / span 2;
}
.cardGrid > :is(:nth-child(6n + 5)) {
grid-row: span 2 / span 2;
--text-clamp-lines: 10;
}
}
.lineClamp {
-webkit-line-clamp: var(--text-clamp-lines);
}
@@ -0,0 +1,118 @@
import type { Meta, StoryObj } from "@storybook/react";
import { HGrid } from "@navikt/ds-react";
import { AkselTheme } from "@/sb-util";
import GpArticleCard from "./GpArticleCard";
const meta = {
title: "Layout/god-praksis/cards/ArticleCard",
component: GpArticleCard,
tags: ["autodocs"],
decorators: [AkselTheme],
} satisfies Meta<typeof GpArticleCard>;
export default meta;
type Story = StoryObj<typeof meta>;
const title = {
short: "AI",
medium: "Machine Learning Basics",
long: "A Comprehensive Guide to Understanding and Implementing Transformer Models in NLP",
};
export const CompactSingle: Story = {
args: {
href: "gp/brukeropplevelse",
children: title.long,
innholdstype: "Testing",
undertema: "WCAG",
description: "",
publishedAt: "",
},
};
export const CompactMultiple = {
render: (props) => {
return (
<HGrid gap="4" columns={{ xs: 1, md: 2 }}>
<GpArticleCard {...props} href="test">
{title.short}
</GpArticleCard>
<GpArticleCard {...props} href="test">
{title.short}
</GpArticleCard>
<GpArticleCard {...props} href="test">
{title.medium}
</GpArticleCard>
<GpArticleCard {...props} href="test">
{title.long}
</GpArticleCard>
</HGrid>
);
},
args: {
innholdstype: "Testing",
undertema: "WCAG",
},
};
export const ExpressiveSingle: Story = {
args: {
href: "gp/brukeropplevelse",
children: "Brukeropplevelse",
innholdstype: "Testing",
undertema: "WCAG",
description:
"La brukeren sortere innholdet på en måte som er logisk for dem med kortsortering og UXtweak.",
publishedAt: "2021-01-01",
},
};
export const ExpressiveMultiple = {
render: (props) => {
return (
<HGrid gap="4" columns={{ xs: 1, md: 2 }}>
<GpArticleCard {...props} href="test">
{title.short}
</GpArticleCard>
<GpArticleCard {...props} href="test">
{title.short}
</GpArticleCard>
<GpArticleCard {...props} href="test">
{title.medium}
</GpArticleCard>
<GpArticleCard {...props} href="test">
{title.long}
</GpArticleCard>
</HGrid>
);
},
args: {
children: title.medium,
undertema: "WCAG",
description:
"La brukeren sortere innholdet på en måte som er logisk for dem med kortsortering og UXtweak.",
publishedAt: "2021-01-01",
},
};
export const ExpressiveNoDescription: Story = {
args: {
href: "gp/brukeropplevelse",
children: title.long,
innholdstype: "Testing",
undertema: "WCAG",
publishedAt: "2021-01-01",
},
};
export const ExpressiveLongDescription: Story = {
args: {
href: "gp/brukeropplevelse",
children: title.long,
innholdstype: "Testing",
undertema: "WCAG",
publishedAt: "2021-01-01",
description:
"This is a description that is very long and should be truncated to a reasonable length to fit the card layout. This is a description that is very long and should be truncated to a reasonable length to fit the card layout. This is a description that is very long and should be truncated to a reasonable length to fit the card layout. This is a description that is very long and should be truncated to a reasonable length to fit the card layout. This is a description that is very long and should be truncated to a reasonable length to fit the card layout. This is a description that is very long and should be truncated to a reasonable length to fit the card layout.",
},
};
@@ -0,0 +1,77 @@
import cl from "clsx";
import Link from "next/link";
import { FileFillIcon, TagFillIcon } from "@navikt/aksel-icons";
import { BodyLong, Detail, Heading } from "@navikt/ds-react";
import { AnimatedChevron } from "@/web/AnimatedChevron";
type GpArticleCardProps = {
children: React.ReactNode;
href: string;
innholdstype?: string;
undertema?: string;
description?: string;
publishedAt?: string;
};
function GpArticleCard({
href,
children,
innholdstype,
undertema,
description,
publishedAt,
}: GpArticleCardProps) {
return (
<div className="group relative flex flex-col gap-1 rounded-lg bg-surface-default p-4 pb-3 text-text-default shadow-xsmall hover:shadow-small has-[:focus-visible]:shadow-focus md:p-5 md:pb-4">
<Link
href={`/${href}`}
className="peer after:absolute after:inset-0 after:z-10 after:rounded-lg focus:outline-none"
>
<Heading
size="small"
as="h3"
className="text-aksel-heading underline group-hover:no-underline"
>
{children}
</Heading>
</Link>
{publishedAt && (
<Detail as="time" textColor="subtle" uppercase>
{publishedAt}
</Detail>
)}
{description && (
<BodyLong className="mt-1 line-clamp-2">{description}</BodyLong>
)}
<div
className={cl("mt-auto flex h-fit justify-between text-deepblue-700", {
"pt-5": description,
})}
>
<div className="flex flex-wrap gap-3">
{undertema && (
<div className="flex items-center gap-05 text-teal-700">
<TagFillIcon aria-hidden fontSize="1rem" />
<Detail weight="semibold" as="span">
{undertema}
</Detail>
</div>
)}
{innholdstype && (
<div className="flex items-center gap-05 text-violet-600">
<FileFillIcon aria-hidden fontSize="1rem" />
<Detail weight="semibold" as="span">
{innholdstype}
</Detail>
</div>
)}
</div>
<AnimatedChevron />
</div>
</div>
);
}
export default GpArticleCard;
@@ -0,0 +1,43 @@
import type { Meta, StoryObj } from "@storybook/react";
import { HStack } from "@navikt/ds-react";
import { AkselTheme } from "@/sb-util";
import GpHeroCard from "./GpHeroCard";
const meta = {
title: "Layout/god-praksis/cards/HeroCard",
component: GpHeroCard,
tags: ["autodocs"],
decorators: [AkselTheme],
} satisfies Meta<typeof GpHeroCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Single: Story = {
args: {
articleCount: 4,
href: "gp/brukeropplevelse",
children: "Brukeropplevelse",
},
};
export const Multiple = {
render: () => {
return (
<HStack gap="4">
<GpHeroCard href="test" articleCount={4}>
Brukerinnsikt
</GpHeroCard>
<GpHeroCard href="test" articleCount={20}>
Universell Utforming
</GpHeroCard>
<GpHeroCard href="test" articleCount={2}>
Innsiktsarbeid
</GpHeroCard>
<GpHeroCard href="test" articleCount={40}>
nav.no
</GpHeroCard>
</HStack>
);
},
};
@@ -0,0 +1,71 @@
import { SanityImageSource } from "@sanity/image-url/lib/types/types";
import cl from "clsx";
import Image from "next/legacy/image";
import Link from "next/link";
import { HTMLAttributes, forwardRef } from "react";
import { BodyShort, Heading } from "@navikt/ds-react";
import { FallbackPictogram } from "@/layout/god-praksis-page/FallbackPictogram";
import { urlFor } from "@/sanity/interface";
type GpHeroCardProps = {
children: React.ReactNode;
href: string;
articleCount?: number;
image?: SanityImageSource;
compact?: boolean;
} & HTMLAttributes<HTMLAnchorElement>;
const GpHeroCard = forwardRef<HTMLAnchorElement, GpHeroCardProps>(
({ href, articleCount, children, image, compact = false, ...rest }, ref) => {
return (
<Link
ref={ref}
href={`/${href}`}
className="group flex gap-2 rounded-lg bg-surface-default py-2 pl-2 pr-3 shadow-xsmall outline-none hover:shadow-small focus-visible:shadow-focus aria-[current]:bg-teal-800 aria-[current]:focus-visible:shadow-focus-gap md:gap-3 md:py-3 md:pl-3 md:pr-6"
{...rest}
>
<div
className={cl("relative my-auto shrink-0", {
"size-8 md:size-12": !compact,
"size-6": compact,
})}
>
{image ? (
<Image
src={urlFor(image).auto("format").url()}
decoding="sync"
layout="fill"
objectFit="contain"
aria-hidden
priority
/>
) : (
<FallbackPictogram />
)}
</div>
<div className="grid gap-05">
<Heading
size="small"
as="span"
className="truncate text-aksel-heading underline group-hover:no-underline group-aria-[current]:text-text-on-inverted group-aria-[current]:no-underline"
>
{children}
</Heading>
{articleCount && (
<BodyShort
as="span"
size="small"
textColor="subtle"
className="uppercase"
>{`${articleCount} ${
articleCount === 1 ? "artikkel" : "artikler"
}`}</BodyShort>
)}
</div>
</Link>
);
},
);
export default GpHeroCard;
@@ -0,0 +1,31 @@
import cl from "clsx";
type GpChipProps = {
children: React.ReactNode;
pressed: boolean;
onClick: () => void;
type: "innholdstype" | "undertema";
disabled: boolean;
};
export function GpChip(props: GpChipProps) {
return (
<button
aria-pressed={props.pressed}
onClick={props.onClick}
className={cl(
"grid min-h-8 place-content-center whitespace-nowrap rounded-full bg-surface-neutral-subtle px-3 py-1 ring-1 ring-inset transition-opacity focus:outline-none focus-visible:shadow-focus-gap aria-pressed:text-text-on-inverted",
"disabled:bg-surface-neutral-subtle disabled:opacity-40 disabled:ring-border-default",
{
"ring-violet-700/50 hover:bg-violet-50 aria-pressed:bg-violet-700 hover:aria-pressed:bg-violet-800":
props.type === "innholdstype",
"ring-teal-700/50 hover:bg-teal-50 aria-pressed:bg-teal-700 hover:aria-pressed:bg-teal-800":
props.type === "undertema",
},
)}
disabled={props.disabled}
>
{props.children}
</button>
);
}
@@ -0,0 +1,32 @@
import { VStack } from "@navikt/ds-react";
import { getArticleCounts } from "@/layout/god-praksis-page/count-articles";
import { ParsedGPArticle } from "@/layout/god-praksis-page/interface";
import { useGpViews } from "@/layout/god-praksis-page/useGpViews";
import { GpChipRow } from "./GpChipRow";
type GpChipNavigationProps = {
articles: ParsedGPArticle[];
};
export function GpChipNavigation({ articles }: GpChipNavigationProps) {
const view = useGpViews();
const articleCount = getArticleCounts(
articles,
view.undertema,
view.innholdstype,
);
return (
<VStack gap="4">
<GpChipRow
type="undertema"
entries={Object.entries(articleCount.undertemaCounts)}
/>
<GpChipRow
type="innholdstype"
entries={Object.entries(articleCount.innholdstypeCounts)}
/>
</VStack>
);
}
@@ -0,0 +1,81 @@
import cl from "clsx";
import omit from "lodash/omit";
import { useRouter } from "next/router";
import { useId } from "react";
import { FileFillIcon, TagFillIcon } from "@navikt/aksel-icons";
import { Label } from "@navikt/ds-react";
import { GpChip } from "@/layout/god-praksis-page/chipnavigation/GpChip";
import { safeString } from "@/layout/god-praksis-page/useGpViews";
import { capitalize } from "@/utils";
import styles from "./Chips.module.css";
export type ChipsRenderData = { title: string; count: number }[];
type GpChipRowProps = {
type: "innholdstype" | "undertema";
entries: [string, number][];
};
export function GpChipRow({ type, entries }: GpChipRowProps) {
const id = useId();
const { query, replace } = useRouter();
const reset = () => {
replace({ query: omit(query, [type]) }, undefined, {
shallow: true,
});
};
const handleClick = (titleRaw: string) => {
query[type] === titleRaw
? reset()
: replace({ query: { ...query, [type]: titleRaw } }, undefined, {
shallow: true,
});
};
return (
<div
/* TODO: After release of new GP structure this could be removed since all articles will required proper data, thus always having chips */
className="hidden has-[.chiplist]:grid"
>
<Label
as="h2"
className={cl("flex items-center gap-1 text-aksel-heading", {
"text-violet-600": type === "innholdstype",
"text-teal-700": type === "undertema",
})}
>
{type == "undertema" && <TagFillIcon fontSize="20" aria-hidden />}
{type == "innholdstype" && <FileFillIcon fontSize="20" aria-hidden />}
{capitalize(type)}
</Label>
<div className="relative mt-2">
<ul id={id} className={cl("flex flex-wrap gap-2 p-1", styles.chips)}>
<GpChip
type={type}
disabled={false}
onClick={reset}
pressed={!safeString(query?.[type])}
>
{`Alle (${entries.reduce((acc, [, count]) => acc + count, 0)})`}
</GpChip>
{entries.map(([entryName, count]) => {
return (
<li key={entryName} className="chiplist">
<GpChip
type={type}
disabled={count === 0}
onClick={() => handleClick(entryName)}
pressed={entryName === safeString(query?.[type])}
>{`${entryName} (${count})`}</GpChip>
</li>
);
})}
</ul>
</div>
</div>
);
}
@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from "@storybook/react";
import { HStack } from "@navikt/ds-react";
import { AkselTheme } from "@/sb-util";
import { GpTemaLink } from "./GpTemaLink";
const meta = {
title: "Layout/god-praksis/chips/TemaLink",
component: GpTemaLink,
tags: ["autodocs"],
decorators: [AkselTheme],
} satisfies Meta<typeof GpTemaLink>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Tema: Story = {
args: {
children: "Innholdstarbeid",
href: "/gp/123",
},
};
export const Group = {
render: () => (
<HStack gap="2">
<GpTemaLink href="#">Universell utforming</GpTemaLink>
<GpTemaLink href="#">Innholdsarbeid</GpTemaLink>
<GpTemaLink href="#">Universell utforming</GpTemaLink>
<GpTemaLink href="#">Innholdsarbeid</GpTemaLink>
</HStack>
),
};
@@ -0,0 +1,25 @@
import NextLink from "next/link";
import { ChevronRightIcon, TagFillIcon } from "@navikt/aksel-icons";
type GpTemaLinkProps = {
children?: React.ReactNode;
href: string;
};
export function GpTemaLink({ children, href, ...rest }: GpTemaLinkProps) {
return (
<NextLink
href={href}
className="group flex min-h-8 w-fit items-center whitespace-nowrap rounded-full bg-teal-100 px-2 py-1 ring-1 ring-inset ring-teal-700 hover:underline focus:outline-none focus-visible:shadow-focus-gap"
{...rest}
>
<TagFillIcon className="text-teal-700" aria-hidden />
<span className="ml-05 leading-none text-text-default">{children}</span>
<ChevronRightIcon
fontSize="1.5rem"
aria-hidden
className="text-teal-700 transition-transform group-hover:translate-x-0.5 group-focus-visible:translate-x-0.5"
/>
</NextLink>
);
}
@@ -1,77 +0,0 @@
import { useMemo } from "react";
import { VStack } from "@navikt/ds-react";
import { ChipsData } from "@/layout/god-praksis-page/interface";
import useGpQuery from "@/layout/god-praksis-page/useGpQuery";
import ChipNav from "./ChipNav";
export type ChipsRenderData = { title: string; count: number }[];
const countUniques = (
type: "innholdstype" | "undertema",
data: ChipsData,
query: string | null,
comparisonQuery: string | null,
): ChipsRenderData => {
const lens =
type === "innholdstype" ? "innholdstype-title" : "undertema-title";
const lensFlipped =
type === "innholdstype" ? "undertema-title" : "innholdstype-title";
const map = new Map<string, number>();
data.forEach((entry) => {
let add = false;
if (comparisonQuery && query) {
add = entry[lens] === query;
}
if (comparisonQuery && !query) {
add = entry[lensFlipped] === comparisonQuery;
} else if (comparisonQuery && query) {
add = entry[lensFlipped] === comparisonQuery && entry[lens] === query;
} else {
add = true;
}
const count = map.get(entry[lens]) || 0;
map.set(entry[lens], count + (add ? 1 : 0));
});
const chipData: { title: string; count: number }[] = [];
for (const [key, value] of map.entries()) {
chipData.push({ title: key, count: value });
}
return chipData;
};
type ChipsGroupProps = { data: ChipsData; showTema: boolean };
function ChipsGroup(props: ChipsGroupProps) {
const { innholdstypeQuery, undertemaQuery } = useGpQuery();
const list = useMemo(() => {
return {
innholdstype: countUniques(
"innholdstype",
props.data,
innholdstypeQuery,
undertemaQuery,
),
undertema: countUniques(
"undertema",
props.data,
undertemaQuery,
innholdstypeQuery,
),
};
}, [props.data, innholdstypeQuery, undertemaQuery]);
return (
<VStack gap="4">
{props.showTema && <ChipNav type="undertema" data={list.undertema} />}
<ChipNav type="innholdstype" data={list.innholdstype} />
</VStack>
);
}
export default ChipsGroup;
@@ -1,86 +0,0 @@
import cl from "clsx";
import omit from "lodash/omit";
import { useRouter } from "next/router";
import { useId } from "react";
import { FileFillIcon, TagFillIcon } from "@navikt/aksel-icons";
import { HGrid, Label } from "@navikt/ds-react";
import { capitalize } from "@/utils";
import styles from "./Chips.module.css";
import ScrollFade from "./ScrollFade";
export type ChipsRenderData = { title: string; count: number }[];
type ChipsNavProps = {
type: "innholdstype" | "undertema";
data?: ChipsRenderData;
};
/**
* TODO:
* - On initial load, scroll selected into view
*/
function ChipNav({ type, data }: ChipsNavProps) {
const id = useId();
const { query, replace } = useRouter();
const handleClick = async (titleRaw: string) => {
const title = encodeURIComponent(titleRaw);
query[type] === title
? replace({ query: omit(query, [type]) }, undefined, {
shallow: true,
})
: replace({ query: { ...query, [type]: title } }, undefined, {
shallow: true,
});
};
return (
<HGrid gap="2" columns={{ md: 1, lg: "auto 1fr" }} align="center">
<Label
as="span"
className={cl("flex items-center gap-2 text-aksel-heading", {
"text-violet-600": type === "innholdstype",
"text-teal-700": type === "undertema",
})}
>
{type == "undertema" && <TagFillIcon fontSize="20" aria-hidden />}
{type == "innholdstype" && <FileFillIcon fontSize="20" aria-hidden />}
{capitalize(type)}
</Label>
<div className="relative overflow-hidden scroll-smooth">
<ScrollFade id={id} />
<ul
id={id}
className={cl("flex gap-2 overflow-x-scroll p-1", styles.chips)}
>
{data?.map((entry) => (
<li key={entry.title}>
<button
aria-pressed={encodeURIComponent(entry.title) === query?.[type]}
onClick={() => handleClick(entry.title)}
className={cl(
"grid min-h-8 place-content-center whitespace-nowrap rounded-full bg-surface-neutral-subtle px-3 py-1 ring-1 ring-inset transition-opacity focus:outline-none focus-visible:shadow-focus-gap aria-pressed:text-text-on-inverted",
"disabled:bg-surface-neutral-subtle disabled:opacity-40 disabled:ring-border-default",
{
"ring-violet-700/50 hover:bg-violet-50 aria-pressed:bg-violet-700 hover:aria-pressed:bg-violet-800":
type === "innholdstype",
"ring-teal-700/50 hover:bg-teal-50 aria-pressed:bg-teal-700 hover:aria-pressed:bg-teal-800":
type === "undertema",
},
)}
disabled={entry.count === 0}
>
{`${entry.title} (${entry.count})`}
</button>
</li>
))}
</ul>
</div>
</HGrid>
);
}
export default ChipNav;
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff. Show more