Compare commits

...
Author SHA1 Message Date
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
github-actions[bot]andgithub-actions[bot] 638e8260b4 Version Packages (#2811)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-02 08:45:10 +02:00
Halvor Haugan d991011690 FileUpload.Dropzone: id prop settes på button (#2817) 2024-03-22 11:03:05 +01:00
KenandHalvor Haugan b6086f7b1f 📝 Oppdatert radio-error dokumentasjon (#2800)
* 📝 Oppdatert radio-error dokumentasjon

* Update aksel.nav.no/website/pages/eksempler/radio/error.tsx

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

* Update aksel.nav.no/website/pages/eksempler/radio/error.tsx

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

* Update aksel.nav.no/website/pages/eksempler/radio/error.tsx

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

---------

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2024-03-22 10:42:38 +01:00
Ken 05462081aa useControllableState i Expansioncard og Switch state-bug (#2810)
* ♻️ Bruker controllableState i ExpansionCard

* 🐛 Fikset tilfelle der checked switch var visuelt unchecked

* 📝 Changeset

* 🔥 Fjernet utkommentert kode
2024-03-22 10:24:35 +01:00
Ken 63e42de3ee 🆙 Oppdatert Playroom (#2815) 2024-03-22 09:31:00 +01:00
Halvor Haugan 2e85178166 yarn.lock sync 2024-03-21 16:36:11 +01:00
Ken 456d4e2e07 🐛 Slack var ikke i outbound accessPolicy for feedback 2024-03-21 11:52:48 +01:00
Ken b5a0cb8cdf Oppdatert logging for Aksel.nav.no + bumpet feedback pagination page-size (#2812)
* 🆙 Oppgradert logging

* 🎨 Bedre loggeoutput

* 🎨 Bruker childlogger

* 🧪 Custome error-logg

* ⚡ Større paginering-page for slackusers

* 📝 error -> info
2024-03-21 11:36:43 +01:00
Ken a2af9bcc37 Lagt til mer padding-right på ReadMore (#2806)
* 💄 Oppdatert padding-right på readmore

* 📝 Changeset

* 🐛 Manglet chromatic-disable på readmore story
2024-03-21 10:16:14 +01:00
bd45754adb [aksel.nav.no] Slackbot for feedback (#2785)
* 🎉 Satt opp boilerplate for auth på Aksel

* 🎉 Auth boilerplate

* 🎨 Demo-setup for å teste auth i dev

* 🚚 Flyttet auth til getserversideprops

* 🔥 Fjernet authprovider fra _app

* 🐛 revalidate er ikke valid key for getserversideprops i nextjs

* 🚚 rename auth-dir

* 🔥 Fjernet auth types

* 🎉 Auth for api

* 🎨 Oppdatert workflows med token

* 🔥 Fjernet testing av auth på god-praksis sider

* 🎨 La til sidecar på prod-deploy

* 🐛 only return boolean in Promise<boolean>

* ✨ feedback form

* 🎨 showcase all states of new FeedBackForm

* 🔥 remove prop name (will use auth api)

* 🎨 use getAuthUser()

* 🎨 use useAuth() for login/logout

* 🎉 Slack feedback-api

* 🐛 handle case where token is null

* 🎨 scroll to anchor on login redirect

* ⚡ Utility-funksjoner for slack-api

* 🧪 Tests for slack-utilities

* 🎨 Ryddet opp i henting av slack memebers

* 🚚 Flyttet caching inn i funkson

* 📱 make FeedbackForm responsive

* 🎉 Strengere typet slack-utilities

* 🎨 Fikset typefeil i feedback-api

* ⬆️ Migrert til @navikt/oasis

* 🏷️ Satt typer til strict for å kjøre tester

* 🎨 Slackbot-API with protected auth and actual user

* 🏷️ strengere typer

* ⚡ Implementert paginering av slacbot

* 🎨 Lagt til slackbot-token i workflows

* 🏷️ strict tsconfig

* 🏷️ Typer for slackbot input

* 🔥 Fjernet demo-body

* 🎨 oppdatert medvirkende tekst

* ⬆️ Yarn lock

* 🎨 move state handling down to Feedback

* 🏷️ typing fixes

* 🚧 API usage

* 🏷️ fix type error

* 👷 update AccessPolicy

* 🦺 add validation to feedback form

* 💄 justify-items-stretch on TextArea needed 🤔

* ♻️ avoid prop drilling, use sanity data hook for document id

* 🔧 add auth to webpack config for storybook

* ♻️ Feedback form  (also add error state handling)

* 👷 add stories for Feedback form

* 🔊 log feedback results metadata

* ♻️ remove refs, use formData and event.currentTarget

* 🧪 retry flaky test

* ⚗️ test promises for dynamic import in test

* 🧪 flaky test config

* ⬆️ yarn lock sync

* ⬆️ yarn lock sync

* ♻️ Bruker nå egen userState-type for fortelle komponenter om bruker er logget inn eller ikke

* 🔥 remove unused CSS

* 📝 lagt til todods

* 🔥 remove anonymous feedback submission

* 🔥 remove CSS

* :goals_net: catch fetch clientside, feedback

* ✨ send feedback to private slack channel as well

notify everyone if there are 0 editors

* 💄 + ♿ aria-hiddens and better styling, Feedback

* ♻️ refactor + add more feedback (loading state + alert)

* ⚗️ test encoding uri for anchor (redirect)

* ⚗️ more testing URL redirect

* ⚗️ more testing

* ⚗️ testing

* ✅ stories for Feedback

* 🔒 sanitize user objects (then cache)

* 🏷️ type SanitizeUser in find-user

* 🐛 optionally render the message about zero editors

* ⚗️ more testing for URL redirect / fragment issue

* ⚗️ log the redirect URL

* ⚗️ more testing

* ♻️ Gjennbruker error-return for feedback-api

* 🧪 Tester alternativ løsning for scroll etter login

* 🐛 Query update slapte loop i useEffect

* 🧪 Tester alternativ løsning for scroll etter login

* 🚚 Flyttet autofocus inn i headingseksjon

* 🐛 Wonderwall fjerner & fra query

* 🐛 encodeUrl as defined in wonderwall docs

* 🧪 Reversert til anchor-løsning

* 🧪 Reversert til anchor-løsning med encoding

* 🐛 Fikset edgecase der asPath med allerede eksisterende hash brakk login-redirect

* 🔥 Cleanup etter anchor-testing

* ⬆️ yarn lock sync

* 🎨 Oppdatert til navikt channelid

* 📝 Endret på error-tekst i feedback

* Fjernet ts-expect-error

* ⬆️ yarn lock sync

* 🔥 Fjernet filterering på godkjent mail

* 📝 Oppdatert slackbot-block

---------

Co-authored-by: Julian Nymark <julian.nymark@nav.no>
Co-authored-by: Halvor Haugan <halvor.haugan@nav.no>
2024-03-20 15:48:49 +01:00
github-actions[bot]andgithub-actions[bot] a882a8a389 Version Packages (#2807)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-03-20 13:26:51 +01:00
Ken d1606d358a 🐛 useDatepicker håndterer isInvalid validation riktig (#2809)
* 🐛 useDatepicker håndterer isInvalid validation riktig nå

* Update @navikt/core/react/src/date/hooks/useDatepicker.tsx

* Update @navikt/core/react/src/date/hooks/useDatepicker.tsx
2024-03-20 13:21:05 +01:00
Ken bb87837632 🧪 Bump timeout and retry of dateStr testing 2024-03-20 12:33:56 +01:00
Halvor Haugan 9c8b2301ed FileUpload.Dropzone: Fiks feil med duplisert id (#2805) 2024-03-20 12:31:08 +01:00
Ken 54ba483d2d ✅ Oppdatert Chromatic-tester
✅ Oppdatert dropdown-tester

🐛 satt dropdown-story til open

✅ Oppdatert errorsummary-tester

✅ Oppdatert fieldset-tester
2024-03-20 11:51:46 +01:00
github-actions[bot]andgithub-actions[bot] 85f5458c06 Version Packages (#2804)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-03-20 09:51:54 +01:00
Halvor Haugan 19abec80f7 FileUpload: Fiks feil ved import av json (#2803) 2024-03-20 09:46:45 +01:00
Julian NymarkandHalvor Haugan b2ddce5279 📝 Search docs review (#2797)
Co-authored-by: Halvor Haugan <halvor.haugan@nav.no>
2024-03-19 15:39:12 +01:00
github-actions[bot]andgithub-actions[bot] bd7c945158 Version Packages (#2799)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-03-19 15:07:37 +01:00
Ken ca24684a25 🐛 Dropzone med disabled stoppet ikke filopplasting (#2798)
inputfelt settes nå til disabled ved bruk av prop for å unngå dette
2024-03-19 15:02:06 +01:00
Julian Nymark 1564363985 💄 break-normal on body text, but break-all on types, as they… (#2790)
* 💄 break-normal on body text, but break-all on types, as they get long and complicated without spaces

* 🔥 remove break-normal, as it's the default
2024-03-19 14:58:58 +01:00
Ken ca8d36e885 🔥 Fjernet UX-tweaks card på forside
Kode er kommentert ut fra forside, men vil være lett tilgjengelig hvis vi ønsker å legge til fremtidige undersøkelser
2024-03-19 14:43:00 +01:00
github-actions[bot]andgithub-actions[bot] 8efc6c58be Version Packages (#2794)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-03-19 14:00:30 +01:00
2ea2a91a37 Combobox complex options (#2716)
* Remove unused function

* Support for complex options, using objects with label and value instead of strings

* Better typing for isTextInSelectedOptions value (boolean instead of truthy/falsy)

* Stricter value type: Only accept string values. This will work better with the allowNewValues prop, and simplify returning a value from onToggleSelected.

* More JSDoc

* Add test for complex options with label and value

* Add example for complex options on the aksel-website

* yarn.lock

* Remove other properties than label and value from Combobox options in Storybook-examples, as we have limited the format for now

* Array.includes does not work with objects, so should use isInList utility function. Fixes missing checkmark for selected items in FilteredOptions.

* Add changeset

* Remove navds-dependencies in playroom that break playwright tests. These are outdated/unsynced, and will be inherited from parent package.json instead.

* Do not try to remove an undefined option

* "it" is from jest. Use "test" instead.

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

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

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

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

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

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

* Update @navikt/core/react/src/form/combobox/types.ts

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

* Raise change level to minor, to signal the new feature

* Spread props instead of adding them separately

* JSDoc @returns doesn't do anything without a value specified, and neither of these return a value

* Options are used for multiple features, so will just skip specifying them

* Doesn't need a guaranteed unique ID for storybook examples. Use hard-coded instead.

* selectedOptions is a list of strings anyhow, so should just use the value

* More realistic example for complex options, where the option label is larger, and the SelectedOptions label uses a short code (the value)

Also made the isInList function better, by checking both label and value when checking ComboboxOptions. Pass just the value or just the label if you only want to check either of them.

* Add tests for mapToComboboxOptionArray (renamed from mapFromStringArrayToComboboxOptionArray)

* Add test for toComboboxOption and use this function in mapToComboboxOptionArray

* Update combobox unit test for complex options to use a more realistic example

* Don't say anything about what might be added in the future. That doesn't belong in the documentation

* Oppdater beskrivelsen for complex options-eksempelet (var kopiert fra annet eksempel)

* Fix Storybook example bug where de-selecting a value selected it an extra time

* Fix example

* Remove listing options because it breaks with allowNewValues

* Revert earlier change that always showed selected values in FilteredOptions, even if it didn't match the search text. This broke autocomplete.

* Remove unneccessary optional check

* yarn.lock

* Don't need to use toLocaleLowerCase

---------

Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>
Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2024-03-19 13:47:13 +01:00
Halvor HauganandKen aebba20fd4 🆙 Minor-oppgradering av deps (#2795)
Co-authored-by: Ken <ken.aleksander@gmail.com>
2024-03-18 18:51:11 +01:00
Ken 5ce4b4ea79 📱 Tilpasset aksel-header for 320px (#2793) 2024-03-17 15:27:43 +01:00
Ken 548d9bb169 💄 Bedre eksempel for logg-ut i dropdown (#2792) 2024-03-17 15:27:33 +01:00
25e0d15d67 Ny komponent FileUpload (#2504)
---------

Co-authored-by: Ken <ken.aleksander@gmail.com>
Co-authored-by: Halvor Haugan <halvor.haugan@nav.no>
2024-03-15 12:54:35 +01:00
Halvor Haugan 98f6d7dde4 Forbedret beskrivelse for ref i prop-tabell (#2791) 2024-03-15 10:04:09 +01:00
196 changed files with 8890 additions and 5116 deletions

No files matched your search

+1
View File
@@ -25,6 +25,7 @@ jobs:
- name: add tokens to .env
run: |
echo "SLACK_BOT_TOKEN=${{ secrets.SLACK_BOT_TOKEN }}" >> aksel.nav.no/website/.env
echo "SANITY_WRITE_KEY=${{ secrets.SANITY_FEEDBACK_WRITE_KEY }}" >> aksel.nav.no/website/.env
echo "SANITY_PREVIEW_TOKEN=${{ secrets.SANITY_PREVIEW_TOKEN }}" >> aksel.nav.no/website/.env
echo "SANITY_PRIVATE_NO_DRAFTS=${{ secrets.SANITY_PRIVATE_NO_DRAFTS }}" >> aksel.nav.no/website/.env
+1
View File
@@ -59,6 +59,7 @@ jobs:
- name: add tokens to .env
run: |
echo "SLACK_BOT_TOKEN=${{ secrets.SLACK_BOT_TOKEN }}" >> aksel.nav.no/website/.env
echo "SANITY_WRITE_KEY=${{ secrets.SANITY_FEEDBACK_WRITE_KEY }}" >> aksel.nav.no/website/.env
echo "SANITY_PREVIEW_TOKEN=${{ secrets.SANITY_PREVIEW_TOKEN }}" >> aksel.nav.no/website/.env
echo "SANITY_PRIVATE_NO_DRAFTS=${{ secrets.SANITY_PRIVATE_NO_DRAFTS }}" >> aksel.nav.no/website/.env
+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"
+18
View File
@@ -1,5 +1,23 @@
# @navikt/aksel-icons
## 6.4.1
## 6.4.0
## 6.3.6
## 6.3.5
## 6.3.4
## 6.3.3
## 6.3.2
## 6.3.1
## 6.3.0
## 6.2.0
### Minor Changes
+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.2.0",
"version": "6.4.1",
"author": "Aksel | NAV",
"homepage": "https://aksel.nav.no/ikoner",
"repository": {
+18
View File
@@ -1,5 +1,23 @@
# @navikt/aksel-stylelint
## 6.4.1
## 6.4.0
## 6.3.6
## 6.3.5
## 6.3.4
## 6.3.3
## 6.3.2
## 6.3.1
## 6.3.0
## 6.2.0
## 6.1.1
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-stylelint",
"version": "6.2.0",
"version": "6.4.1",
"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.2.0",
"@navikt/ds-tokens": "^6.2.0",
"@navikt/ds-css": "^6.4.1",
"@navikt/ds-tokens": "^6.4.1",
"concurrently": "7.2.1",
"postcss-selector-parser": "^6.0.13",
"postcss-value-parser": "^4.2.0",
+63
View File
@@ -1,5 +1,68 @@
# @navikt/aksel
## 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
- Updated dependencies [[`a2af9bcc3`](https://github.com/navikt/aksel/commit/a2af9bcc37eb12cc7cf3a1596d57dc0421898657)]:
- @navikt/ds-css@6.3.4
## 6.3.3
### Patch Changes
- Updated dependencies []:
- @navikt/ds-css@6.3.3
## 6.3.2
### Patch Changes
- Updated dependencies []:
- @navikt/ds-css@6.3.2
## 6.3.1
### Patch Changes
- Updated dependencies []:
- @navikt/ds-css@6.3.1
## 6.3.0
### Patch Changes
- Updated dependencies [[`25e0d15d6`](https://github.com/navikt/aksel/commit/25e0d15d67cebd36ee58aa3b7d9f1c7104d92de6)]:
- @navikt/ds-css@6.3.0
## 6.2.0
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel",
"version": "6.2.0",
"version": "6.4.1",
"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.2.0",
"@navikt/ds-css": "6.4.1",
"axios": "1.6.0",
"chalk": "4.1.0",
"clipboardy": "^2.3.0",
+30
View File
@@ -1,5 +1,35 @@
# @navikt/ds-css
## 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
- ReadMore: Padding-right på button er bumpet noen px ([#2806](https://github.com/navikt/aksel/pull/2806))
## 6.3.3
## 6.3.2
## 6.3.1
## 6.3.0
### Minor Changes
- FileUpload: Ny komponent `FileUpload` for å håndtere opplasting av filer, Dropzone og fil-visning ([#2504](https://github.com/navikt/aksel/pull/2504))
## 6.2.0
## 6.1.1
+5
View File
@@ -120,6 +120,11 @@ const StyleMappings = {
dependencies: [typoCss],
},
{ component: "Fieldset", main: formCss, dependencies: [typoCss] },
{
component: "FileUpload",
main: formCss,
dependencies: [typoCss, "button.css", "loader.css", "link.css"],
},
{ component: "Grid", main: "grid.css", dependencies: [typoCss] },
{
component: "GuidePanel",
+230
View File
@@ -0,0 +1,230 @@
/**
* Makes it easier for user to use FileItem in semantic lists
*/
.navds-file-upload :is(ul, li) {
list-style: none;
margin: 0;
padding: 0;
}
/**
* FileUpload.Dropzone
*/
.navds-dropzone__area {
--__ac-dropzone-background: var(--a-surface-subtle);
--__ac-dropzone-text-color: var(--a-text-default);
--__ac-dropzone-padding: var(--a-spacing-6);
--__ac-dropzone-icon-padding: var(--a-spacing-2);
--__ac-dropzone-animation-length-long: 0.6s;
--__ac-dropzone-animation-length-short: 250ms;
--__ac-dropzone-animation-ease-out: cubic-bezier(0.3, 1, 0.3, 1);
--__ac-dropzone-animation-over-under: cubic-bezier(0.3, 1.4, 0.3, 1);
position: relative;
width: 100%;
display: flex;
align-items: center;
flex-direction: column;
gap: var(--a-spacing-4);
text-align: center;
padding: var(--__ac-dropzone-padding);
outline: 1px dashed var(--a-border-subtle);
outline-offset: -1px;
border-radius: var(--a-border-radius-large);
background-color: var(--__ac-dropzone-background);
color: var(--__ac-dropzone-text-color);
transition: background-color var(--__ac-dropzone-animation-length-short) var(--__ac-dropzone-animation-ease-out);
cursor: pointer;
}
.navds-dropzone__area:hover {
box-shadow: inset 0 2px 4px 1px rgb(11 11 11/ 0.05);
outline-color: var(--a-border-default);
}
.navds-dropzone--disabled > .navds-dropzone__area:hover {
outline-color: var(--a-border-subtle);
box-shadow: initial;
cursor: default;
}
.navds-dropzone--dragging > .navds-dropzone__area {
--__ac-dropzone-background: var(--a-surface-action-subtle-hover);
}
.navds-dropzone--dragging > .navds-dropzone__area::after {
outline: 1px dashed var(--a-border-subtle);
outline-offset: -1px;
color: var(--a-text-action-selected);
background-color: var(--a-surface-hover);
content: "";
inset: 0;
position: absolute;
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
box-shadow: inset 0 2px 7px 3px rgb(11 11 11/ 0.1);
border-radius: var(--a-border-radius-large);
animation: akselDropzoneDragoverAnimation var(--__ac-dropzone-animation-length-short) var(--__ac-dropzone-animation-ease-out);
}
@keyframes akselDropzoneDragoverAnimation {
0% {
-webkit-backdrop-filter: blur(0);
backdrop-filter: blur(0);
background-color: var(--a-surface-transparent);
}
70% {
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
100% {
background-color: var(--a-surface-hover);
}
}
.navds-dropzone__area-icon {
display: grid;
padding: var(--__ac-dropzone-icon-padding);
visibility: hidden;
}
.navds-dropzone--error:where(:not(.navds-dropzone--dragging)) > .navds-dropzone__area {
outline-color: var(--a-surface-danger);
outline-width: 2px;
}
.navds-dropzone__area:hover > .navds-dropzone__area-button {
color: var(--ac-button-secondary-hover-text, var(--__ac-button-secondary-hover-text, var(--a-text-action-on-action-subtle)));
background-color: var(
--ac-button-secondary-hover-bg,
var(--__ac-button-secondary-hover-bg, var(--a-surface-action-subtle-hover))
);
}
.navds-dropzone__area:active .navds-dropzone__area-button {
color: var(--ac-button-secondary-active-text, var(--__ac-button-secondary-active-text, var(--a-text-on-action)));
background-color: var(--ac-button-secondary-active-bg, var(--__ac-button-secondary-active-bg, var(--a-surface-action-active)));
box-shadow: none;
}
.navds-dropzone__area-release {
top: var(--__ac-dropzone-padding);
display: grid;
position: absolute;
z-index: 1;
pointer-events: none;
transition:
top var(--__ac-dropzone-animation-length-long) var(--__ac-dropzone-animation-over-under),
transform var(--__ac-dropzone-animation-length-long) var(--__ac-dropzone-animation-over-under);
}
.navds-dropzone__area-release__icon {
display: grid;
padding: var(--__ac-dropzone-icon-padding);
border-radius: var(--a-border-radius-full);
background-color: var(--a-surface-neutral-subtle);
transition:
background-color 300ms var(--__ac-dropzone-animation-ease-out),
font-size 300ms var(--__ac-dropzone-animation-ease-out);
font-size: 1.5rem;
}
.navds-dropzone--dragging .navds-dropzone__area-release > .navds-dropzone__area-release__icon {
background-color: transparent;
font-size: 2rem;
}
.navds-dropzone__area-release__text {
visibility: hidden;
}
.navds-dropzone--dragging .navds-dropzone__area-release > .navds-dropzone__area-release__text {
transition: visibility var(--__ac-dropzone-animation-length-long) var(--__ac-dropzone-animation-over-under);
visibility: visible;
}
.navds-dropzone--dragging .navds-dropzone__area-release {
color: var(--a-text-action-selected);
top: 50%;
transform: translateY(-50%);
}
.navds-dropzone__area-disabled {
color: var(--a-text-subtle);
display: flex;
align-items: center;
flex-direction: column;
gap: var(--a-spacing-2);
}
/**
* FileUpload.Item
*/
.navds-file-item__inner {
outline: 1px solid var(--a-border-subtle);
outline-offset: -1px;
transition: outline-color 250ms cubic-bezier(0, 0.3, 0.15, 1);
border-radius: var(--a-border-radius-large);
padding: var(--a-spacing-3);
display: flex;
gap: var(--a-spacing-3);
align-items: flex-start;
}
.navds-file-item--error > .navds-file-item__inner {
outline: 2px solid var(--a-surface-danger);
}
.navds-file-item__icon {
background-color: var(--a-surface-neutral-subtle);
color: var(--a-icon-default);
border-radius: var(--a-border-radius-full);
min-height: 3rem;
min-width: 3rem;
display: grid;
place-content: center;
transition: background-color 250ms cubic-bezier(0, 0.3, 0.15, 1);
}
.navds-file-item__icon--loading {
background-color: transparent;
}
.navds-file-item__file-info {
word-break: break-word;
}
.navds-file-item__button {
margin-left: auto;
}
.navds-file-item__error {
color: var(--a-text-danger);
display: grid;
transition-property: grid-template-rows, padding-top;
transition-duration: 250ms;
transition-timing-function: cubic-bezier(0, 0.3, 0.15, 1);
overflow: hidden;
grid-template-rows: 0fr;
}
.navds-file-item--error .navds-file-item__error {
grid-template-rows: 1fr;
padding-top: var(--a-spacing-1);
}
.navds-file-item__error-content > svg {
flex-shrink: 0;
height: 1rem;
margin-top: 0.1rem;
}
.navds-file-item__error-content {
min-height: 0;
display: flex;
gap: var(--a-spacing-1);
}
+4 -2
View File
@@ -18,13 +18,15 @@
}
.navds-form-field .navds-error-message,
.navds-fieldset .navds-error-message {
.navds-fieldset .navds-error-message,
.navds-file-item .navds-error-message {
display: flex;
gap: var(--a-spacing-2);
}
.navds-form-field .navds-error-message::before,
.navds-fieldset .navds-error-message::before {
.navds-fieldset .navds-error-message::before,
.navds-file-item .navds-error-message::before {
content: "•";
}
+1
View File
@@ -2,6 +2,7 @@
Order matters; rearrange with care
*/
@import "fieldset.css";
@import "file-upload.css";
@import "form.css";
@import "error-summary.css";
@import "confirmation-panel.css";
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-css",
"version": "6.2.0",
"version": "6.4.1",
"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.2.0",
"@navikt/ds-tokens": "^6.4.1",
"cssnano": "6.0.0",
"fast-glob": "3.2.11",
"lodash": "4.17.21",
+2 -2
View File
@@ -8,12 +8,12 @@
gap: var(--a-spacing-05);
color: var(--ac-read-more-text, var(--a-text-action));
border-radius: var(--a-border-radius-small);
padding: var(--a-spacing-1) var(--a-spacing-1) var(--a-spacing-1) var(--a-spacing-05);
padding: var(--a-spacing-1) var(--a-spacing-2) var(--a-spacing-1) var(--a-spacing-05);
text-align: start;
}
.navds-read-more--small .navds-read-more__button {
padding: var(--a-spacing-05) var(--a-spacing-1) var(--a-spacing-05) var(--a-spacing-05);
padding: var(--a-spacing-05) var(--a-spacing-1-alt) var(--a-spacing-05) var(--a-spacing-05);
}
@media (forced-colors: active) {
+98
View File
@@ -1,5 +1,103 @@
# @navikt/ds-react
## 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
- 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))
- Updated dependencies []:
- @navikt/ds-tokens@6.3.4
- @navikt/aksel-icons@6.3.4
## 6.3.3
### Patch Changes
- FileUpload.Dropzone: Fiks feil med duplisert id ([#2805](https://github.com/navikt/aksel/pull/2805))
- useDatePicker: `isInvalid`-key i onValidate var hadde flipper boolean i noen tilfeller ([#2809](https://github.com/navikt/aksel/pull/2809))
- Updated dependencies []:
- @navikt/ds-tokens@6.3.3
- @navikt/aksel-icons@6.3.3
## 6.3.2
### Patch Changes
- FileUpload: Fiks feil ved import av json ([#2803](https://github.com/navikt/aksel/pull/2803))
- Updated dependencies []:
- @navikt/ds-tokens@6.3.2
- @navikt/aksel-icons@6.3.2
## 6.3.1
### Patch Changes
- FileUpload: Disabled dropzone stopper opplasting ([#2798](https://github.com/navikt/aksel/pull/2798))
- Updated dependencies []:
- @navikt/ds-tokens@6.3.1
- @navikt/aksel-icons@6.3.1
## 6.3.0
### Minor Changes
- Allow Combobox options as objects to support separate display text and value ([#2716](https://github.com/navikt/aksel/pull/2716))
- FileUpload: Ny komponent `FileUpload` for å håndtere opplasting av filer, Dropzone og fil-visning ([#2504](https://github.com/navikt/aksel/pull/2504))
### Patch Changes
- Updated dependencies []:
- @navikt/ds-tokens@6.3.0
- @navikt/aksel-icons@6.3.0
## 6.2.0
### Patch Changes
+13 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-react",
"version": "6.2.0",
"version": "6.4.1",
"description": "React components from the Norwegian Labour and Welfare Administration.",
"author": "Aksel, a team part of the Norwegian Labour and Welfare Administration.",
"license": "MIT",
@@ -539,6 +539,16 @@
"default": "./cjs/form/textfield/index.js"
}
},
"./FileUpload": {
"import": {
"types": "./esm/form/file-upload/index.d.ts",
"default": "./esm/form/file-upload/index.js"
},
"require": {
"types": "./cjs/form/file-upload/index.d.ts",
"default": "./cjs/form/file-upload/index.js"
}
},
"./package.json": "./package.json"
},
"scripts": {
@@ -552,8 +562,8 @@
},
"dependencies": {
"@floating-ui/react": "0.25.4",
"@navikt/aksel-icons": "^6.2.0",
"@navikt/ds-tokens": "^6.2.0",
"@navikt/aksel-icons": "^6.4.1",
"@navikt/ds-tokens": "^6.4.1",
"@radix-ui/react-tabs": "1.0.0",
"@radix-ui/react-toggle-group": "1.0.0",
"clsx": "^2.1.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;
/**
@@ -160,6 +160,7 @@ export const UseDatepicker = () => {
fromDate: new Date("Aug 23 2019"),
toDate: new Date("Feb 23 2024"),
onDateChange: console.log,
onValidate: console.log,
});
return (
@@ -20,6 +20,7 @@ const DayButton = (props: DayProps) => {
name="day"
ref={buttonRef}
{...dayRender.buttonProps}
role={undefined}
aria-label={dateTime}
aria-hidden={dayRender.activeModifiers.outside}
/>
@@ -272,7 +272,7 @@ export const useDatepicker = (
) {
updateDate(undefined);
updateValidation({
isInvalid: isValidDate(day),
isInvalid: !isValidDate(day),
isWeekend: disableWeekends && isWeekend(day),
isDisabled: disabled && isMatch(day, disabled),
isValidDate: false,
@@ -1,4 +1,4 @@
import { Meta } from "@storybook/react";
import { Meta, StoryObj } from "@storybook/react";
import React, { useState } from "react";
import { Button } from "../button";
import Dropdown from "./Dropdown";
@@ -6,10 +6,15 @@ import Dropdown from "./Dropdown";
export default {
title: "ds-react/Dropdown",
component: Dropdown,
} as Meta;
parameters: {
chromatic: { disable: true },
},
} satisfies Meta<typeof Dropdown>;
export const Default = () => {
return (
type Story = StoryObj<typeof Dropdown>;
export const Default: Story = {
render: () => (
<Dropdown onSelect={(event) => console.log(event)}>
<Dropdown.Toggle>Toggle</Dropdown.Toggle>
<Dropdown.Menu strategy="fixed">
@@ -33,10 +38,10 @@ export const Default = () => {
</Dropdown.Menu.List>
</Dropdown.Menu>
</Dropdown>
);
),
};
export const DefaultOpen = {
export const DefaultOpen: Story = {
render: () => (
<Dropdown onSelect={(event) => console.log(event)} defaultOpen>
<Button as={Dropdown.Toggle}>Toggle</Button>
@@ -57,20 +62,41 @@ export const DefaultOpen = {
</Dropdown.Menu>
</Dropdown>
),
args: { chromatic: { delay: 300 } },
};
export const ControlledOpen = () => {
const [openState, setOpenState] = useState(true);
return (
<Dropdown onSelect={(event) => console.log(event)} open={openState}>
<Button as={Dropdown.Toggle} onClick={() => setOpenState(!openState)}>
Toggle
</Button>
<Dropdown.Menu
strategy="fixed"
onClose={() => console.log("ONCLOSE CONTROLLED")}
>
export const ControlledOpen: Story = {
render: () => {
const [openState, setOpenState] = useState(true);
return (
<Dropdown onSelect={(event) => console.log(event)} open={openState}>
<Button as={Dropdown.Toggle} onClick={() => setOpenState(!openState)}>
Toggle
</Button>
<Dropdown.Menu
strategy="fixed"
onClose={() => console.log("ONCLOSE CONTROLLED")}
>
<Dropdown.Menu.GroupedList>
<Dropdown.Menu.GroupedList.Heading>
Systemer og oppslagsverk
</Dropdown.Menu.GroupedList.Heading>
<Dropdown.Menu.GroupedList.Item
onClick={() => console.log("GroupedList.Item-click")}
>
Gosys
</Dropdown.Menu.GroupedList.Item>
</Dropdown.Menu.GroupedList>
</Dropdown.Menu>
</Dropdown>
);
},
};
export const Chromatic: Story = {
render: () => (
<Dropdown onSelect={(event) => console.log(event)} open>
<Dropdown.Toggle>Toggle</Dropdown.Toggle>
<Dropdown.Menu strategy="fixed">
<Dropdown.Menu.GroupedList>
<Dropdown.Menu.GroupedList.Heading>
Systemer og oppslagsverk
@@ -81,8 +107,18 @@ export const ControlledOpen = () => {
Gosys
</Dropdown.Menu.GroupedList.Item>
</Dropdown.Menu.GroupedList>
<Dropdown.Menu.Divider />
<Dropdown.Menu.List>
<Dropdown.Menu.List.Item onClick={() => console.log("Item-click")}>
Gosys
</Dropdown.Menu.List.Item>
<Dropdown.Menu.List.Item>Psys</Dropdown.Menu.List.Item>
<Dropdown.Menu.List.Item disabled>Infotrygd</Dropdown.Menu.List.Item>
</Dropdown.Menu.List>
</Dropdown.Menu>
</Dropdown>
);
),
parameters: {
chromatic: { disable: false, delay: 300 },
},
};
ControlledOpen.args = { chromatic: { delay: 300 } };
@@ -1,5 +1,6 @@
import cl from "clsx";
import React, { forwardRef, useRef, useState } from "react";
import React, { forwardRef, useRef } from "react";
import { useControllableState } from "../util/hooks";
import { OverridableComponent } from "../util/types";
import ExpansionCardContent, {
ExpansionCardContentProps,
@@ -116,24 +117,24 @@ export const ExpansionCard = forwardRef<HTMLDivElement, ExpansionCardProps>(
},
ref,
) => {
const [_open, _setOpen] = useState(defaultOpen);
const shouldFade = useRef<boolean>(!(Boolean(open) || defaultOpen));
const handleOpen = () => {
if (open === undefined) {
const newOpen = !_open;
_setOpen(newOpen);
onToggle?.(newOpen);
} else {
onToggle?.(!open);
}
shouldFade.current = true;
};
const [_open, _setOpen] = useControllableState({
value: open,
onChange: (newValue) => {
onToggle?.(newValue);
shouldFade.current = true;
},
defaultValue: defaultOpen,
});
return (
<ExpansionCardContext.Provider
value={{ open: open ?? _open, toggleOpen: handleOpen, size }}
value={{
open: open ?? _open,
toggleOpen: () => _setOpen((x) => !x),
size,
}}
>
<section
{...rest}
@@ -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 labling of Checkbox.
* Adds a description to extend the labeling.
*/
description?: string;
}
@@ -3,6 +3,7 @@ import Combobox from "./Combobox";
import { FilteredOptionsProvider } from "./FilteredOptions/filteredOptionsContext";
import { InputContextProvider } from "./Input/inputContext";
import { SelectedOptionsProvider } from "./SelectedOptions/selectedOptionsContext";
import { mapToComboboxOptionArray } from "./combobox-utils";
import { CustomOptionsProvider } from "./customOptionsContext";
import { ComboboxProps } from "./types";
@@ -36,15 +37,15 @@ const ComboboxProvider = forwardRef<HTMLInputElement, ComboboxProps>(
defaultValue,
error,
errorId,
filteredOptions,
filteredOptions: externalFilteredOptions,
id,
isListOpen,
isLoading = false,
isMultiSelect,
onToggleSelected,
selectedOptions,
selectedOptions: externalSelectedOptions,
maxSelected,
options,
options: externalOptions,
value,
onChange,
onClear,
@@ -52,6 +53,9 @@ const ComboboxProvider = forwardRef<HTMLInputElement, ComboboxProps>(
size,
...rest
} = props;
const options = mapToComboboxOptionArray(externalOptions) || [];
const filteredOptions = mapToComboboxOptionArray(externalFilteredOptions);
const selectedOptions = mapToComboboxOptionArray(externalSelectedOptions);
return (
<InputContextProvider
value={{
@@ -5,6 +5,8 @@ import { Loader } from "../../../loader";
import { BodyShort, Label } from "../../../typography";
import { useInputContext } from "../Input/inputContext";
import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
import { isInList, toComboboxOption } from "../combobox-utils";
import { ComboboxOption } from "../types";
import filteredOptionsUtil from "./filtered-options-util";
import { useFilteredOptionsContext } from "./filteredOptionsContext";
@@ -30,8 +32,8 @@ const FilteredOptions = () => {
const { isMultiSelect, selectedOptions, toggleOption, maxSelected } =
useSelectedOptionsContext();
const isDisabled = (option) =>
maxSelected?.isLimitReached && !selectedOptions.includes(option);
const isDisabled = (option: ComboboxOption) =>
maxSelected?.isLimitReached && !isInList(option.value, selectedOptions);
const shouldRenderNonSelectables =
maxSelected?.isLimitReached || // Render maxSelected message
@@ -102,8 +104,8 @@ const FilteredOptions = () => {
}
}}
onPointerUp={(event) => {
toggleOption(value, event);
if (!isMultiSelect && !selectedOptions.includes(value))
toggleOption(toComboboxOption(value), event);
if (!isMultiSelect && !isInList(value, selectedOptions))
toggleIsListOpen(false);
}}
id={filteredOptionsUtil.getAddNewOptionId(id)}
@@ -132,21 +134,23 @@ const FilteredOptions = () => {
className={cl("navds-combobox__list-item", {
"navds-combobox__list-item--focus":
activeDecendantId ===
filteredOptionsUtil.getOptionId(id, option),
"navds-combobox__list-item--selected":
selectedOptions.includes(option),
filteredOptionsUtil.getOptionId(id, option.label),
"navds-combobox__list-item--selected": isInList(
option.value,
selectedOptions,
),
})}
data-no-focus={isDisabled(option) || undefined}
id={filteredOptionsUtil.getOptionId(id, option)}
key={option}
id={filteredOptionsUtil.getOptionId(id, option.label)}
key={option.label}
tabIndex={-1}
onMouseMove={() => {
if (
activeDecendantId !==
filteredOptionsUtil.getOptionId(id, option)
filteredOptionsUtil.getOptionId(id, option.label)
) {
virtualFocus.moveFocusToElement(
filteredOptionsUtil.getOptionId(id, option),
filteredOptionsUtil.getOptionId(id, option.label),
);
setIsMouseLastUsedInputDevice(true);
}
@@ -156,16 +160,19 @@ const FilteredOptions = () => {
return;
}
toggleOption(option, event);
if (!isMultiSelect && !selectedOptions.includes(option)) {
if (
!isMultiSelect &&
!isInList(option.value, selectedOptions)
) {
toggleIsListOpen(false);
}
}}
role="option"
aria-selected={selectedOptions.includes(option)}
aria-selected={isInList(option.value, selectedOptions)}
aria-disabled={isDisabled(option) || undefined}
>
<BodyShort size={size}>{option}</BodyShort>
{selectedOptions.includes(option) && <CheckmarkIcon />}
<BodyShort size={size}>{option.label}</BodyShort>
{isInList(option.value, selectedOptions) && <CheckmarkIcon />}
</li>
))}
</ul>
@@ -1,17 +1,13 @@
import { ComboboxOption } from "../types";
const normalizeText = (text: string): string =>
typeof text === "string" ? text.toLocaleLowerCase().trim() : "";
const isPartOfText = (value, text) =>
const isPartOfText = (value: string, text: string) =>
normalizeText(text).startsWith(normalizeText(value ?? ""));
const isValueInList = (value, list) =>
list?.find((listItem) => normalizeText(value) === normalizeText(listItem));
const getMatchingValuesFromList = (value, list, alwaysIncluded) =>
list?.filter(
(listItem) =>
isPartOfText(value, listItem) || alwaysIncluded.includes(listItem),
);
const getMatchingValuesFromList = (value: string, list: ComboboxOption[]) =>
list.filter((listItem) => isPartOfText(value, listItem.label));
const getFilteredOptionsId = (comboboxId: string) =>
`${comboboxId}-filtered-options`;
@@ -34,7 +30,6 @@ const getMaxSelectedOptionsId = (comboboxId: string) =>
export default {
normalizeText,
isPartOfText,
isValueInList,
getMatchingValuesFromList,
getFilteredOptionsId,
getAddNewOptionId,
@@ -10,21 +10,18 @@ import React, {
import { useClientLayoutEffect, usePrevious } from "../../../util/hooks";
import { useInputContext } from "../Input/inputContext";
import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
import { toComboboxOption } from "../combobox-utils";
import { useCustomOptionsContext } from "../customOptionsContext";
import { ComboboxProps } from "../types";
import { ComboboxOption, ComboboxProps } from "../types";
import filteredOptionsUtils from "./filtered-options-util";
import useVirtualFocus, { VirtualFocusType } from "./useVirtualFocus";
type FilteredOptionsProps = {
children: any;
value: Pick<
ComboboxProps,
| "allowNewValues"
| "filteredOptions"
| "isListOpen"
| "isLoading"
| "options"
>;
children: React.ReactNode;
value: Pick<ComboboxProps, "allowNewValues" | "isListOpen" | "isLoading"> & {
filteredOptions?: ComboboxOption[];
options: ComboboxOption[];
};
};
type FilteredOptionsContextType = {
@@ -36,12 +33,12 @@ type FilteredOptionsContextType = {
>;
isListOpen: boolean;
isLoading?: boolean;
filteredOptions: string[];
filteredOptions: ComboboxOption[];
isMouseLastUsedInputDevice: boolean;
setIsMouseLastUsedInputDevice: React.Dispatch<SetStateAction<boolean>>;
isValueNew: boolean;
toggleIsListOpen: (newState?: boolean) => void;
currentOption?: string;
currentOption?: ComboboxOption;
shouldAutocomplete?: boolean;
virtualFocus: VirtualFocusType;
};
@@ -71,7 +68,7 @@ export const FilteredOptionsProvider = ({
setSearchTerm,
shouldAutocomplete,
} = useInputContext();
const { selectedOptions, maxSelected } = useSelectedOptionsContext();
const { maxSelected } = useSelectedOptionsContext();
const [isInternalListOpen, setInternalListOpen] = useState(false);
const { customOptions } = useCustomOptionsContext();
@@ -81,18 +78,8 @@ export const FilteredOptionsProvider = ({
return externalFilteredOptions;
}
const opts = [...customOptions, ...options];
return filteredOptionsUtils.getMatchingValuesFromList(
searchTerm,
opts,
selectedOptions,
);
}, [
customOptions,
externalFilteredOptions,
options,
searchTerm,
selectedOptions,
]);
return filteredOptionsUtils.getMatchingValuesFromList(searchTerm, opts);
}, [customOptions, externalFilteredOptions, options, searchTerm]);
const previousSearchTerm = usePrevious(searchTerm);
@@ -104,11 +91,11 @@ export const FilteredOptionsProvider = ({
options.reduce(
(map, _option) => ({
...map,
[filteredOptionsUtils.getOptionId(id, _option)]: _option,
[filteredOptionsUtils.getOptionId(id, _option.label)]: _option,
}),
{
[filteredOptionsUtils.getAddNewOptionId(id)]: allowNewValues
? value
? toComboboxOption(value)
: undefined,
},
),
@@ -123,7 +110,7 @@ export const FilteredOptionsProvider = ({
filteredOptions.length > 0
) {
setValue(
`${searchTerm}${filteredOptions[0].substring(searchTerm.length)}`,
`${searchTerm}${filteredOptions[0].label.substring(searchTerm.length)}`,
);
setSearchTerm(searchTerm);
}
@@ -161,7 +148,10 @@ export const FilteredOptionsProvider = ({
activeOption = filteredOptionsUtils.getNoHitsId(id);
} else if ((value && value !== "") || isLoading) {
if (shouldAutocomplete && filteredOptions[0]) {
activeOption = filteredOptionsUtils.getOptionId(id, filteredOptions[0]);
activeOption = filteredOptionsUtils.getOptionId(
id,
filteredOptions[0].label,
);
} else if (isListOpen && isLoading) {
activeOption = filteredOptionsUtils.getIsLoadingId(id);
}
@@ -43,17 +43,17 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
const onEnter = useCallback(
(event: React.KeyboardEvent) => {
const isTextInSelectedOptions = (text: string) => {
return selectedOptions.find(
(item) => item.toLocaleLowerCase() === text.toLocaleLowerCase(),
const isTextInSelectedOptions = (text: string) =>
selectedOptions.some(
(option) =>
option.label.toLocaleLowerCase() === text.toLocaleLowerCase(),
);
};
if (currentOption) {
event.preventDefault();
// Selecting a value from the dropdown / FilteredOptions
toggleOption(currentOption, event);
if (!isMultiSelect && !isTextInSelectedOptions(currentOption)) {
if (!isMultiSelect && !isTextInSelectedOptions(currentOption.label)) {
toggleIsListOpen(false);
}
} else if (shouldAutocomplete && isTextInSelectedOptions(value)) {
@@ -64,11 +64,15 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
event.preventDefault();
// Autocompleting or adding a new value
const selectedValue =
allowNewValues && isValueNew ? value : filteredOptions[0];
allowNewValues && isValueNew
? { label: value, value }
: filteredOptions[0];
toggleOption(selectedValue, event);
if (
!isMultiSelect &&
!isTextInSelectedOptions(filteredOptions[0] || selectedValue)
!isTextInSelectedOptions(
filteredOptions[0].label || selectedValue.label,
)
) {
toggleIsListOpen(false);
}
@@ -120,7 +124,9 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
if (value === "") {
const lastSelectedOption =
selectedOptions[selectedOptions.length - 1];
removeSelectedOption(lastSelectedOption);
if (lastSelectedOption) {
removeSelectedOption(lastSelectedOption);
}
}
} else if (e.key === "ArrowDown") {
// Check that cursor position is at the end of the input field,
@@ -1,15 +1,16 @@
import React from "react";
import { Chips } from "../../../chips";
import { useInputContext } from "../Input/inputContext";
import { ComboboxOption } from "../types";
import { useSelectedOptionsContext } from "./selectedOptionsContext";
interface SelectedOptionsProps {
selectedOptions?: string[];
selectedOptions?: ComboboxOption[];
size?: "medium" | "small";
children: React.ReactNode;
}
const Option = ({ option }: { option: string }) => {
const Option = ({ option }: { option: ComboboxOption }) => {
const { isMultiSelect, removeSelectedOption } = useSelectedOptionsContext();
const { focusInput } = useInputContext();
@@ -21,11 +22,13 @@ const Option = ({ option }: { option: string }) => {
if (!isMultiSelect) {
return (
<div className="navds-combobox__selected-options--no-bg">{option}</div>
<div className="navds-combobox__selected-options--no-bg">
{option.label}
</div>
);
}
return <Chips.Removable onClick={onClick}>{option}</Chips.Removable>;
return <Chips.Removable onClick={onClick}>{option.label}</Chips.Removable>;
};
const SelectedOptions: React.FC<SelectedOptionsProps> = ({
@@ -37,7 +40,7 @@ const SelectedOptions: React.FC<SelectedOptionsProps> = ({
<Chips className="navds-combobox__selected-options" size={size}>
{selectedOptions.length
? selectedOptions.map((option, i) => (
<Option key={option + i} option={option} />
<Option key={option.label + i} option={option} />
))
: []}
{children}
@@ -7,19 +7,20 @@ import React, {
} from "react";
import { usePrevious } from "../../../util/hooks";
import { useInputContext } from "../Input/inputContext";
import { isInList } from "../combobox-utils";
import { useCustomOptionsContext } from "../customOptionsContext";
import { ComboboxProps, MaxSelected } from "../types";
import { ComboboxOption, ComboboxProps, MaxSelected } from "../types";
type SelectedOptionsContextType = {
addSelectedOption: (option: string) => void;
addSelectedOption: (option: ComboboxOption) => void;
isMultiSelect?: boolean;
removeSelectedOption: (option: string) => void;
prevSelectedOptions?: string[];
selectedOptions: string[];
removeSelectedOption: (option: ComboboxOption) => void;
prevSelectedOptions?: ComboboxOption[];
selectedOptions: ComboboxOption[];
maxSelected?: MaxSelected & { isLimitReached: boolean };
setSelectedOptions: (any) => void;
toggleOption: (
option: string,
option: ComboboxOption,
event: React.KeyboardEvent | React.PointerEvent,
) => void;
};
@@ -35,13 +36,8 @@ export const SelectedOptionsProvider = ({
children: any;
value: Pick<
ComboboxProps,
| "allowNewValues"
| "isMultiSelect"
| "options"
| "selectedOptions"
| "onToggleSelected"
| "maxSelected"
>;
"allowNewValues" | "isMultiSelect" | "onToggleSelected" | "maxSelected"
> & { options: ComboboxOption[]; selectedOptions?: ComboboxOption[] };
}) => {
const { clearInput, focusInput } = useInputContext();
const {
@@ -58,7 +54,9 @@ export const SelectedOptionsProvider = ({
options,
maxSelected,
} = value;
const [internalSelectedOptions, setSelectedOptions] = useState<string[]>([]);
const [internalSelectedOptions, setSelectedOptions] = useState<
ComboboxOption[]
>([]);
const selectedOptions = useMemo(
() =>
externalSelectedOptions ?? [...customOptions, ...internalSelectedOptions],
@@ -66,10 +64,8 @@ export const SelectedOptionsProvider = ({
);
const addSelectedOption = useCallback(
(option: string) => {
const isCustomOption = !options
.map((opt) => opt.toLowerCase())
.includes(option?.toLowerCase?.());
(option: ComboboxOption) => {
const isCustomOption = !isInList(option, options);
if (isCustomOption) {
allowNewValues && addCustomOption(option);
!isMultiSelect && setSelectedOptions([]);
@@ -82,7 +78,7 @@ export const SelectedOptionsProvider = ({
setSelectedOptions([option]);
setCustomOptions([]);
}
onToggleSelected?.(option, true, isCustomOption);
onToggleSelected?.(option.value, true, isCustomOption);
},
[
addCustomOption,
@@ -95,8 +91,8 @@ export const SelectedOptionsProvider = ({
);
const removeSelectedOption = useCallback(
(option: string) => {
const isCustomOption = customOptions.includes(option);
(option: ComboboxOption) => {
const isCustomOption = isInList(option, customOptions);
if (isCustomOption) {
removeCustomOption(option);
} else {
@@ -106,14 +102,17 @@ export const SelectedOptionsProvider = ({
),
);
}
onToggleSelected?.(option, false, isCustomOption);
onToggleSelected?.(option.value, false, isCustomOption);
},
[customOptions, onToggleSelected, removeCustomOption],
);
const toggleOption = useCallback(
(option: string, event: React.KeyboardEvent | React.PointerEvent) => {
if (selectedOptions.includes(option)) {
(
option: ComboboxOption,
event: React.KeyboardEvent | React.PointerEvent,
) => {
if (isInList(option.value, selectedOptions)) {
removeSelectedOption(option);
} else {
addSelectedOption(option);
@@ -130,7 +129,7 @@ export const SelectedOptionsProvider = ({
],
);
const prevSelectedOptions = usePrevious<string[]>(selectedOptions);
const prevSelectedOptions = usePrevious<ComboboxOption[]>(selectedOptions);
const isLimitReached =
!!maxSelected?.limit && selectedOptions.length >= maxSelected.limit;
@@ -0,0 +1,67 @@
import {
isInList,
mapToComboboxOptionArray,
toComboboxOption,
} from "./combobox-utils";
const list = [
{ label: "Hjelpemidler", value: "HJE" },
{ label: "Oppfølging", value: "OPP" },
{ label: "Sykepenger", value: "SYK" },
{ label: "Sykemelding", value: "SYM" },
];
describe("isInList", () => {
test("finds a string value in a list of ComboboxOptions", () => {
expect(isInList("Oppfølging", list)).toBe(true);
expect(isInList("SYM", list)).toBe(true);
expect(isInList("Arbeidsavklaringspenger", list)).toBe(false);
expect(isInList("AAP", list)).toBe(false);
});
test("finds a ComboboxOption in a list of ComboboxOptions", () => {
expect(isInList({ label: "Oppfølging", value: "OPP" }, list)).toBe(true);
expect(isInList({ label: "Sykemelding", value: "SYM" }, list)).toBe(true);
expect(
isInList({ label: "Arbeidsavklaringspenger", value: "AAP" }, list),
).toBe(false);
expect(
isInList({ label: "Arbeidsavklaringspenger", value: "AAP" }, list),
).toBe(false);
});
test("returns false for ComboboxOptions which do not match both label and value", () => {
expect(isInList({ label: "Oppfølging", value: "SYM" }, list)).toBe(false);
expect(isInList({ label: "Sykemelding", value: "OPP" }, list)).toBe(false);
});
});
describe("mapToComboboxOptionArray", () => {
test("maps an array of strings to an array of ComboboxOptions", () => {
const stringArray = ["Hjelpemidler", "Oppfølging", "Sykepenger"];
const comboboxOptions = [
{ label: "Hjelpemidler", value: "Hjelpemidler" },
{ label: "Oppfølging", value: "Oppfølging" },
{ label: "Sykepenger", value: "Sykepenger" },
];
expect(mapToComboboxOptionArray(stringArray)).toEqual(comboboxOptions);
});
test("does not change an array of ComboboxOptions", () => {
const comboboxOptions = [
{ label: "Hjelpemidler", value: "Hjelpemidler" },
{ label: "Oppfølging", value: "Oppfølging" },
{ label: "Sykepenger", value: "Sykepenger" },
];
expect(mapToComboboxOptionArray(comboboxOptions)).toEqual(comboboxOptions);
});
});
describe("toComboboxOption", () => {
test("creates a ComboboxOption from a string", () => {
expect(toComboboxOption("Hjelpemidler")).toEqual({
label: "Hjelpemidler",
value: "Hjelpemidler",
});
});
});
@@ -0,0 +1,32 @@
import { ComboboxOption } from "./types";
/**
*
* @param option ComboboxOption will be compared by both label and value, while a string option is checked if it matches either the label or value in the list.
* @param list
*/
const isInList = (option: ComboboxOption | string, list: ComboboxOption[]) => {
if (typeof option === "string") {
return list.some(
(listItem) => listItem.label === option || listItem.value === option,
);
} else {
return list.some(
(listItem) =>
listItem.label === option.label && listItem.value === option.value,
);
}
};
const toComboboxOption = (value: string): ComboboxOption => ({
label: value,
value,
});
const mapToComboboxOptionArray = (options?: string[] | ComboboxOption[]) => {
return options?.map((option: string | ComboboxOption) =>
typeof option === "string" ? toComboboxOption(option) : option,
);
};
export { isInList, mapToComboboxOptionArray, toComboboxOption };
@@ -1,6 +1,6 @@
import { Meta, StoryFn, StoryObj } from "@storybook/react";
import { expect, fn, userEvent, within } from "@storybook/test";
import React, { useId, useMemo, useRef, useState } from "react";
import React, { useMemo, useRef, useState } from "react";
import { Chips } from "../../chips";
import { TextField } from "../textfield";
import { ComboboxProps, UNSAFE_Combobox } from "./index";
@@ -30,10 +30,10 @@ const options = [
"grape fruit",
];
export const Default: StoryFunction = (props) => {
const id = useId();
return <UNSAFE_Combobox {...props} id={id} />;
};
export const Default: StoryFunction = (props) => (
<UNSAFE_Combobox {...props} id="combobox" />
);
Default.args = {
options,
label: "Hva er dine favorittfrukter?",
@@ -57,10 +57,9 @@ Default.argTypes = {
};
export const MultiSelect: StoryFunction = (props) => {
const id = useId();
return (
<UNSAFE_Combobox
id={id}
id="combobox-with-multiselect"
label="Komboboks - velg flere"
options={props.options}
isMultiSelect={props.isMultiSelect}
@@ -75,11 +74,55 @@ MultiSelect.args = {
size: "medium",
};
export const MultiSelectWithComplexOptions: StoryFunction = (props) => {
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
return (
<>
<UNSAFE_Combobox
{...props}
options={props.options.map((option) => ({
...option,
label: `${option.label} [${option.value}]`,
}))}
id="combobox-with-complex-options"
label="Velg temakoder"
allowNewValues
onToggleSelected={(value, isSelected) =>
isSelected
? setSelectedOptions([...selectedOptions, value])
: setSelectedOptions(selectedOptions.filter((o) => o !== value))
}
selectedOptions={selectedOptions}
/>
</>
);
};
MultiSelectWithComplexOptions.args = {
options: [
{ label: "Hjelpemidler", value: "HJE" },
{ label: "Oppfølging", value: "OPP" },
{ label: "Sykepenger", value: "SYK" },
{ label: "Sykemelding", value: "SYM" },
{ label: "Foreldre- og svangerskapspenger", value: "FOR" },
{ label: "Arbeidsavklaringspenger", value: "AAP" },
{ label: "Uføretrygd", value: "UFO" },
{ label: "Pensjon", value: "PEN" },
{ label: "Barnetrygd", value: "BAR" },
{ label: "Kontantstøtte", value: "KON" },
{ label: "Bostøtte", value: "BOS" },
{ label: "Barnebidrag", value: "BBI" },
{ label: "Bidragsforskudd", value: "BIF" },
{ label: "Grunn- og hjelpestønad", value: "GRU" },
],
isMultiSelect: true,
size: "medium",
};
export const WithAddNewOptions: StoryFunction = (props) => {
const id = useId();
return (
<UNSAFE_Combobox
id={id}
id="combobox-with-add-new-options"
label="Komboboks med mulighet for å legge til nye verdier"
options={props.options}
allowNewValues={props.allowNewValues}
@@ -95,10 +138,9 @@ WithAddNewOptions.args = {
};
export const MultiSelectWithAddNewOptions: StoryFunction = (props) => {
const id = useId();
return (
<UNSAFE_Combobox
id={id}
id="combobox-with-multiselect-and-add-new-options"
isMultiSelect={props.isMultiSelect}
label="Multiselect komboboks med mulighet for å legge til nye verdier"
options={props.options}
@@ -120,7 +162,6 @@ export const MultiSelectWithExternalChips: StoryFn<{
}> = (props) => {
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
const [value, setValue] = useState("");
const id = useId();
const toggleSelected = (option: string) =>
selectedOptions.includes(option)
@@ -142,6 +183,7 @@ export const MultiSelectWithExternalChips: StoryFn<{
</Chips>
)}
<UNSAFE_Combobox
id="combobox-with-external-chips"
options={props.options}
selectedOptions={selectedOptions}
onToggleSelected={(option) => toggleSelected(option)}
@@ -154,7 +196,6 @@ export const MultiSelectWithExternalChips: StoryFn<{
}
label="Komboboks"
size="medium"
id={id}
shouldShowSelectedOptions={false}
/>
</>
@@ -166,19 +207,16 @@ MultiSelectWithExternalChips.args = {
options,
};
export const Loading: StoryFunction = (props) => {
const id = useId();
return (
<UNSAFE_Combobox
id={id}
label="Komboboks (laster)"
options={[]}
selectedOptions={[]}
isListOpen={props.isListOpen}
isLoading={props.isLoading}
/>
);
};
export const Loading: StoryFunction = (props) => (
<UNSAFE_Combobox
id="combobox-with-loading-indicator"
label="Komboboks (laster)"
options={[]}
selectedOptions={[]}
isListOpen={props.isListOpen}
isLoading={props.isLoading}
/>
);
Loading.args = {
isLoading: true,
@@ -186,11 +224,10 @@ Loading.args = {
};
export const ComboboxWithNoHits: StoryFunction = (props) => {
const id = useId();
const [value, setValue] = useState(props.value);
return (
<UNSAFE_Combobox
id={id}
id="combobox-with-no-hits"
label="Komboboks (uten søketreff)"
options={props.options}
value={value}
@@ -210,7 +247,6 @@ export const Controlled: StoryFn<{
options: string[];
initialSelectedOptions: string[];
}> = (props) => {
const id = useId();
const [value, setValue] = useState(props.value);
const [selectedOptions, setSelectedOptions] = useState(
props.initialSelectedOptions,
@@ -238,7 +274,7 @@ export const Controlled: StoryFn<{
<br />
<UNSAFE_Combobox
label="Hva er dine favorittfrukter?"
id={id}
id="combobox-controlled"
filteredOptions={filteredOptions}
isMultiSelect
options={props.options}
@@ -292,7 +328,6 @@ export const ComboboxSizes = () => (
);
export const MaxSelectedOptions: StoryFunction = () => {
const id = useId();
const [value, setValue] = useState<string | undefined>("");
const [selectedOptions, setSelectedOptions] = useState([
options[0],
@@ -301,7 +336,7 @@ export const MaxSelectedOptions: StoryFunction = () => {
const comboboxRef = useRef<HTMLInputElement>(null);
return (
<UNSAFE_Combobox
id={id}
id="combobox-with-max-selected-options"
label="Komboboks med begrenset antall valg"
options={options}
maxSelected={{ limit: 2 }}
@@ -3,7 +3,7 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React, { useId } from "react";
import { act } from "react-dom/test-utils";
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { UNSAFE_Combobox } from "./index";
const options = [
@@ -123,4 +123,35 @@ describe("Combobox state-handling", () => {
await screen.findByRole("option", { name: "banana" }),
).toBeInTheDocument();
});
test("Should handle complex options with label and value", async () => {
const onToggleSelected = vi.fn();
render(
<App
options={[
{ label: "Hjelpemidler [HJE]", value: "HJE" },
{ label: "Oppfølging [OPP]", value: "OPP" },
{ label: "Sykepenger [SYK]", value: "SYK" },
{ label: "Sykemelding [SYM]", value: "SYM" },
]}
onToggleSelected={onToggleSelected}
/>,
);
expect(screen.getByRole("combobox")).toBeInTheDocument();
const bananaOption = screen.getByRole("option", {
name: "Hjelpemidler [HJE]",
selected: false,
});
await act(async () => {
await userEvent.click(bananaOption);
});
expect(onToggleSelected).toHaveBeenCalledWith("HJE", true, false);
expect(
screen.getByRole("option", {
name: "Hjelpemidler [HJE]",
selected: true,
}),
).toBeInTheDocument();
});
});
@@ -1,11 +1,12 @@
import React, { createContext, useCallback, useContext, useState } from "react";
import { useInputContext } from "./Input/inputContext";
import { ComboboxOption } from "./types";
type CustomOptionsContextType = {
customOptions: string[];
removeCustomOption: (option: string) => void;
addCustomOption: (option: string) => void;
setCustomOptions: React.Dispatch<React.SetStateAction<string[]>>;
customOptions: ComboboxOption[];
removeCustomOption: (option: ComboboxOption) => void;
addCustomOption: (option: ComboboxOption) => void;
setCustomOptions: React.Dispatch<React.SetStateAction<ComboboxOption[]>>;
};
const CustomOptionsContext = createContext<CustomOptionsContextType>(
@@ -19,14 +20,14 @@ export const CustomOptionsProvider = ({
children: any;
value: { isMultiSelect?: boolean };
}) => {
const [customOptions, setCustomOptions] = useState<string[]>([]);
const [customOptions, setCustomOptions] = useState<ComboboxOption[]>([]);
const { focusInput } = useInputContext();
const { isMultiSelect } = value;
const removeCustomOption = useCallback(
(option: string) => {
(option: ComboboxOption) => {
setCustomOptions((prevCustomOptions) =>
prevCustomOptions.filter((o) => o !== option),
prevCustomOptions.filter((o) => o.label !== option.label),
);
focusInput();
},
@@ -34,7 +35,7 @@ export const CustomOptionsProvider = ({
);
const addCustomOption = useCallback(
(option: string) => {
(option: ComboboxOption) => {
if (isMultiSelect) {
setCustomOptions((prevOptions) => [...prevOptions, option]);
} else {
+23 -11
View File
@@ -1,6 +1,21 @@
import React, { ChangeEvent, InputHTMLAttributes } from "react";
import { FormFieldProps } from "../useFormField";
/**
* A more complex version of options for the Combobox.
* Used for separating the label and the value of the option.
*/
export type ComboboxOption = {
/**
* The label to display in the dropdown list
*/
label: string;
/**
* The programmatic value of the option, for use internally. Will be returned from onToggleSelected.
*/
value: string;
};
export type MaxSelected = {
/**
* The limit for maximum selected options
@@ -20,9 +35,9 @@ export interface ComboboxProps
*/
label: React.ReactNode;
/**
* List of options to use for autocompletion.
* List of options
*/
options: string[];
options: string[] | ComboboxOption[];
/**
* If enabled, adds an option to add the value of the input as an option whenever there are no options matching the value.
*/
@@ -42,7 +57,7 @@ export interface ComboboxProps
* If provided, this overrides the internal search logic in the component.
* Useful for e.g. searching on a server or when overriding the search algorithm to search for synonyms or similar.
*/
filteredOptions?: string[];
filteredOptions?: string[] | ComboboxOption[];
/**
* Optionally hide the label visually.
* Not recommended, but can be considered for e.g. search fields in the top menu.
@@ -75,7 +90,6 @@ export interface ComboboxProps
* Callback function triggered whenever the value of the input field is triggered.
*
* @param event
* @returns
*/
onChange?: (
event: ChangeEvent<HTMLInputElement> | null,
@@ -85,19 +99,17 @@ export interface ComboboxProps
* Callback function triggered whenever the input field is cleared.
*
* @param event
* @returns
*/
onClear?: (event: React.PointerEvent | React.KeyboardEvent) => void;
/**
* Callback function triggered whenever an option is selected or de-selected.
*
* @param option The selected option.
* @param isSelected Whether the option has been selected or unselected.
* @param isCustomOption Whether the option comes from user input, instead of from the list.
* @returns
* @param option The option value
* @param isSelected Whether the option has been selected or unselected
* @param isCustomOption Whether the option comes from user input, instead of from the list
*/
onToggleSelected?: (
option: string,
option: ComboboxOption["value"],
isSelected: boolean,
isCustomOption: boolean,
) => void;
@@ -107,7 +119,7 @@ export interface ComboboxProps
* Use this prop when controlling the selected state outside for the component,
* e.g. for a filter, where options can be toggled elsewhere/programmatically.
*/
selectedOptions?: string[];
selectedOptions?: string[] | ComboboxOption[];
/**
* Options for the maximum number of selected options.
*/
@@ -1,5 +1,6 @@
import { Meta } from "@storybook/react";
import { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { VStack } from "../../layout/stack";
import { ErrorSummary } from "./ErrorSummary";
export default {
@@ -18,9 +19,14 @@ export default {
},
},
},
} as Meta;
parameters: {
chromatic: { disable: true },
},
} satisfies Meta<typeof ErrorSummary>;
export const Default = {
type Story = StoryObj<typeof ErrorSummary>;
export const Default: Story = {
render: (props) => (
<ErrorSummary
heading="Feiloppsummering komponent"
@@ -35,11 +41,41 @@ export const Default = {
),
};
export const Small = () => (
<ErrorSummary heading="Feiloppsummering komponent" size="small">
<ErrorSummary.Item href="#1">Checkbox må fylles ut</ErrorSummary.Item>
<ErrorSummary.Item href="#2">
Tekstfeltet må ha en godkjent e-mail
</ErrorSummary.Item>
</ErrorSummary>
);
export const Small: Story = {
render: () => (
<ErrorSummary heading="Feiloppsummering komponent" size="small">
<ErrorSummary.Item href="#1">Checkbox må fylles ut</ErrorSummary.Item>
<ErrorSummary.Item href="#2">
Tekstfeltet må ha en godkjent e-mail
</ErrorSummary.Item>
</ErrorSummary>
),
};
export const Chromatic: Story = {
render: () => (
<VStack gap="4">
<div>
<h2>Default</h2>
<ErrorSummary heading="Feiloppsummering komponent">
<ErrorSummary.Item href="#1">Checkbox må fylles ut</ErrorSummary.Item>
<ErrorSummary.Item href="#2">
Tekstfeltet må ha en godkjent e-mail
</ErrorSummary.Item>
</ErrorSummary>
</div>
<div>
<h2>Small</h2>
<ErrorSummary heading="Feiloppsummering komponent" size="small">
<ErrorSummary.Item href="#1">Checkbox må fylles ut</ErrorSummary.Item>
<ErrorSummary.Item href="#2">
Tekstfeltet må ha en godkjent e-mail
</ErrorSummary.Item>
</ErrorSummary>
</div>
</VStack>
),
parameters: {
chromatic: { disable: false },
},
};
@@ -1,5 +1,6 @@
import { Meta } from "@storybook/react";
import { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { VStack } from "../../layout/stack";
import TextField from "../textfield/TextField";
import { Fieldset } from "./index";
@@ -26,7 +27,12 @@ export default {
type: "boolean",
},
},
} as Meta;
parameters: {
chromatic: { disable: true },
},
} satisfies Meta<typeof Fieldset>;
type Story = StoryObj<typeof Fieldset>;
const content = (
<>
@@ -35,88 +41,117 @@ const content = (
</>
);
export const Default = {
render: (props) => {
return (
<Fieldset legend="Mollit eiusmod" {...props}>
{content}
</Fieldset>
);
},
const contentWithError = (
<>
<TextField label="Textfield label" hideLabel error="Må være fylt ut" />
<TextField label="Textfield label" hideLabel />
</>
);
export const Default: Story = {
args: {
legend: "Mollit eiusmod",
description:
"Do ullamco amet mollit labore tempor minim cupidatat dolore voluptate velit irure.",
errorPropagation: true,
children: content,
},
};
export const Small = () => (
<Fieldset
size="small"
legend="Mollit eiusmod"
description="Do ullamco amet mollit labore tempor minim cupidatat dolore voluptate velit irure."
>
{content}
</Fieldset>
);
export const Small: Story = {
args: {
legend: "Mollit eiusmod",
description:
"Do ullamco amet mollit labore tempor minim cupidatat dolore voluptate velit irure.",
errorPropagation: true,
children: content,
size: "small",
},
};
export const Description = () => (
<Fieldset
legend="Mollit eiusmod"
description="Esse cupidatat reprehenderit est culpa consectetur sit dolor esse."
>
<TextField
label="Textfield label"
description="Amet quis cillum incididunt "
/>
<TextField
label="Textfield label"
description="Enim et occaecat voluptate labore sit do exercitation laborum non "
/>
</Fieldset>
);
export const ErrorPropagation: Story = {
args: {
legend: "Mollit eiusmod",
description:
"Do ullamco amet mollit labore tempor minim cupidatat dolore voluptate velit irure.",
errorPropagation: false,
children: contentWithError,
},
};
export const ErrorPropagation = () => (
<Fieldset
legend="Mollit eiusmod"
error="Fieldsett error"
errorPropagation={false}
>
<TextField label="Textfield label" hideLabel error="Må være fylt ut" />
<TextField label="Textfield label" hideLabel />
</Fieldset>
);
export const Error: Story = {
args: {
legend: "Mollit eiusmod",
description:
"Do ullamco amet mollit labore tempor minim cupidatat dolore voluptate velit irure.",
children: content,
error: "Laborum officia nisi aliqua esse minim in amet.",
},
};
export const Error = () => (
<div className="colgap">
<Fieldset
legend="Mollit eiusmod"
error="Laborum officia nisi aliqua esse minim in amet."
>
{content}
</Fieldset>
<Fieldset
size="small"
legend="Mollit eiusmod"
error="Laborum officia nisi aliqua esse minim in amet."
>
{content}
</Fieldset>
</div>
);
export const Disabled: Story = {
args: {
legend: "Mollit eiusmod",
description:
"Do ullamco amet mollit labore tempor minim cupidatat dolore voluptate velit irure.",
children: content,
disabled: true,
},
};
export const Disabled = () => (
<div className="colgap">
<Fieldset legend="Mollit eiusmod" disabled>
{content}
</Fieldset>
<Fieldset size="small" legend="Mollit eiusmod" disabled>
{content}
</Fieldset>
</div>
);
export const HideLegend: Story = {
args: {
legend: "Mollit eiusmod",
description:
"Do ullamco amet mollit labore tempor minim cupidatat dolore voluptate velit irure.",
children: content,
hideLegend: true,
},
};
export const HideLegend = () => (
<Fieldset legend="Mollit eiusmod" hideLegend>
{content}
</Fieldset>
);
export const Chromatic: Story = {
render: () => {
return (
<VStack gap="4">
<div>
<h2>Default</h2>
{/* @ts-expect-error Args are Partial here */}
<Fieldset {...Default.args} />
</div>
<div>
<h2>Small</h2>
{/* @ts-expect-error Args are Partial here */}
<Fieldset {...Small.args} />
</div>
<div>
<h2>ErrorPropagation</h2>
{/* @ts-expect-error Args are Partial here */}
<Fieldset {...ErrorPropagation.args} />
</div>
<div>
<h2>Error</h2>
{/* @ts-expect-error Args are Partial here */}
<Fieldset {...Error.args} />
</div>
<div>
<h2>Error small</h2>
{/* @ts-expect-error Args are Partial here */}
<Fieldset {...Error.args} size="small" />
</div>
<div>
<h2>Disabled</h2>
{/* @ts-expect-error Args are Partial here */}
<Fieldset {...Disabled.args} />
</div>
<div>
<h2>HideLegend</h2>
{/* @ts-expect-error Args are Partial here */}
<Fieldset {...HideLegend.args} />
</div>
</VStack>
);
},
parameters: {
chromatic: { disable: false },
},
};
@@ -0,0 +1,9 @@
import { createContext } from "../../util/create-context";
import { ComponentTranslation } from "./i18n/i18n.types";
export type FileUploadLocaleContextProps = {
translations?: ComponentTranslation<"FileUpload">;
};
export const [FileUploadLocaleContextProvider, useFileUploadTranslation] =
createContext<FileUploadLocaleContextProps>();
@@ -0,0 +1,142 @@
import cl from "clsx";
import React, { HTMLAttributes, forwardRef } from "react";
import { FileUploadLocaleContextProvider } from "./FileUpload.context";
import { ComponentTranslation } from "./i18n/i18n.types";
import Trigger from "./parts/Trigger";
import Dropzone from "./parts/dropzone/Dropzone";
import Item from "./parts/item/Item";
interface FileUploadProps extends HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
/**
* i18n-API for customizing texts and labels
*/
translations?: ComponentTranslation<"FileUpload">;
}
interface FileUploadComponent
extends React.ForwardRefExoticComponent<
FileUploadProps & React.RefAttributes<HTMLDivElement>
> {
/**
* Framed area to drag-n-drop files, upload files with button-click or copy-paste.
* @example
* Single file
* ```jsx
* <FileUpload.Dropzone
* label="Last opp fil"
* multiple={false}
* onSelect={onSelect}
* />
* ```
*
* @example
* Multiple files
* ```jsx
* <FileUpload.Dropzone
* label="Last opp fil"
* multiple={true}
* onSelect={onSelect}
* />
* ```
*
* @example
* Error
* ```jsx
* <FileUpload.Dropzone
* label="Last opp filer"
* onSelect={onSelect}
* error={error}
* />
* ```
*/
Dropzone: typeof Dropzone;
/**
* Displays a file with status, file size, action and error message.
* @example
* Single
* ```jsx
* <FileUpload.Item file={file} status="uploading" />
* ```
*
* @example
* Multiple items can be semantically grouped as a list.
* ```jsx
* <FileUpload>
* <VStack gap="4" as="ul">
* <FileUpload.Item as="li" file={file} />
* <FileUpload.Item as="li" file={file2} />
* <FileUpload.Item as="li" file={file3} status="uploading" />
* </VStack>
* </FileUpload>
* ```
*
* @example
* Custom file object
* ```jsx
* <FileUpload.Item file={{ name: "fileName.pdf", size: 1_048_576 }} />
* ```
*
* @example
* Error
* ```jsx
* <FileUpload.Item file={file} error="Something went wrong" />
* ```
*
* @example
* Status & actions
* ```jsx
* <FileUpload.Item file={file} status="uploading" />
* <FileUpload.Item file={file} status="downloading" />
* <FileUpload.Item file={file} button={{ action:"retry", onClick:... }} />
* <FileUpload.Item file={file} button={{ action:"delete", onClick:... }} />
* ```
*/
Item: typeof Item;
/**
* Wrapper for a button to trigger file select.
* @example
* ```jsx
* <FileUpload.Trigger onSelect={...}>
* <Button variant="secondary">Last opp filer</Button>
* </FileUpload.Trigger>
* ```
*/
Trigger: typeof Trigger;
}
/**
* A set of components used to upload and display files.
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/fileupload)
* @example Dropzone
* ```jsx
* <FileUpload.Dropzone />
* ```
*
* @example
* Items
* ```jsx
* <VStack gap="4" as="ul">
* <FileUpload.Item as="li" file={myFile} />
* <FileUpload.Item as="li" file={mySecondFile} />
* </VStack>
* ```
*/
export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(
({ children, className, translations, ...rest }: FileUploadProps, ref) => {
return (
<FileUploadLocaleContextProvider translations={translations}>
<div ref={ref} {...rest} className={cl("navds-file-upload", className)}>
{children}
</div>
</FileUploadLocaleContextProvider>
);
},
) as FileUploadComponent;
FileUpload.Dropzone = Dropzone;
FileUpload.Item = Item;
FileUpload.Trigger = Trigger;
export default FileUpload;
@@ -0,0 +1,57 @@
export const fileRejectionReason = {
FileType: "fileType" as const,
FileSize: "fileSize" as const,
};
export type FileRejectionReason =
(typeof fileRejectionReason)[keyof typeof fileRejectionReason];
export type FileRejected = {
file: File;
error: true;
reasons: string[];
};
export type FileAccepted = { file: File; error: false };
export type FileObject = FileRejected | FileAccepted;
export type FileRejectedPartitioned = {
file: File;
reasons: string[];
};
export type FilesPartitioned = {
accepted: File[];
rejected: FileRejectedPartitioned[];
};
export interface FileUploadBaseProps {
/**
* Indicates if it is possible to select multiple files at once.
* @default true
*/
multiple?: boolean;
/**
* Indicates which file types to accept.
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept
*/
accept?: string;
/**
* Maximum size of a file to accept
*/
maxSizeInBytes?: number;
/**
* Custom validator that is used to decide if a file is accepted or rejected.
* @return true if the file is accepted, otherwise a string with the reason for rejection
*/
validator?: (file: File) => true | string;
/**
* Callback triggered on file select
*/
onSelect: (files: FileObject[], partitionedFiles: FilesPartitioned) => void;
/**
* Disables the dropzone when current >= max, unless `disabled` prop is set to `false`.
*/
fileLimit?: {
max: number;
current: number;
};
}
@@ -0,0 +1,123 @@
import { Meta, StoryObj } from "@storybook/react";
import { fireEvent, within } from "@storybook/test";
import React from "react";
import { ImageIcon } from "@navikt/aksel-icons";
import {
type FileObject,
UNSAFE_FileUpload as FileUpload,
type FilesPartitioned,
} from ".";
const meta: Meta<typeof FileUpload.Dropzone> = {
title: "ds-react/FileUpload/Dropzone",
component: FileUpload.Dropzone,
decorators: [
(Story) => (
<div style={{ width: 500, maxWidth: "100%" }}>
<Story />
</div>
),
],
};
export default meta;
const onSelect = (
allFiles: FileObject[],
{ accepted, rejected }: FilesPartitioned,
) => {
alert(
`Lastet opp ${allFiles.length} filer. Accepted: ${accepted.length}. Rejected: ${rejected.length}`,
);
};
export const Default: StoryObj<typeof FileUpload.Dropzone> = {
render: (props) => <FileUpload.Dropzone {...props} onSelect={console.log} />,
args: {
label: "Last opp filer",
description: "",
error: "",
multiple: true,
accept: "",
maxSizeInBytes: 0,
fileLimit: { max: 2, current: 1 },
},
argTypes: {
disabled: { control: { type: "boolean" } },
},
parameters: { chromatic: { disable: true } },
};
export const States: StoryObj = {
render: () => (
<div>
<h2>Disabled</h2>
<FileUpload.Dropzone
label="Disabled prop"
onSelect={console.log}
disabled
/>
<FileUpload.Dropzone
label="FileLimit disabled"
onSelect={console.log}
fileLimit={{ max: 1, current: 2 }}
/>
<h2>Error</h2>
<FileUpload.Dropzone
label="Last opp filer"
onSelect={onSelect}
error="Du må laste opp en fil"
description="Bruk filtype DOC, PPT eller PDF. Maks filstørrelse 10 MB."
/>
<h2>Dragging</h2>
<FileUpload.Dropzone
label="Drag over test"
multiple={false}
onSelect={onSelect}
/>
</div>
),
};
States.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByText("Velg fil");
fireEvent.dragEnter(button);
};
export const Translation: StoryObj = {
render: () => (
<div>
<h2>Single file</h2>
<FileUpload.Dropzone
label="Last opp fil"
multiple={false}
onSelect={onSelect}
/>
<h2>Custom texts</h2>
<FileUpload.Dropzone
translations={{
dragAndDropMultiple: "Dra og slipp bilder i format .png",
buttonMultiple: "Velg bilder",
disabled: "Du kan ikke laste opp flere bilder",
}}
label="Last opp bilder"
onSelect={console.log}
icon={ImageIcon}
/>
<h3>Disabled</h3>
<FileUpload.Dropzone
translations={{
disabled: "Du kan ikke laste opp flere bilder",
}}
label="Last opp bilder"
onSelect={console.log}
icon={ImageIcon}
disabled
/>
</div>
),
};
@@ -0,0 +1,148 @@
import { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { FileItem, UNSAFE_FileUpload as FileUpload } from ".";
import { VStack } from "../../layout/stack";
const meta: Meta<typeof FileUpload.Item> = {
title: "ds-react/FileUpload/Item",
component: FileUpload.Item,
decorators: [
(Story) => (
<div style={{ width: 400, maxWidth: "100%" }}>
<Story />
</div>
),
],
};
export default meta;
const onDelete = (file: FileItem) => alert(`Delete ${file.name}`);
const onRetry = (file: FileItem) => alert(`Retry ${file.name}`);
const fileTxt = new File(["abc".repeat(10000)], "file.txt");
const filePng = new File(["abc".repeat(10000)], "file.png");
const filePdf = new File(["abc".repeat(100000)], "file.pdf");
const fileXlsx = new File(["abc"], "file.xlsx");
const fileCsv = new File(["abc"], "file.csv");
const filePptx = new File(["abc"], "file.pptx");
const fileWebp = new File(["abc"], "file.webp");
const fileDocx = new File(["abc"], "file.docx");
export const Icons: StoryObj<typeof FileUpload.Item> = {
render: () => (
<FileUpload>
<VStack gap="5" as="ul">
<FileUpload.Item file={fileTxt} as="li" />
<FileUpload.Item file={filePng} as="li" />
<FileUpload.Item file={fileWebp} as="li" />
<FileUpload.Item file={filePdf} as="li" />
<FileUpload.Item file={fileDocx} as="li" />
<FileUpload.Item file={fileDocx} as="li" status="uploading" />
<FileUpload.Item file={fileDocx} as="li" status="downloading" />
<FileUpload.Item file={fileXlsx} as="li" />
<FileUpload.Item file={fileCsv} as="li" />
<FileUpload.Item file={filePptx} as="li" />
</VStack>
</FileUpload>
),
};
export const States: StoryObj<typeof FileUpload.Item> = {
render: () => (
<div>
<h2>Error</h2>
<FileUpload.Item file={fileTxt} error="Plain error" />
<h3>error + status</h3>
<FileUpload.Item
file={fileTxt}
error="Error og uploading"
status="uploading"
button={{
onClick: () => onDelete(fileTxt),
action: "delete",
}}
/>
<h2>Item Actions</h2>
<h3>status + delete</h3>
<FileUpload.Item
file={fileDocx}
status="uploading"
button={{
onClick: () => onDelete(fileDocx),
action: "delete",
}}
/>
<h3>status + retry</h3>
<FileUpload.Item
file={fileDocx}
status="downloading"
button={{
onClick: () => onRetry(fileDocx),
action: "retry",
}}
/>
<h3>retry</h3>
<FileUpload.Item
file={fileCsv}
button={{
onClick: () => onRetry(fileCsv),
action: "retry",
}}
/>
<h3>delete</h3>
<FileUpload.Item
file={filePptx}
button={{
onClick: () => onDelete(filePptx),
action: "delete",
}}
/>
<h3>retry + error</h3>
<FileUpload.Item
file={fileCsv}
error="Error og onRetry"
button={{
onClick: () => onRetry(fileCsv),
action: "retry",
}}
/>
<h3>delete + error</h3>
<FileUpload.Item
file={filePptx}
error="Error og onDelete"
button={{
onClick: () => onDelete(filePptx),
action: "delete",
}}
/>
</div>
),
};
export const Download: StoryObj = {
render: () => (
<VStack gap="5">
<FileUpload.Item
file={{
name: "with onClick.txt",
size: 1_048_576,
}}
onFileClick={() => alert("onFileClick")}
/>
<FileUpload.Item
file={{
name: "with href.txt",
size: 1,
}}
href="https://www.nav.no"
/>
<FileUpload.Item
file={{
name: "without href/onFileClick.txt",
size: 2_000_000,
}}
/>
<FileUpload.Item file={fileTxt} />
</VStack>
),
};
@@ -0,0 +1,248 @@
import { Meta, StoryFn, StoryObj } from "@storybook/react";
import React, { useEffect, useState } from "react";
import { UploadIcon } from "@navikt/aksel-icons";
import { UNSAFE_FileUpload as FileUpload, FileUploadItemProps } from ".";
import { Alert } from "../../alert";
import { Button } from "../../button";
import { VStack } from "../../layout/stack";
import { Heading } from "../../typography";
import {
FileObject,
FileRejected,
FileRejectionReason,
} from "./FileUpload.types";
const meta: Meta<typeof FileUpload.Dropzone> = {
title: "ds-react/FileUpload",
component: FileUpload.Dropzone,
};
export default meta;
const MAX_FILES = 3;
const MAX_SIZE_MB = 1;
const MAX_SIZE = MAX_SIZE_MB * 1024 * 1024;
const CustomItem = ({
index,
onDelete,
...props
}: FileUploadItemProps & {
index: number;
onDelete: (event: React.MouseEvent<HTMLButtonElement>) => void;
}) => {
const [loading, setLoading] = useState(true);
useEffect(() => {
setTimeout(
() => {
setLoading(false);
},
1700 * index + 1,
);
}, [index]);
return (
<FileUpload.Item
{...props}
status={loading ? "uploading" : "idle"}
button={{
action: "delete",
onClick: onDelete,
}}
as="li"
/>
);
};
export const Default: StoryFn = () => {
const [files, setFiles] = useState<FileObject[]>([]);
function addFiles(filesToAdd: FileObject[]) {
setFiles([...files, ...filesToAdd]);
}
function removeFile(fileToRemove: FileObject) {
setFiles(files.filter((file) => file !== fileToRemove));
}
const acceptedFiles = files.filter((file) => !file.error);
const rejectedFiles = files.filter((f): f is FileRejected => f.error);
return (
<FileUpload style={{ width: 500, maxWidth: "100%", margin: "0 auto" }}>
<VStack gap="6">
<FileUpload.Dropzone
label="Last opp filer til søknaden"
description={`Maks størrelse ${MAX_SIZE_MB} MB`}
accept=".doc,.docx,.xls,.xlsx,.pdf"
maxSizeInBytes={MAX_SIZE}
fileLimit={{ max: MAX_FILES, current: acceptedFiles.length }}
onSelect={addFiles}
/>
{getListError(acceptedFiles) && (
<Alert variant="error">{getListError(acceptedFiles)}</Alert>
)}
{acceptedFiles.length > 0 && (
<VStack gap="2">
<Heading level="3" size="xsmall">
{`Vedlegg (${acceptedFiles.length} av maks ${MAX_FILES})`}
</Heading>
<VStack as="ul" gap="3">
{acceptedFiles.map((file, index) => (
<CustomItem
key={index}
index={index}
file={file.file}
onDelete={() => removeFile(file)}
/>
))}
</VStack>
</VStack>
)}
{rejectedFiles.length > 0 && (
<VStack gap="2">
<Heading level="3" size="xsmall">
Vedlegg med feil
</Heading>
<VStack as="ul" gap="3">
{rejectedFiles.map((rejected, index) => (
<CustomItem
key={index}
index={index}
file={rejected.file}
error={errors[rejected.reasons[0]]}
onDelete={() => removeFile(rejected)}
/>
))}
</VStack>
</VStack>
)}
</VStack>
</FileUpload>
);
};
Default.parameters = {
chromatic: { disable: true },
layout: "padded",
};
const errors: Record<FileRejectionReason, string> = {
fileType: "Filformatet støttes ikke",
fileSize: `Filen er større enn ${MAX_SIZE_MB} MB`,
};
function getListError(acceptedFiles: FileObject[]) {
const filesTooMany = acceptedFiles.length - MAX_FILES;
if (filesTooMany === 1)
return "Du har lagt ved en fil for mye, vennligst fjern en fil";
if (filesTooMany > 1)
return `Du har lagt ved ${filesTooMany} filer for mye, vennligst fjern ${filesTooMany} filer`;
}
export const Single: StoryFn = () => {
const [files, setFiles] = useState<FileObject[]>([]);
function addFiles(filesToAdd: FileObject[]) {
setFiles(filesToAdd);
}
function removeFile() {
setFiles([]);
}
return (
<VStack gap="6" style={{ width: 500, maxWidth: "100%" }}>
<FileUpload.Dropzone
label="Last opp fil til søknaden"
description={`Maks størrelse ${MAX_SIZE_MB} MB`}
accept=".doc,.docx,.xls,.xlsx,.pdf"
maxSizeInBytes={MAX_SIZE}
fileLimit={{ max: 1, current: files.length }}
multiple={false}
onSelect={addFiles}
/>
{files.map((file) => (
<FileUpload.Item
key={file.file.name}
file={file.file}
error={file.error ? errors[file.reasons[0]] : undefined}
button={{
action: "delete",
onClick: removeFile,
}}
/>
))}
</VStack>
);
};
Single.parameters = { chromatic: { disable: true } };
export const Translation = () => (
<FileUpload
translations={{
dropzone: {
dragAndDropMultiple: "Dra og slipp bilder i format .png",
buttonMultiple: "Velg bilder",
or: "eventuelt",
disabled: "Du kan ikke laste opp flere bilder",
},
item: {
deleteButtonTitle: "Slett bilde",
downloading: "Laster bilde...",
uploading: "Laster opp bilde...",
retryButtonTitle: "Last opp bilde på nytt",
},
}}
>
<VStack gap="3" style={{ width: 500, maxWidth: "100%" }}>
<FileUpload.Dropzone label="Last opp bilder" onSelect={console.log} />
<FileUpload.Item
file={{ name: "eksempel.png", size: 200000 }}
button={{
action: "delete",
onClick: () => null,
}}
/>
<FileUpload.Item
file={{ name: "eksempel.png", size: 200000 }}
button={{
action: "retry",
onClick: () => null,
}}
/>
<FileUpload.Item
file={{ name: "eksempel.png", size: 200000 }}
status="downloading"
/>
<FileUpload.Item
file={{ name: "eksempel.png", size: 200000 }}
status="uploading"
/>
<FileUpload.Item
file={{ name: "eksempel.png", size: 200000 }}
status="uploading"
translations={{ uploading: "Sender bilde..." }}
/>
</VStack>
</FileUpload>
);
export const TriggerWithButton: StoryObj<typeof FileUpload.Trigger> = {
render: (props) => {
return (
<FileUpload.Trigger {...props} onSelect={console.log}>
<Button variant="secondary" icon={<UploadIcon aria-hidden />}>
Last opp filer
</Button>
</FileUpload.Trigger>
);
},
args: {
multiple: true,
accept: "",
maxSizeInBytes: 0,
},
};
@@ -0,0 +1,48 @@
import { TranslationObject } from "./i18n.types";
/**
* https://github.com/Shopify/polaris/blob/main/polaris-react/src/utilities/get.ts#L3
*/
const OBJECT_NOTATION_MATCHER = /(\w+)/g;
export function get(
keypath: string | string[],
...objs: (TranslationObject | undefined)[]
) {
const keys = Array.isArray(keypath) ? keypath : getKeypath(keypath);
for (const obj of objs) {
if (!obj) {
continue;
}
let acc: string | TranslationObject = obj;
for (let i = 0; i < keys.length; i++) {
const val = acc[keys[i]];
if (val === undefined) {
continue;
}
acc = val;
}
if (typeof acc === "string") {
return acc;
}
}
throw new Error(
"Error translating key. The keypath does not resolve to a string.",
);
}
function getKeypath(str: string) {
const path: string[] = [];
let result: RegExpExecArray | null;
while ((result = OBJECT_NOTATION_MATCHER.exec(str))) {
const [, first, second] = result;
path.push(first || second);
}
return path;
}
@@ -0,0 +1,92 @@
import { renderHook } from "@testing-library/react";
import React from "react";
import { describe, expect, test } from "vitest";
import { I18nContext, useI18n } from "./i18n.context";
describe("useI18n", () => {
test("should throw error if key is not found", () => {
const { result } = renderHook(() => useI18n("FileUpload"));
const translate = result.current;
// @ts-expect-error - Testing nonexistent key
expect(() => translate("item.nonexistentKey")).toThrowError();
});
test("should return the translated text from I18nContext", () => {
const i18n = { FileUpload: { item: { uploading: "Test translation" } } };
const { result } = renderHook(() => useI18n("FileUpload"), {
wrapper: ({ children }) => (
<I18nContext.Provider value={i18n}>{children}</I18nContext.Provider>
),
});
const translate = result.current;
expect(translate("item.uploading")).toBe("Test translation");
});
test("should return the translated text from first context object", () => {
const i18n1 = {
FileUpload: { item: { uploading: "Correct translation" } },
};
const i18n2 = { FileUpload: { item: { uploading: "Wrong translation" } } };
const { result } = renderHook(() => useI18n("FileUpload"), {
wrapper: ({ children }) => (
<I18nContext.Provider value={[i18n1, i18n2]}>
{children}
</I18nContext.Provider>
),
});
const translate = result.current;
expect(translate("item.uploading")).toBe("Correct translation");
});
test("should return the translated text from second context object", () => {
const i18n1 = { FileUpload: { item: { uploading: "Foo" } } };
const i18n2 = {
FileUpload: { item: { downloading: "Correct translation" } },
};
const { result } = renderHook(() => useI18n("FileUpload"), {
wrapper: ({ children }) => (
<I18nContext.Provider value={[i18n1, i18n2]}>
{children}
</I18nContext.Provider>
),
});
const translate = result.current;
expect(translate("item.downloading")).toBe("Correct translation");
});
test("should return the translated text from first local object", () => {
const i18n1 = { item: { uploading: "Correct translation" } };
const i18n2 = { item: { uploading: "Wrong translation" } };
const { result } = renderHook(() => useI18n("FileUpload", i18n1, i18n2));
const translate = result.current;
expect(translate("item.uploading")).toBe("Correct translation");
});
test("should return the translated text from second local object", () => {
const i18n1 = { item: { uploading: "Foo" } };
const i18n2 = { item: { downloading: "Correct translation" } };
const { result } = renderHook(() => useI18n("FileUpload", i18n1, i18n2));
const translate = result.current;
expect(translate("item.downloading")).toBe("Correct translation");
});
test("should replace placeholders in the translated text", () => {
const i18n = {
item: { uploading: "Hello, {name}. You have {cnt} messages." },
};
const { result } = renderHook(() => useI18n("FileUpload", i18n));
const translate = result.current;
expect(
translate("item.uploading", { replacements: { name: "John", cnt: 3 } }),
).toBe("Hello, John. You have 3 messages.");
});
test("should throw an error if replacement key is not found", () => {
const i18n = { item: { uploading: "Hello, {name}" } };
const { result } = renderHook(() => useI18n("FileUpload", i18n));
const translate = result.current;
expect(() =>
translate("item.uploading", { replacements: { other: "John" } }),
).toThrowError();
});
});
@@ -0,0 +1,67 @@
import { createContext, useContext } from "react";
import { get } from "./get";
import {
Component,
ComponentTranslation,
TranslationDictionary,
} from "./i18n.types";
import nb from "./locales/nb";
/**
* https://regex101.com/r/LYKWi3/1
*/
const REPLACE_REGEX = /{[^}]*}/g;
export const I18nContext = createContext<
TranslationDictionary | TranslationDictionary[]
>(nb);
/* https://dev.to/pffigueiredo/typescript-utility-keyof-nested-object-2pa3 */
type NestedKeyOf<ObjectType extends object> = {
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object
? `${Key}.${NestedKeyOf<ObjectType[Key]>}`
: `${Key}`;
}[keyof ObjectType & (string | number)];
export function useI18n<T extends Component>(
componentName: T,
...local: (ComponentTranslation<T> | undefined)[]
) {
const i18n = useContext(I18nContext);
/**
* https://github.com/Shopify/polaris/blob/2115f9ba2f5bcbf2ad15745233501bff2db81ecf/polaris-react/src/utilities/i18n/I18n.ts#L24
*/
const translate = (
keypath: NestedKeyOf<(typeof nb)[T]>,
options?: { replacements: Record<string, string | number> },
) => {
const text = get(
keypath,
...local,
...(Array.isArray(i18n)
? i18n.map((t) => t[componentName])
: [i18n[componentName]]),
);
if (options?.replacements) {
return text.replace(REPLACE_REGEX, (match) => {
const replacement = match.substring(1, match.length - 1);
if (options.replacements[replacement] === undefined) {
const replacementData = JSON.stringify(options.replacements);
throw new Error(
`Error translating key '${keypath}'. No replacement syntax ({}) found for key '${replacement}'. The following replacements were passed: '${replacementData}'`,
);
}
return options.replacements[replacement] as string; // can also be a number, but JS doesn't mind...
});
}
return text;
};
return translate;
}
@@ -0,0 +1,20 @@
import nb from "./locales/nb";
export interface TranslationObject {
[key: string]: string | TranslationObject | undefined;
}
export interface TranslationDictionary {
[key: string]: TranslationObject | undefined;
}
/* https://stackoverflow.com/questions/47914536/use-partial-in-nested-property-with-typescripts */
type RecursivePartial<T> = {
[P in keyof T]?: RecursivePartial<T[P]>;
};
export type Component = keyof typeof nb;
export type ComponentTranslation<T extends Component> = RecursivePartial<
(typeof nb)[T]
>;
@@ -0,0 +1,20 @@
export default {
FileUpload: {
dropzone: {
button: "Velg fil",
buttonMultiple: "Velg filer",
dragAndDrop: "Dra og slipp filen her",
dragAndDropMultiple: "Dra og slipp filer her",
drop: "Slipp",
or: "eller",
disabled: "Filopplasting er deaktivert",
disabledFilelimit: "Du kan ikke laste opp flere filer",
},
item: {
retryButtonTitle: "Prøv å laste opp filen på nytt",
deleteButtonTitle: "Slett filen",
uploading: "Laster opp…",
downloading: "Laster ned…",
},
},
};
@@ -0,0 +1,35 @@
import { TranslationDictionary, TranslationObject } from "./i18n.types";
export function merge(...objs: TranslationDictionary[]) {
let final: TranslationDictionary = {};
for (const obj of objs) {
final = mergeRecursively(final, obj);
}
return final;
}
function mergeRecursively<T>(
objA: T,
objB: TranslationDictionary | TranslationObject,
) {
const objARes = { ...objA };
for (const key in objB) {
if (!(key in objB)) {
continue;
}
const a = objARes[key];
const b = objB[key];
if (b && typeof b !== "string" && typeof a !== "string") {
objARes[key] = mergeRecursively(a, b);
} else {
objARes[key] = b;
}
}
return objARes;
}
@@ -0,0 +1,21 @@
"use client";
export { default as UNSAFE_FileUpload } from "./FileUpload";
export { default as UNSAFE_FileUploadDropzone } from "./parts/dropzone/Dropzone";
export { type FileUploadDropzoneProps } from "./parts/dropzone/dropzone.types";
export {
default as UNSAFE_FileUploadTrigger,
type FileUploadTriggerProps,
} from "./parts/Trigger";
export {
type FileObject,
type FileRejected,
type FileAccepted,
type FileRejectedPartitioned,
type FilesPartitioned,
type FileRejectionReason,
} from "./FileUpload.types";
export {
default as UNSAFE_FileUploadItem,
type FileUploadItemProps,
} from "./parts/item/Item";
export { type FileItem, type FileMetadata } from "./parts/item/Item.types";
@@ -0,0 +1,48 @@
import React, { forwardRef } from "react";
import { Slot } from "../../../util/Slot";
import { FileUploadBaseProps } from "../FileUpload.types";
import { useFileUpload } from "../useFileUpload";
export interface FileUploadTriggerProps
extends Omit<FileUploadBaseProps, "fileLimit"> {
children: React.ReactNode;
}
const Trigger = forwardRef<HTMLInputElement, FileUploadTriggerProps>(
(
{
children,
multiple = true,
accept,
onSelect,
validator,
maxSizeInBytes,
}: FileUploadTriggerProps,
ref,
) => {
const { onChange, inputRef, mergedRef } = useFileUpload({
ref,
onSelect,
validator,
accept,
maxSizeInBytes,
disabled: false,
});
return (
<>
<Slot onClick={() => inputRef.current?.click()}>{children}</Slot>
<input
ref={mergedRef}
type="file"
style={{ display: "none" }}
multiple={multiple}
accept={accept}
onChange={onChange}
/>
</>
);
},
);
export default Trigger;
@@ -0,0 +1,186 @@
import cl from "clsx";
import React, { forwardRef } from "react";
import { CircleSlashIcon, CloudUpIcon } from "@navikt/aksel-icons";
import { Button } from "../../../../button";
import { BodyShort, ErrorMessage, Label } from "../../../../typography";
import { composeEventHandlers } from "../../../../util/composeEventHandlers";
import { useId } from "../../../../util/hooks";
import { omit } from "../../../../util/omit";
import { useFormField } from "../../../useFormField";
import { useFileUploadTranslation } from "../../FileUpload.context";
import { useI18n } from "../../i18n/i18n.context";
import { useFileUpload } from "../../useFileUpload";
import { FileUploadDropzoneProps } from "./dropzone.types";
import { useDropzone } from "./useDropzone";
const Dropzone = forwardRef<HTMLInputElement, FileUploadDropzoneProps>(
(props: FileUploadDropzoneProps, ref) => {
const {
onSelect,
error,
label,
description,
className,
multiple = true,
accept,
validator,
maxSizeInBytes,
fileLimit,
icon: DropzoneIcon = CloudUpIcon,
disabled,
translations,
onClick,
id: buttonId,
...rest
} = props;
const context = useFileUploadTranslation(false);
const translate = useI18n(
"FileUpload",
{ dropzone: translations },
context?.translations,
);
const fileLimitReached =
fileLimit && fileLimit?.current >= fileLimit?.max && fileLimit?.max > 0;
const _disabled = disabled ?? fileLimitReached;
const { inputProps, errorId, showErrorMsg, hasError, inputDescriptionId } =
useFormField(
{ ...omit(props, ["id"]), disabled: _disabled },
"fileUpload",
);
const {
id: inputId,
"aria-describedby": ariaDescribedby,
...inputPropsRest
} = inputProps;
const labelId = useId();
const { upload, onChange, inputRef, mergedRef } = useFileUpload({
ref,
onSelect,
validator,
accept,
maxSizeInBytes,
disabled: inputProps.disabled,
});
const dropzoneCtx = useDropzone({
upload,
disabled: inputProps.disabled,
});
return (
<div
className={cl("navds-form-field", "navds-dropzone", className, {
"navds-dropzone--error": hasError,
"navds-dropzone--dragging": dropzoneCtx.isDraggingOver,
"navds-dropzone--disabled": inputProps.disabled,
})}
>
<Label
htmlFor={inputId}
id={labelId}
className="navds-form-field__label"
>
{label}
</Label>
{!!description && (
<BodyShort
id={inputDescriptionId}
className="navds-form-field__description"
as="div"
>
{description}
</BodyShort>
)}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
className="navds-dropzone__area"
onDragEnter={dropzoneCtx.onDragEnter}
onDragOver={dropzoneCtx.onDragOver}
onDragLeave={dropzoneCtx.onDragLeave}
onDrop={dropzoneCtx.onDrop}
onClick={composeEventHandlers(
onClick,
() => inputRef.current?.click(),
)}
>
{!inputProps.disabled && (
<>
<div className="navds-dropzone__area-icon">
<DropzoneIcon fontSize="1.5rem" aria-hidden />
</div>
<div className="navds-dropzone__area-release">
<div className="navds-dropzone__area-release__icon">
<DropzoneIcon aria-hidden />
</div>
<span
aria-hidden={!dropzoneCtx.isDraggingOver}
className="navds-dropzone__area-release__text"
>
{translate("dropzone.drop")}
</span>
</div>
<div aria-hidden>
<BodyShort as="div" spacing>
{multiple
? translate("dropzone.dragAndDropMultiple")
: translate("dropzone.dragAndDrop")}
</BodyShort>
<BodyShort as="div">{translate("dropzone.or")}</BodyShort>
</div>
<Button
{...omit(rest, ["errorId"])}
{...inputPropsRest}
id={buttonId}
aria-describedby={cl(labelId, ariaDescribedby)}
className="navds-dropzone__area-button"
type="button"
variant="secondary"
>
{multiple
? translate("dropzone.buttonMultiple")
: translate("dropzone.button")}
</Button>
</>
)}
{inputProps.disabled && (
<div className="navds-dropzone__area-disabled">
<CircleSlashIcon aria-hidden fontSize="1.75rem" />
<BodyShort as="div">
{fileLimitReached
? translate("dropzone.disabledFilelimit")
: translate("dropzone.disabled")}
</BodyShort>
</div>
)}
<input
id={inputId}
type="file"
style={{ display: "none" }}
multiple={multiple}
accept={accept}
onChange={onChange}
ref={mergedRef}
disabled={inputProps.disabled}
/>
</div>
<div
className="navds-form-field__error"
id={errorId}
aria-relevant="additions removals"
aria-live="polite"
>
{showErrorMsg && <ErrorMessage>{error}</ErrorMessage>}
</div>
</div>
);
},
);
export default Dropzone;
@@ -0,0 +1,22 @@
import { FormFieldProps } from "../../../useFormField";
import { FileUploadBaseProps } from "../../FileUpload.types";
import { ComponentTranslation } from "../../i18n/i18n.types";
export interface FileUploadDropzoneProps
extends FileUploadBaseProps,
Omit<FormFieldProps, "size" | "readOnly">,
Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect" | "onClick">,
Pick<React.HTMLAttributes<HTMLDivElement>, "onClick"> {
/**
* Text shown to the user.
*/
label: string;
/**
* @default CloudUpIcon
*/
icon?: React.ComponentType<any>;
/**
* i18n-API for customizing texts and labels
*/
translations?: ComponentTranslation<"FileUpload">["dropzone"];
}
@@ -0,0 +1,43 @@
import { useState } from "react";
import { UseFileUploadProps } from "../../useFileUpload";
interface Props {
upload: (fileList: FileList) => void;
disabled: UseFileUploadProps["disabled"];
}
export const useDropzone = ({ upload, disabled }: Props) => {
const [isDraggingOver, setIsDraggingOver] = useState(false);
const onDragEnter = () => {
setIsDraggingOver(true);
};
const onDragOver = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Prevents the browser from opening the file in a new tab
};
const onDragLeave = () => {
setIsDraggingOver(false);
};
const onDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Prevents the browser from opening the file in a new tab
setIsDraggingOver(false);
const fileList = event.dataTransfer.files;
if (!fileList) {
return;
}
upload(fileList);
};
return {
isDraggingOver,
onDragEnter: disabled ? undefined : onDragEnter,
onDragOver: disabled ? undefined : onDragOver,
onDragLeave: disabled ? undefined : onDragLeave,
onDrop: disabled ? undefined : onDrop,
};
};
@@ -0,0 +1,149 @@
import cl from "clsx";
import React, { MouseEvent, forwardRef } from "react";
import { ExclamationmarkTriangleIcon } from "@navikt/aksel-icons";
import { BodyShort } from "../../../../typography";
import { OverridableComponent } from "../../../../util";
import { useFileUploadTranslation } from "../../FileUpload.context";
import { useI18n } from "../../i18n/i18n.context";
import { ComponentTranslation } from "../../i18n/i18n.types";
import { FileItem } from "./Item.types";
import ItemButton from "./ItemButton";
import ItemIcon from "./ItemIcon";
import ItemName from "./ItemName";
import { formatFileSize } from "./utils/format-file-size";
export interface FileUploadItemProps
extends React.HTMLAttributes<HTMLDivElement> {
/**
* Overrides html-tag
* @default "div"
*/
as?: ("div" | "li") & React.ElementType;
/**
* Either a native File or file metadata.
*/
file: FileItem;
/**
* onClick on the file name.
*
* If neither this nor `href` is set, and the `file` prop is a native file, onClick will download the file.
*/
onFileClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
/**
* href on the file name.
*
* If neither this nor `onFileClick` is set, and the `file` prop is a native file, onClick will download the file.
*/
href?: string;
/**
* Error message relating to the item.
*/
error?: string;
/**
* Status "downloading" and "uploading" displays a loading indicator.
* @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"];
}
export const Item: OverridableComponent<FileUploadItemProps, HTMLDivElement> =
forwardRef(
(
{
as: Component = "div",
file,
status = "idle",
error,
className,
href,
onFileClick,
button,
translations,
...rest
}: FileUploadItemProps,
ref,
) => {
const context = useFileUploadTranslation(false);
const translate = useI18n(
"FileUpload",
{ item: translations },
context?.translations,
);
const showError = !!error && status === "idle";
function getStatusText() {
if (status === "uploading") {
return translate("item.uploading");
}
if (status === "downloading") {
return translate("item.downloading");
}
return formatFileSize(file);
}
return (
<Component
ref={ref}
{...rest}
className={cl("navds-file-item", className, {
"navds-file-item--error": showError,
})}
>
<div className="navds-file-item__inner">
<ItemIcon
isLoading={status !== "idle"}
file={file}
showError={showError}
/>
<div className="navds-file-item__file-info">
<ItemName file={file} href={href} onClick={onFileClick} />
<BodyShort as="div" size="small">
{getStatusText()}
</BodyShort>
<div
className="navds-file-item__error"
aria-relevant="additions removals"
aria-live="polite"
>
{showError && (
<BodyShort
size="small"
className="navds-file-item__error-content"
>
<ExclamationmarkTriangleIcon aria-hidden />
{error}
</BodyShort>
)}
</div>
</div>
{status === "idle" && button && (
<ItemButton
{...button}
title={translate(
button.action === "retry"
? "item.retryButtonTitle"
: "item.deleteButtonTitle",
)}
/>
)}
</div>
</Component>
);
},
);
export default Item;
@@ -0,0 +1,6 @@
export interface FileMetadata {
name: string;
size?: number;
}
export type FileItem = FileMetadata | File;
@@ -0,0 +1,27 @@
import React from "react";
import { ArrowsCirclepathIcon, TrashIcon } from "@navikt/aksel-icons";
import { Button } from "../../../../button";
interface Props {
action: "delete" | "retry";
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
id?: string;
title: string;
}
const ItemButton = ({ action, onClick, id, title }: Props) => {
const Icon = action === "delete" ? TrashIcon : ArrowsCirclepathIcon;
return (
<Button
id={id}
className="navds-file-item__button"
type="button"
variant="tertiary-neutral"
onClick={onClick}
icon={<Icon title={title} />}
/>
);
};
export default ItemButton;
@@ -0,0 +1,74 @@
import React from "react";
import {
FileCsvIcon,
FileExcelIcon,
FileIcon,
FileImageIcon,
FilePdfIcon,
FileTextIcon,
FileWordIcon,
FileXMarkIcon,
} from "@navikt/aksel-icons";
import { Loader } from "../../../../loader";
import { FileItem } from "./Item.types";
interface ItemIconProps {
isLoading?: boolean;
file: FileItem;
showError: boolean;
}
const iconProps = {
fontSize: "2rem",
"aria-hidden": true,
};
function ItemIcon({ isLoading, file, showError }: ItemIconProps) {
if (isLoading) {
return (
<div className="navds-file-item__icon navds-file-item__icon--loading">
<Loader size="large" />
</div>
);
} else if (showError) {
return (
<div className="navds-file-item__icon">
<FileXMarkIcon {...iconProps} />
</div>
);
}
return (
<div className="navds-file-item__icon">
<Icon file={file} />
</div>
);
}
function Icon({ file }: { file: FileItem }) {
const extension = file.name.substring(file.name.lastIndexOf(".") + 1);
switch (extension) {
case "jpg":
case "jpeg":
case "png":
case "gif":
case "webp":
return <FileImageIcon {...iconProps} />;
case "pdf":
return <FilePdfIcon {...iconProps} />;
case "txt":
return <FileTextIcon {...iconProps} />;
case "csv":
return <FileCsvIcon {...iconProps} />;
case "xls":
case "xlsx":
return <FileExcelIcon {...iconProps} />;
case "doc":
case "docx":
return <FileWordIcon {...iconProps} />;
default:
return <FileIcon {...iconProps} />;
}
}
export default ItemIcon;
@@ -0,0 +1,58 @@
import React from "react";
import { Link } from "../../../../link";
import { FileItem } from "./Item.types";
import { downloadFile } from "./utils/download-file";
import { isNativeFile } from "./utils/file-type-checker";
interface Props {
file: FileItem;
href?: string;
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
}
const ItemName = ({ file, href, onClick }: Props) => {
if (onClick && href) {
return (
<Link href={href} onClick={onClick}>
{file.name}
</Link>
);
}
if (onClick) {
return (
<Link
href="#"
onClick={(event) => {
event.preventDefault();
onClick(event);
}}
>
{file.name}
</Link>
);
}
if (href) {
return <Link href={href}>{file.name}</Link>;
}
if (isNativeFile(file)) {
return (
<Link
href="#"
download={file.name}
onClick={(event) => {
event.preventDefault();
downloadFile(file);
}}
>
{file.name}
</Link>
);
}
return <span>{file.name}</span>;
};
export default ItemName;
@@ -0,0 +1,9 @@
export const downloadFile = (file: File): void => {
const a = document.createElement("a");
const url = URL.createObjectURL(file);
a.href = url;
a.download = file.name;
a.click();
URL.revokeObjectURL(url);
};
@@ -0,0 +1,4 @@
import { FileItem } from "../Item.types";
export const isNativeFile = (fileItem: FileItem): fileItem is File =>
"lastModified" in fileItem;
@@ -0,0 +1,76 @@
import { formatFileSize } from "./format-file-size";
describe("format-file-size", () => {
describe("with native File", () => {
it('returns "0,01 MB" when file size is less than 0,01 MB', () => {
const file = new File(["abc"], "file.txt");
expect(formatFileSize(file)).toBe("0,01 MB");
});
it('returns "0,29 MB" when file size is 0,29 MB', () => {
const file = new File(["abc".repeat(99_999)], "file.txt");
expect(formatFileSize(file)).toBe("0,29 MB");
});
it('returns "> 500 MB" when file size is more than 500 MB', () => {
const file = createLargeMockFile(600_000_000);
expect(formatFileSize(file)).toBe("> 500 MB");
});
});
describe("with MetadataFile", () => {
it("returns null when file size is undefined", () => {
const file = {
name: "myfile.txt",
};
expect(formatFileSize(file)).toBeNull();
});
it('returns "0,01 MB" when file size is less than 0,01 MB', () => {
const file = {
name: "myfile.txt",
size: 1,
};
expect(formatFileSize(file)).toBe("0,01 MB");
});
it('returns "0,96 MB" when file size is 0,96 MB', () => {
const file = {
name: "myfile.txt",
size: 999_999,
};
expect(formatFileSize(file)).toBe("0,96 MB");
});
it('returns "> 500 MB" when file size is more than 500 MB', () => {
const file = {
name: "myfile.txt",
size: 600_000_000,
};
expect(formatFileSize(file)).toBe("> 500 MB");
});
});
});
function createLargeMockFile(sizeInBytes: number): File {
const chunkSize = 1024 * 1024; // 1MB chunk size
const chunks: Uint8Array[] = [];
for (let i = 0; i < sizeInBytes; i += chunkSize) {
const size = Math.min(chunkSize, sizeInBytes - i);
const chunk = new Uint8Array(size);
chunk.fill("a".charCodeAt(0));
chunks.push(chunk);
}
const blob = new Blob(chunks, { type: "text/plain" });
return new File([blob], "largeMockFile.txt", {
type: "text/plain",
});
}
@@ -0,0 +1,25 @@
import { FileItem } from "../Item.types";
const MAX_MEGA_BYTES = 500;
export function formatFileSize(file: FileItem): string | null {
if (!file.size) {
return null;
}
const megaBytes = file.size / (1024 * 1024);
if (megaBytes <= MAX_MEGA_BYTES) {
return formatter.format(megaBytes);
}
return `> ${MAX_MEGA_BYTES} MB`;
}
const formatter = new Intl.NumberFormat("nb-NO", {
style: "unit",
unit: "megabyte",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
// @ts-expect-error - Looks like roundingMode hasn't been added to TypeScript yet
roundingMode: "ceil",
});
@@ -0,0 +1,54 @@
import { useRef } from "react";
import { useMergeRefs } from "../../util/hooks";
import { FileUploadBaseProps } from "./FileUpload.types";
import { validateFiles } from "./utils/validate-files";
export interface UseFileUploadProps
extends Omit<FileUploadBaseProps, "fileLimit"> {
ref: React.ForwardedRef<HTMLInputElement>;
disabled?: boolean;
}
export const useFileUpload = ({
ref,
accept,
onSelect,
validator,
maxSizeInBytes,
disabled,
}: UseFileUploadProps) => {
const inputRef = useRef<HTMLInputElement | null>(null);
const mergedRef = useMergeRefs(inputRef, ref);
const upload = (fileList: FileList) => {
const { files, partitionedFiles } = validateFiles(
Array.from(fileList),
accept,
validator,
maxSizeInBytes,
);
onSelect(files, partitionedFiles);
};
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const fileList = event.target.files;
if (!fileList) {
return;
}
if (!disabled) {
upload(fileList);
}
// Resets the value to make it is possible to upload the same file several consecutive times
event.target.value = "";
};
return {
upload,
onChange,
inputRef,
mergedRef,
};
};
@@ -0,0 +1,69 @@
import { isAcceptedFileType } from "./is-accepted-file-type";
const txtFile = () =>
new File(["foo"], "foo.txt", {
type: "text/plain",
});
describe("isAcceptedFileType", () => {
test("returns true when accept is undefined", () => {
expect(isAcceptedFileType(txtFile(), undefined)).toBe(true);
});
test("returns true when accept is empty string", () => {
expect(isAcceptedFileType(txtFile(), "")).toBe(true);
});
test("returns true when file matches accepted extensions", () => {
expect(isAcceptedFileType(txtFile(), ".txt, .pdf")).toBe(true);
});
test("works as expected when there is no space after comma in accept", () => {
expect(isAcceptedFileType(txtFile(), ".txt,.pdf")).toBe(true);
});
test("returns false when file does not match the accepted extensions", () => {
expect(isAcceptedFileType(txtFile(), ".xlsx, .pdf")).toBe(false);
});
test("returns true when file matches the accepted exact mime types", () => {
expect(isAcceptedFileType(txtFile(), "application/pdf, text/plain")).toBe(
true,
);
});
test("returns false when file does not the accepted exact mime types", () => {
expect(isAcceptedFileType(txtFile(), "application/pdf, text/csv")).toBe(
false,
);
});
test("returns true when file matches the accepted wildcard mime types", () => {
expect(isAcceptedFileType(txtFile(), "application/*, text/*")).toBe(true);
});
test("returns false when file does not the accepted wildcard mime types", () => {
expect(isAcceptedFileType(txtFile(), "application/*, image/*")).toBe(false);
});
test("returns true if matches extension, but not exact or wildcard mime type", () => {
expect(
isAcceptedFileType(txtFile(), "application/*, image/*, .txt, audio/mpeg"),
).toBe(true);
});
test("returns true if matches exact mime type, but not extension or wildcard mime type", () => {
expect(
isAcceptedFileType(
txtFile(),
"application/*, text/plain, .jpg, audio/mpeg",
),
).toBe(true);
});
test("returns true if matches wildcard mime type, but not extension or exact mime type", () => {
expect(
isAcceptedFileType(txtFile(), "application/*, text/*, .jpg, audio/mpeg"),
).toBe(true);
});
});
@@ -0,0 +1,25 @@
export function isAcceptedFileType(
file: File,
accept: string | undefined,
): boolean {
if (!accept) {
return true;
}
const mimeType = file.type;
const acceptedTypes = accept.split(",");
return acceptedTypes.some((type) => {
const validType = type.trim();
const isExtensionType = validType.startsWith(".");
const isWildcardMimeType = validType.endsWith("/*");
if (isExtensionType) {
return file.name.toLowerCase().endsWith(validType.toLowerCase());
} else if (isWildcardMimeType) {
const baseMimeType = mimeType.replace(/\/.*$/, "");
const baseValidType = validType.replace(/\/.*$/, "");
return baseMimeType === baseValidType;
}
return mimeType === validType;
});
}
@@ -0,0 +1,26 @@
import { isAcceptedSize } from "./is-accepted-size";
describe("isAcceptedSize", () => {
it("should return true if maxSize is less than or equal to 0", () => {
const file = new File([new Array(5000).join("a")], "filename.txt", {
type: "text/plain",
});
expect(isAcceptedSize(file, -1)).toBe(true);
expect(isAcceptedSize(file, 0)).toBe(true);
});
it("should return true if file size is less than or equal to maxSize", () => {
const file = new File([new Array(5000).join("a")], "filename.txt", {
type: "text/plain",
});
expect(isAcceptedSize(file, 5000)).toBe(true);
expect(isAcceptedSize(file, 6000)).toBe(true);
});
it("should return false if file size is greater than maxSize", () => {
const file = new File([new Array(5000).join("a")], "filename.txt", {
type: "text/plain",
});
expect(isAcceptedSize(file, 4000)).toBe(false);
});
});
@@ -0,0 +1,7 @@
export function isAcceptedSize(file: File, maxSize: number = -1): boolean {
if (maxSize <= 0 || file.size <= maxSize) {
return true;
}
return false;
}
@@ -0,0 +1,132 @@
import { validateFiles } from "./validate-files";
const createTxtFile = (name: string = "foo.txt") =>
new File(["foo"], name, {
type: "text/plain",
});
const createCsvFile = (name: string = "bar.csv") =>
new File(["abc,123"], name, {
type: "text/csv",
});
describe("partitionFiles", () => {
test("accepts all files when accept parameter is undefined", () => {
const files = [createTxtFile(), createCsvFile()];
const {
partitionedFiles: { accepted, rejected },
} = validateFiles(files);
expect(accepted.length).toBe(2);
expect(rejected.length).toBe(0);
});
test("accepts all files when accept parameter is empty", () => {
const files = [createTxtFile(), createCsvFile()];
const {
partitionedFiles: { accepted, rejected },
} = validateFiles(files, "");
expect(accepted.length).toBe(2);
expect(rejected.length).toBe(0);
});
test("rejects file that does not match accept parameter", () => {
const txtFileName = "foo.txt";
const csvFileName = "bar.csv";
const files = [createTxtFile(txtFileName), createCsvFile(csvFileName)];
const {
partitionedFiles: { accepted, rejected },
} = validateFiles(files, ".txt");
expect(accepted.length).toBe(1);
expect(rejected.length).toBe(1);
expect(accepted[0].name).toBe(txtFileName);
expect(rejected[0].file.name).toBe(csvFileName);
});
test("rejects file that does not pass validator", () => {
const txtFileName = "foo.txt";
const csvFileName = "bar.csv";
const csvFile = createCsvFile(csvFileName);
const txtFile = createTxtFile(txtFileName);
const files = [txtFile, csvFile];
const validator = (file: File) => {
if (file.name === txtFile.name) {
return true;
}
return "custom validation error";
};
const {
partitionedFiles: { accepted, rejected },
} = validateFiles(files, undefined, validator);
expect(accepted.length).toBe(1);
expect(rejected.length).toBe(1);
expect(accepted[0].name).toBe(txtFileName);
expect(rejected[0].file.name).toBe(csvFileName);
expect(rejected[0].reasons).toEqual(["custom validation error"]);
});
test("rejects file that passes accept parameter but does not pass validator", () => {
const txtFileName = "foo.txt";
const csvFileName = "bar.csv";
const csvFile = createCsvFile(csvFileName);
const txtFile = createTxtFile(txtFileName);
const files = [txtFile, csvFile];
const validator = (file: File) => {
if (file.name === txtFile.name) {
return true;
}
return "custom validation error";
};
const {
partitionedFiles: { accepted, rejected },
} = validateFiles(files, ".txt, .csv", validator);
expect(accepted.length).toBe(1);
expect(rejected.length).toBe(1);
expect(accepted[0].name).toBe(txtFileName);
expect(rejected[0].file.name).toBe(csvFileName);
});
test("rejects file that passes validator but does not pass accept parameter", () => {
const txtFileName = "foo.txt";
const csvFileName = "bar.csv";
const csvFile = createCsvFile(csvFileName);
const txtFile = createTxtFile(txtFileName);
const files = [txtFile, csvFile];
const validator = (file: File) => {
if (file.name === csvFile.name || file.name === txtFile.name) {
return true;
}
return "custom validation error";
};
const {
partitionedFiles: { accepted, rejected },
} = validateFiles(files, ".csv", validator);
expect(accepted.length).toBe(1);
expect(rejected.length).toBe(1);
expect(accepted[0].name).toBe(csvFileName);
expect(rejected[0].file.name).toBe(txtFileName);
expect(rejected[0].reasons).toEqual(["fileType"]);
});
test("rejects file that is too large", () => {
const txtFileName = "foo.txt";
const csvFileName = "bar.csv";
const csvFile = createCsvFile(csvFileName);
const txtFile = createTxtFile(txtFileName);
const files = [txtFile, csvFile];
const maxSizeInBytes = 5;
const {
partitionedFiles: { accepted, rejected },
} = validateFiles(files, undefined, undefined, maxSizeInBytes);
expect(accepted.length).toBe(1);
expect(rejected.length).toBe(1);
expect(accepted[0].name).toBe(txtFileName);
expect(rejected[0].file.name).toBe(csvFileName);
expect(rejected[0].reasons).toEqual(["fileSize"]);
});
});
@@ -0,0 +1,62 @@
import {
FileObject,
FileUploadBaseProps,
FilesPartitioned,
fileRejectionReason,
} from "../FileUpload.types";
import { isAcceptedFileType } from "./is-accepted-file-type";
import { isAcceptedSize } from "./is-accepted-size";
export const validateFiles = (
files: File[],
accept?: string,
validator?: FileUploadBaseProps["validator"],
maxSizeInBytes: number = -1,
) => {
const allFiles: FileObject[] = [];
const accepted: File[] = [];
const rejected: FilesPartitioned["rejected"] = [];
files.forEach((file) => {
const acceptedFileType = isAcceptedFileType(file, accept);
const acceptedFileSize = isAcceptedSize(file, maxSizeInBytes);
const customValidation = validator ? validator(file) : true;
const reasons: FilesPartitioned["rejected"][0]["reasons"] = [];
if (customValidation !== true) {
reasons.push(customValidation);
}
if (!acceptedFileType) {
reasons.push(fileRejectionReason.FileType);
}
if (!acceptedFileSize) {
reasons.push(fileRejectionReason.FileSize);
}
allFiles.push({
file,
error: reasons.length > 0,
reasons,
});
if (reasons.length === 0) {
accepted.push(file);
return;
}
rejected.push({
file,
reasons,
});
});
return {
files: allFiles,
partitionedFiles: {
accepted,
rejected,
},
};
};
@@ -18,7 +18,7 @@ export const RadioGroupContext =
export interface RadioGroupProps
extends Omit<
FieldsetProps,
"onChange" | "errorPropagation" | "defaultValue"
"onChange" | "errorPropagation" | "defaultValue" | "nativeReadOnly"
> {
/**
* Collection of `<Radio />`-elements
@@ -49,6 +49,7 @@ export const Default = (props) => {
</RadioGroup>
);
};
Default.args = {
controlled: false,
error: false,
+3 -3
View File
@@ -5,15 +5,15 @@ export interface RadioProps
extends Omit<FormFieldProps, "error" | "errorId" | "readOnly">,
Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "value"> {
/**
* Radio label
* Radio label.
*/
children: React.ReactNode;
/**
* The value of the HTML element
* Radio value.
*/
value: any;
/**
* Adds a description to extend labling of Radio
* Adds a description to extend the labeling.
*/
description?: string;
}
+15 -11
View File
@@ -28,47 +28,51 @@ export interface SearchProps
/**
* Search label.
*
* Will be hidden by default, is required for accessibility reasons.
* Will be hidden by default and is required for WCAG compliance.
*/
label: React.ReactNode;
/**
* Shows label and description for screenreaders-only
* Shows label and description for screenreaders only.
* @default true
*/
hideLabel?: boolean;
/**
* Callback for value-change in input
* Callback for value-change in input.
*/
onChange?: (value: string) => void;
/**
* Callback for click on clear-button or Escape keydown
* Callback for click on clear-button or Escape keydown.
*/
onClear?: (e: SearchClearEvent) => void;
/**
* Callback for Search-button submit
* Callback for Search-button submit.
*/
onSearchClick?: (value: string) => void;
/**
* aria-label on clear button
* Sets the `aria-label` for the clear button.
* @default "Tøm"
*/
clearButtonLabel?: string;
/**
* If false, removes clear-button option from input.
* Removes clear-button if `false`.
* @default true
*/
clearButton?: boolean;
/**
* Changes button-variant, "simple" removes button
* Changes button-variant.
*
* - "primary": When this is the main function of the page.
* - "secondary": This is probably the one you want if in doubt.
* - "simple": Removes the search button.
* @default "primary"
*/
variant?: "primary" | "secondary" | "simple";
/**
* Exposes the HTML size attribute
* Exposes the HTML size attribute. Specifies the width of the element, in characters.
*/
htmlSize?: number | string;
/*
* Exposes role attribute
* Exposes role attribute.
*/
role?: string;
}
@@ -88,7 +92,7 @@ interface SearchComponent
*
* @example
* ```jsx
* <form>
* <form role="search">
* <Search label="Søk alle NAV sine sider" variant="primary" />
* </form>
* ```
@@ -53,7 +53,7 @@ export interface SwitchProps
*/
position?: "left" | "right";
/**
* Adds a description to extend labling of Switch.
* Adds a description to extend labeling of Switch.
*/
description?: string;
}
@@ -85,7 +85,7 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
...rest
} = props;
const [checked, setChecked] = useState(
const [_checked, setChecked] = useState(
defaultChecked ?? checkedProp ?? false,
);
@@ -93,6 +93,8 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
checkedProp !== undefined && setChecked(checkedProp);
}, [checkedProp]);
const checked = checkedProp ?? _checked;
return (
<div
className={cl(
@@ -1,5 +1,6 @@
import { Meta } from "@storybook/react";
import { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { VStack } from "../../layout/stack";
import Switch from "./Switch";
export default {
@@ -31,70 +32,59 @@ export default {
type: "boolean",
},
},
} as Meta;
export const Default = {
render: (props) => {
return <Switch {...props}>Label text</Switch>;
parameters: {
chromatic: { disable: true },
},
} satisfies Meta<typeof Switch>;
type Story = StoryObj<typeof Switch>;
export const Default: Story = {
args: {
position: "right",
children: "Label text",
},
};
export const Small = () => {
return (
<div className="colgap">
<Switch size="small">Label text</Switch>
<Switch size="small" position="right">
Label text
</Switch>
</div>
);
export const Small: Story = {
args: {
children: "Label text",
size: "small",
},
};
export const Description = () => {
return (
<div className="colgap">
<Switch size="small" description="Cillum sint exercitation ut cillum.">
Label text
</Switch>
<Switch
size="small"
position="right"
description="Cillum sint exercitation ut cillum."
>
Label text
</Switch>
</div>
);
export const Description: Story = {
args: {
children: "Label text",
description: "Cillum sint exercitation ut cillum.",
},
};
export const Loading = () => {
return (
<div className="colgap">
export const Loading: Story = {
render: () => {
return (
<div className="colgap">
<Switch loading>Label text</Switch>
<div className="colgap">
<Switch loading>Label text</Switch>
<Switch checked loading>
Label text
</Switch>
<Switch checked loading>
Label text
</Switch>
</div>
<div className="colgap">
<Switch loading size="small">
Label text
</Switch>
<Switch checked loading size="small">
Label text
</Switch>
</div>
</div>
<div className="colgap">
<Switch loading size="small">
Label text
</Switch>
<Switch checked loading size="small">
Label text
</Switch>
</div>
</div>
);
);
},
};
export const Disabled = () => {
return (
export const Disabled: Story = {
render: () => (
<div className="colgap">
<Switch disabled>Label text</Switch>
@@ -102,11 +92,11 @@ export const Disabled = () => {
Label text
</Switch>
</div>
);
),
};
export const HideLabel = () => {
return (
export const HideLabel: Story = {
render: () => (
<div className="colgap">
<Switch hideLabel>Label text</Switch>
@@ -114,11 +104,11 @@ export const HideLabel = () => {
Label text
</Switch>
</div>
);
),
};
export const Readonly = () => {
return (
export const Readonly: Story = {
render: () => (
<div className="colgap">
<Switch description="Slår av alle notifikasjoner" readOnly>
Notifikasjoner
@@ -128,5 +118,54 @@ export const Readonly = () => {
Notifikasjoner
</Switch>
</div>
);
),
};
export const Chromatic: Story = {
render: () => (
<VStack gap="4" align="start">
<div>
<h2>Default</h2>
{/* @ts-expect-error Args are Partial here */}
<Switch {...Default.args} />
</div>
<div>
<h2>Small</h2>
{/* @ts-expect-error Args are Partial here */}
<Switch {...Small.args} position="left" />
{/* @ts-expect-error Args are Partial here */}
<Switch {...Small.args} position="right" />
</div>
<div>
<h2>Description</h2>
{/* @ts-expect-error Args are Partial here */}
<Switch {...Description.args} position="left" />
{/* @ts-expect-error Args are Partial here */}
<Switch {...Description.args} position="right" />
</div>
<div>
<h2>Loading</h2>
{/* @ts-expect-error Args are Partial here */}
<Loading.render />
</div>
<div>
<h2>Disabled</h2>
{/* @ts-expect-error Args are Partial here */}
<Disabled.render />
</div>
<div>
<h2>HideLabel</h2>
{/* @ts-expect-error Args are Partial here */}
<HideLabel.render />
</div>
<div>
<h2>Readonly</h2>
{/* @ts-expect-error Args are Partial here */}
<Readonly.render />
</div>
</VStack>
),
parameters: {
chromatic: { disable: false },
},
};
+2 -3
View File
@@ -5,8 +5,7 @@ import { FieldsetContext } from "./fieldset/context";
export interface FormFieldProps {
/**
* Adds a description to extend labling 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 labling 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>
),
+14
View File
@@ -136,6 +136,20 @@ export { Select, type SelectProps } from "./form/select";
export { Switch, type SwitchProps } from "./form/switch";
export { Textarea, type TextareaProps } from "./form/textarea";
export { TextField, type TextFieldProps } from "./form/textfield";
export {
UNSAFE_FileUpload,
type FileUploadDropzoneProps,
type FileUploadTriggerProps,
type FileObject,
type FileRejected,
type FileAccepted,
type FileRejectedPartitioned,
type FilesPartitioned,
type FileRejectionReason,
type FileUploadItemProps,
type FileItem,
type FileMetadata,
} from "./form/file-upload";
/**
* @deprecated
@@ -2,10 +2,12 @@ import { Meta } from "@storybook/react";
import React from "react";
import {
ChevronDownIcon,
CogIcon,
ExternalLinkIcon,
LeaveIcon,
MenuGridIcon,
} from "@navikt/aksel-icons";
import { BodyLong, BodyShort, Detail } from "@navikt/ds-react";
import { BodyLong, BodyShort, Detail, Spacer } from "@navikt/ds-react";
import { Dropdown } from "../dropdown";
import InternalHeader from "./InternalHeader";
@@ -102,10 +104,11 @@ export const Default = {
<Dropdown.Menu.Divider />
<Dropdown.Menu.List>
<Dropdown.Menu.List.Item as="a" href="/#settings">
Innstillinger
Innstillinger <Spacer />{" "}
<CogIcon aria-hidden fontSize="1.5rem" />
</Dropdown.Menu.List.Item>
<Dropdown.Menu.List.Item onClick={() => console.log("logg ut")}>
Logg ut
Logg ut <Spacer /> <LeaveIcon aria-hidden fontSize="1.5rem" />
</Dropdown.Menu.List.Item>
</Dropdown.Menu.List>
</Dropdown.Menu>
@@ -180,10 +183,10 @@ export const UserWithMenu = () => (
<Dropdown.Menu.Divider />
<Dropdown.Menu.List>
<Dropdown.Menu.List.Item as="a" href="/#settings">
Innstillinger
Innstillinger <Spacer /> <CogIcon aria-hidden fontSize="1.5rem" />
</Dropdown.Menu.List.Item>
<Dropdown.Menu.List.Item onClick={() => console.log("logg ut")}>
Logg ut
Logg ut <Spacer /> <LeaveIcon aria-hidden fontSize="1.5rem" />
</Dropdown.Menu.List.Item>
</Dropdown.Menu.List>
</Dropdown.Menu>
-7
View File
@@ -32,13 +32,6 @@ export interface LoaderProps extends Omit<SVGProps<SVGSVGElement>, "ref"> {
* @default "neutral"
*/
variant?: "neutral" | "interaction" | "inverted";
/**
* Allows getting a ref to the component instance.
* Once the component unmounts, React will set `ref.current` to `null` (or call the ref with `null` if you passed a callback ref).
*
* @see https://react.dev/learn/referencing-values-with-refs#refs-and-the-dom
*/
ref?: React.Ref<SVGSVGElement>;
}
/* Workaround for @types/react v17/v18 feil */
+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>
@@ -1,65 +1,91 @@
import { Meta, StoryFn } from "@storybook/react";
import React, { useState } from "react";
import { Meta, StoryObj } from "@storybook/react";
import { fn } from "@storybook/test";
import React from "react";
import { ReadMore } from ".";
import { VStack } from "../layout/stack";
const meta: Meta<typeof ReadMore> = {
export default {
title: "ds-react/ReadMore",
component: ReadMore,
};
export default meta;
parameters: {
chromatic: { disable: true },
},
} satisfies Meta<typeof ReadMore>;
export const Default: StoryFn<{
controlled: boolean;
size: "medium" | "small";
defaultOpen: boolean;
}> = (props) => {
const [state, setState] = useState(false);
type Story = StoryObj<typeof ReadMore>;
return (
<ReadMore
open={props.controlled ? state : undefined}
onClick={() => setState((x) => !x)}
header="Grunnen til at vi spør om dette og i tillegg ber om vedlegg"
size={props.size}
onOpenChange={console.log}
defaultOpen={props.defaultOpen}
>
<div style={{ maxWidth: 300 }}>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Mollitia,
tempore corporis exercitationem minus dignissimos eius aspernatur fugiat
iusto.
</div>
</ReadMore>
);
};
Default.args = {
controlled: false,
size: "medium",
defaultOpen: false,
};
Default.argTypes = {
size: {
options: ["medium", "small"],
control: { type: "radio" },
const Content = (
<div style={{ maxWidth: 300 }}>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Mollitia, tempore
corporis exercitationem minus dignissimos eius aspernatur fugiat iusto.
</div>
);
export const Default: Story = {
args: {
size: "medium",
defaultOpen: false,
onOpenChange: fn(),
children: Content,
header: "Grunnen til at vi spør om dette og i tillegg ber om vedlegg",
},
argTypes: {
size: {
options: ["medium", "small"],
control: { type: "radio" },
},
},
};
export const Small = () => (
<ReadMore
header="Grunnen til at vi spør om dette og i tillegg ber om vedlegg"
size="small"
>
Command station, this is ST 321. Code Clearance Blue. We&apos;re starting
our approach. Deactivate the security shield.
</ReadMore>
);
export const Small: Story = {
args: {
...Default.args,
size: "small",
},
};
export const Open = () => (
<ReadMore
header="Grunnen til at vi spør om dette og i tillegg ber om vedlegg"
defaultOpen
>
Command station, this is ST 321. Code Clearance Blue. We&apos;re starting
our approach. Deactivate the security shield.
</ReadMore>
);
export const DefaultOpen: Story = {
args: {
...Default.args,
defaultOpen: true,
},
};
export const Open: Story = {
args: {
...Default.args,
open: true,
},
};
export const Chromatic: Story = {
render: () => {
return (
<VStack gap="4">
<div>
<h2>Default</h2>
{/* @ts-expect-error Args are partial, leading to required prop mismatch */}
<ReadMore {...Default.args} />
</div>
<div>
<h2>Small</h2>
{/* @ts-expect-error Args are partial, leading to required prop mismatch */}
<ReadMore {...Small.args} />
</div>
<div>
<h2>DefaultOpen</h2>
{/* @ts-expect-error Args are partial, leading to required prop mismatch */}
<ReadMore {...DefaultOpen.args} />
</div>
<div>
<h2>Controlled open</h2>
{/* @ts-expect-error Args are partial, leading to required prop mismatch */}
<ReadMore {...Open.args} />
</div>
</VStack>
);
},
parameters: {
chromatic: { disable: false },
},
};
+1 -6
View File
@@ -15,12 +15,7 @@ export interface DataCellProps
textSize?: "medium" | "small";
}
export interface DataCellType
extends React.ForwardRefExoticComponent<
DataCellProps & React.RefAttributes<HTMLTableCellElement>
> {}
export const DataCell: DataCellType = forwardRef(
export const DataCell = forwardRef<HTMLTableCellElement, DataCellProps>(
({ className, children = "", align, textSize, ...rest }, ref) => {
return (
<BodyShort
@@ -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";
@@ -16,6 +16,7 @@ export interface CreateContextOptions<T> {
errorMessage?: string;
name?: string;
defaultValue?: T;
strict?: boolean;
}
type ProviderProps<T> = T & { children: React.ReactNode };
@@ -1,7 +1,7 @@
/* https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/composeRefs.tsx */
import React from "react";
type PossibleRef<T> = React.Ref<T> | undefined;
type PossibleRef<T> = React.LegacyRef<T> | undefined;
// https://github.com/gregberge/react-merge-refs
/**
+18
View File
@@ -1,5 +1,23 @@
# @navikt/ds-tailwind
## 6.4.1
## 6.4.0
## 6.3.6
## 6.3.5
## 6.3.4
## 6.3.3
## 6.3.2
## 6.3.1
## 6.3.0
## 6.2.0
## 6.1.1
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tailwind",
"version": "6.2.0",
"version": "6.4.1",
"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.2.0",
"@navikt/ds-tokens": "^6.4.1",
"color": "4.2.3",
"lodash": "^4.17.21",
"tailwindcss": "^3.3.3",
+18
View File
@@ -1,5 +1,23 @@
# @navikt/ds-tokens
## 6.4.1
## 6.4.0
## 6.3.6
## 6.3.5
## 6.3.4
## 6.3.3
## 6.3.2
## 6.3.1
## 6.3.0
## 6.2.0
## 6.1.1
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff. Show more