mirror of
https://github.com/navikt/aksel.git
synced 2026-10-13 17:22:02 +00:00
Compare commits
129
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b691b6cd0 | ||
|
|
1b048ba5c3 | ||
|
|
e92c897c01 | ||
|
|
a1041d13cd | ||
|
|
241dd79ecb | ||
|
|
6aaf56bc10 | ||
|
|
7becf3e4cf | ||
|
|
a358d222b5 | ||
|
|
24d4d48c87 | ||
|
|
2d0c2c6567 | ||
|
|
bd482c5d3e | ||
|
|
c8c3fc4ec5 | ||
|
|
834ba0cbae | ||
|
|
be653cc67a | ||
|
|
054835d1dd | ||
|
|
7f99d407c0 | ||
|
|
1202e0a6f7 | ||
|
|
0c06fa12d0 | ||
|
|
4797ccae9d | ||
|
|
ac06d882a7 | ||
|
|
8f5c956bad | ||
|
|
6b40a0ddd8 | ||
|
|
60a6cba329 | ||
|
|
7fd62f3b79 | ||
|
|
0944bbc256 | ||
|
|
16087a458a | ||
|
|
c47c372d0f | ||
|
|
0c479831df | ||
|
|
41e9fbd2a5 | ||
|
|
7b25f30e60 | ||
|
|
db9c858153 | ||
|
|
b21015bf54 | ||
|
|
bac48d9057 | ||
|
|
e1a85dfbeb | ||
|
|
3f65eee9e9 | ||
|
|
cd77198f1c | ||
|
|
82827ec712 | ||
|
|
710ed83661 | ||
|
|
3b44a97725 | ||
|
|
edcbd6e198 | ||
|
|
bd3f9355eb | ||
|
|
f11bce1f8c | ||
|
|
277525f39f | ||
|
|
210496c2cc | ||
|
|
8f317878fc | ||
|
|
c3064a01f7 | ||
|
|
02f0a911b0 | ||
|
|
ee8524229f | ||
|
|
d9932e203a | ||
|
|
b14fc23ecb | ||
|
|
c16f75d6f8 | ||
|
|
a01be91608 | ||
|
|
8826a79021 | ||
|
|
c2f3da84cc | ||
|
|
2c1476022d | ||
|
|
dacf648c47 | ||
|
|
5ec986771b | ||
|
|
ca6af8a5f7 | ||
|
|
9410840f74 | ||
|
|
d88884f785 | ||
|
|
0c8239ee00 | ||
|
|
987118fd47 | ||
|
|
8d13f11689 | ||
|
|
158e18454e | ||
|
|
be26bf22c9 | ||
|
|
c2968a0e6d | ||
|
|
3f92773273 | ||
|
|
0c3ab1d0e7 | ||
|
|
3a73a029f6 | ||
|
|
6fd0f501f5 | ||
|
|
a11b8ddf4e | ||
|
|
171db6a813 | ||
|
|
085cc532ee | ||
|
|
deae4ecba5 | ||
|
|
cd2adcb655 | ||
|
|
0d4385a545 | ||
|
|
6f1b9751f3 | ||
|
|
7fb97808f0 | ||
|
|
d8ac958de2 | ||
|
|
b417c74374 | ||
|
|
ff1d3534a6 | ||
|
|
57d015209d | ||
|
|
5969859024 | ||
|
|
9bbc3e6813 | ||
|
|
5250b47ca4 | ||
|
|
fc7709ae2a | ||
|
|
2364e689ed | ||
|
|
d57c0b2c2c | ||
|
|
868bb543a5 | ||
|
|
8f9ed7a404 | ||
|
|
5ea6255f6d | ||
|
|
491fccbe80 | ||
|
|
cca2a8263f | ||
|
|
3140ec2765 | ||
|
|
d8fce83ae3 | ||
|
|
f88b00daf6 | ||
|
|
6d0baea5c1 | ||
|
|
880e77b9c2 | ||
|
|
6d16373f85 | ||
|
|
ae078254dc | ||
|
|
638e8260b4 | ||
|
|
d991011690 | ||
|
|
b6086f7b1f | ||
|
|
05462081aa | ||
|
|
63e42de3ee | ||
|
|
2e85178166 | ||
|
|
456d4e2e07 | ||
|
|
b5a0cb8cdf | ||
|
|
a2af9bcc37 | ||
|
|
bd45754adb | ||
|
|
a882a8a389 | ||
|
|
d1606d358a | ||
|
|
bb87837632 | ||
|
|
9c8b2301ed | ||
|
|
54ba483d2d | ||
|
|
85f5458c06 | ||
|
|
19abec80f7 | ||
|
|
b2ddce5279 | ||
|
|
bd7c945158 | ||
|
|
ca24684a25 | ||
|
|
1564363985 | ||
|
|
ca8d36e885 | ||
|
|
8efc6c58be | ||
|
|
2ea2a91a37 | ||
|
|
aebba20fd4 | ||
|
|
5ce4b4ea79 | ||
|
|
548d9bb169 | ||
|
|
25e0d15d67 | ||
|
|
98f6d7dde4 |
No files matched your search
@@ -19,6 +19,7 @@ add links to project-tasks, slack discussions etc here
|
||||
- Check that styling is correctly exported from `@navikt/core/css/index.css`
|
||||
- Check that correct tokens for component is documented in `@navikt/core/css/tokens.json`
|
||||
- Check that only needed components and types are exported from `@navikt/core/react/src/index.ts`
|
||||
- Check that component is exported from `@navikt/core/react/package.json`
|
||||
- Add JSDoc to component (see other components for reference)
|
||||
- Create storybook-stories for relevant component variations and compositions. Stories are used for [visual regression testing](https://www.chromatic.com/docs/), so make sure they are complete.
|
||||
- Is the component sufficiently complex to warrant tests? Add some unit-tests for vitest.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# @navikt/aksel-icons
|
||||
|
||||
## 6.8.0
|
||||
|
||||
## 6.7.1
|
||||
|
||||
## 6.7.0
|
||||
|
||||
## 6.6.1
|
||||
|
||||
## 6.6.0
|
||||
|
||||
## 6.5.0
|
||||
|
||||
## 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
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"postcss": "^8.4.31",
|
||||
"postcss-url": "^10.1.3",
|
||||
"typescript": "^5.1.6",
|
||||
"vite": "^5.0.12",
|
||||
"vite": "^5.1.7",
|
||||
"vite-plugin-singlefile": "^1.0.0",
|
||||
"vite-plugin-svgr": "^4.2.0"
|
||||
}
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel-icons",
|
||||
"version": "6.2.0",
|
||||
"version": "6.8.0",
|
||||
"author": "Aksel | NAV",
|
||||
"homepage": "https://aksel.nav.no/ikoner",
|
||||
"repository": {
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# @navikt/aksel-stylelint
|
||||
|
||||
## 6.8.0
|
||||
|
||||
## 6.7.1
|
||||
|
||||
## 6.7.0
|
||||
|
||||
## 6.6.1
|
||||
|
||||
## 6.6.0
|
||||
|
||||
## 6.5.0
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel-stylelint",
|
||||
"version": "6.2.0",
|
||||
"version": "6.8.0",
|
||||
"author": "Aksel | NAV",
|
||||
"homepage": "https://aksel.nav.no/grunnleggende/kode/stylelint",
|
||||
"repository": {
|
||||
@@ -35,8 +35,8 @@
|
||||
"dev": "yarn watch:lint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@navikt/ds-css": "^6.2.0",
|
||||
"@navikt/ds-tokens": "^6.2.0",
|
||||
"@navikt/ds-css": "^6.8.0",
|
||||
"@navikt/ds-tokens": "^6.8.0",
|
||||
"concurrently": "7.2.1",
|
||||
"postcss-selector-parser": "^6.0.13",
|
||||
"postcss-value-parser": "^4.2.0",
|
||||
|
||||
@@ -1,5 +1,112 @@
|
||||
# @navikt/aksel
|
||||
|
||||
## 6.8.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`bd482c5d3`](https://github.com/navikt/aksel/commit/bd482c5d3ea9c7f84114d1199e73594c7b593154), [`7becf3e4c`](https://github.com/navikt/aksel/commit/7becf3e4cf47b119e4b456a066b53e6077669bcd)]:
|
||||
- @navikt/ds-css@6.8.0
|
||||
|
||||
## 6.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`0c06fa12d`](https://github.com/navikt/aksel/commit/0c06fa12d0de4f3015be045d4e65e149ebd2c141)]:
|
||||
- @navikt/ds-css@6.7.1
|
||||
|
||||
## 6.7.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`db9c85815`](https://github.com/navikt/aksel/commit/db9c85815392528e59ef5082397ec14b1aa36e7a), [`277525f39`](https://github.com/navikt/aksel/commit/277525f39ff94c7ded1accf6031957efa3bdeb8b), [`b21015bf5`](https://github.com/navikt/aksel/commit/b21015bf54b4e19673b2849756c608ae323a9f70)]:
|
||||
- @navikt/ds-css@6.7.0
|
||||
|
||||
## 6.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-css@6.6.1
|
||||
|
||||
## 6.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Aksel CLI: Fjernet deprecated komponenter fra css oversikt. ([#2860](https://github.com/navikt/aksel/pull/2860))
|
||||
|
||||
- Updated dependencies [[`3a73a029f`](https://github.com/navikt/aksel/commit/3a73a029f6272f76e10cb6baefbaa2040feaef6b)]:
|
||||
- @navikt/ds-css@6.6.0
|
||||
|
||||
## 6.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-css@6.5.0
|
||||
|
||||
## 6.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`5250b47ca`](https://github.com/navikt/aksel/commit/5250b47ca410f9cd57c5350bacc77b1b2ec9b2c0)]:
|
||||
- @navikt/ds-css@6.4.1
|
||||
|
||||
## 6.4.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-css@6.4.0
|
||||
|
||||
## 6.3.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-css@6.3.6
|
||||
|
||||
## 6.3.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-css@6.3.5
|
||||
|
||||
## 6.3.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel",
|
||||
"version": "6.2.0",
|
||||
"version": "6.8.0",
|
||||
"description": "Aksel command line interface. Handles css-imports, codemods and more",
|
||||
"author": "Aksel | NAV designsystem team",
|
||||
"license": "MIT",
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"homepage": "https://aksel.nav.no/grunnleggende/kode/kommandolinje",
|
||||
"dependencies": {
|
||||
"@navikt/ds-css": "6.2.0",
|
||||
"@navikt/ds-css": "6.8.0",
|
||||
"axios": "1.6.0",
|
||||
"chalk": "4.1.0",
|
||||
"clipboardy": "^2.3.0",
|
||||
|
||||
@@ -1,5 +1,73 @@
|
||||
# @navikt/ds-css
|
||||
|
||||
## 6.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- :sparkles: Ny komponent: FormProgress ([#2855](https://github.com/navikt/aksel/pull/2855))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- HGrid: forenklet CSS. ([#2917](https://github.com/navikt/aksel/pull/2917))
|
||||
|
||||
## 6.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Progressbar: Tweak API, examples, stories and css ([#2892](https://github.com/navikt/aksel/pull/2892))
|
||||
|
||||
## 6.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- :sparkles: Ny komponent: ProgressBar ([#2845](https://github.com/navikt/aksel/pull/2845))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Checkbox, Radio: Erstatter `:focus-visible` med `:focus` for bedre UX brukt sammen med ErrorSummary. ([#2893](https://github.com/navikt/aksel/pull/2893))
|
||||
|
||||
- :lipstick: tweak CSS for timeline pins. ([#2889](https://github.com/navikt/aksel/pull/2889))
|
||||
|
||||
## 6.6.1
|
||||
|
||||
## 6.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- :sparkles: Ny komponent FormSummary ([#2802](https://github.com/navikt/aksel/pull/2802))
|
||||
|
||||
## 6.5.0
|
||||
|
||||
## 6.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- FileUpload: Item-illustrasjon er nå radius-full ([#2834](https://github.com/navikt/aksel/pull/2834))
|
||||
|
||||
## 6.4.0
|
||||
|
||||
## 6.3.6
|
||||
|
||||
## 6.3.5
|
||||
|
||||
## 6.3.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* https://web.dev/prefers-reduced-motion/ */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*:not(.navds-loader *):not(.navds-loader),
|
||||
*:not(.navds-loader *):not(.navds-loader):not(.navds-progress-bar *):not(.navds-progress-bar),
|
||||
::before,
|
||||
::after {
|
||||
animation-delay: -1ms !important;
|
||||
|
||||
@@ -97,10 +97,6 @@ const StyleMappings = {
|
||||
main: formCss,
|
||||
dependencies: [typoCss],
|
||||
},
|
||||
{
|
||||
component: "ContentContainer",
|
||||
main: "content-container.css",
|
||||
},
|
||||
{
|
||||
component: "CopyButton",
|
||||
main: "copybutton.css",
|
||||
@@ -120,7 +116,11 @@ const StyleMappings = {
|
||||
dependencies: [typoCss],
|
||||
},
|
||||
{ component: "Fieldset", main: formCss, dependencies: [typoCss] },
|
||||
{ component: "Grid", main: "grid.css", dependencies: [typoCss] },
|
||||
{
|
||||
component: "FileUpload",
|
||||
main: formCss,
|
||||
dependencies: [typoCss, "button.css", "loader.css", "link.css"],
|
||||
},
|
||||
{
|
||||
component: "GuidePanel",
|
||||
main: "guide-panel.css",
|
||||
@@ -159,6 +159,7 @@ const StyleMappings = {
|
||||
},
|
||||
{ component: "Panel", main: "panel.css" },
|
||||
{ component: "Popover", main: "popover.css" },
|
||||
{ component: "ProgressBar", main: "progress-bar.css" },
|
||||
{ component: "Provider", main: "" },
|
||||
{ component: "Radio", main: formCss, dependencies: [typoCss] },
|
||||
{ component: "RadioGroup", main: formCss, dependencies: [typoCss] },
|
||||
@@ -173,6 +174,22 @@ const StyleMappings = {
|
||||
{ component: "Tag", main: "tag.css", dependencies: [typoCss] },
|
||||
{ component: "TextField", main: formCss, dependencies: [typoCss] },
|
||||
{ component: "Textarea", main: formCss, dependencies: [typoCss] },
|
||||
{
|
||||
component: "FormSummary",
|
||||
main: formCss,
|
||||
dependencies: [typoCss, "link.css"],
|
||||
},
|
||||
{
|
||||
component: "FormProgress",
|
||||
main: formCss,
|
||||
dependencies: [
|
||||
typoCss,
|
||||
"progress-bar.css",
|
||||
"button.css",
|
||||
primitivesCss,
|
||||
"stepper.css",
|
||||
],
|
||||
},
|
||||
{
|
||||
component: "ToggleGroup",
|
||||
main: "toggle-group.css",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
.navds-form-progress__bar {
|
||||
margin-bottom: var(--a-spacing-2);
|
||||
}
|
||||
|
||||
.navds-form-progress__button[data-state="closed"] .navds-form-progress__btn-txt-hide,
|
||||
.navds-form-progress__button[data-state="open"] .navds-form-progress__btn-txt-show {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navds-form-progress__button:hover svg {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.navds-form-progress__button[data-state="open"] svg {
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
|
||||
.navds-form-progress__button[data-state="open"]:hover svg {
|
||||
transform: translateY(-1px) rotate(-180deg);
|
||||
}
|
||||
|
||||
.navds-form-progress__collapsible {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
overflow: hidden;
|
||||
transition: grid-template-rows 0.3s cubic-bezier(0.3, 0, 0.15, 1);
|
||||
}
|
||||
|
||||
.navds-form-progress__collapsible[hidden] {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.navds-form-progress__collapsible[data-state="open"] {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.navds-form-progress__collapsible-content {
|
||||
min-height: 0;
|
||||
transition: visibility 0.3s;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.navds-form-progress__collapsible[data-state="open"] .navds-form-progress__collapsible-content {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.navds-form-progress__stepper {
|
||||
border: 2px solid var(--a-border-subtle);
|
||||
border-radius: var(--a-border-radius-large);
|
||||
padding: var(--a-spacing-2) var(--a-spacing-4) 0;
|
||||
margin-top: var(--a-spacing-1);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
.navds-form-summary {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--a-border-subtle);
|
||||
border-radius: var(--a-border-radius-large);
|
||||
}
|
||||
|
||||
.navds-form-summary__header {
|
||||
background-color: var(--a-surface-subtle);
|
||||
padding: var(--a-spacing-4) var(--a-spacing-6);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0 var(--a-spacing-2);
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.navds-form-summary__header {
|
||||
padding: var(--a-spacing-3) var(--a-spacing-4);
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.navds-form-summary__edit {
|
||||
flex-shrink: 0;
|
||||
margin-top: var(--a-spacing-1);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.navds-form-summary > .navds-form-summary__answers {
|
||||
border-top: 1px solid var(--a-border-subtle);
|
||||
}
|
||||
|
||||
.navds-form-summary__answers {
|
||||
padding: var(--a-spacing-5) var(--a-spacing-6) var(--a-spacing-6);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.navds-form-summary__answers {
|
||||
padding: var(--a-spacing-4);
|
||||
}
|
||||
}
|
||||
|
||||
.navds-form-summary__answer:not(:last-child) {
|
||||
margin-bottom: var(--a-spacing-4);
|
||||
padding-bottom: var(--a-spacing-4);
|
||||
border-bottom: 1px solid var(--a-border-divider);
|
||||
}
|
||||
|
||||
.navds-form-summary__answer:has(.navds-form-summary__answer) {
|
||||
padding-bottom: var(--a-spacing-6);
|
||||
}
|
||||
|
||||
.navds-form-summary__answer:has(.navds-form-summary__answer):last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.navds-form-summary__answer:not(:last-child) {
|
||||
margin-bottom: var(--a-spacing-3);
|
||||
padding-bottom: var(--a-spacing-3);
|
||||
}
|
||||
|
||||
.navds-form-summary__answer:has(.navds-form-summary__answer) {
|
||||
padding-bottom: var(--a-spacing-5);
|
||||
}
|
||||
}
|
||||
|
||||
.navds-form-summary__value:first-of-type {
|
||||
margin-top: var(--a-spacing-1);
|
||||
}
|
||||
|
||||
.navds-form-summary__value :where(.navds-form-summary__answers) {
|
||||
margin-top: var(--a-spacing-2);
|
||||
padding: var(--a-spacing-4);
|
||||
background: var(--a-surface-selected);
|
||||
border-radius: var(--a-border-radius-large);
|
||||
}
|
||||
@@ -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: "•";
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -12,3 +13,5 @@
|
||||
@import "textarea.css";
|
||||
@import "search.css";
|
||||
@import "combobox.css";
|
||||
@import "form-summary.css";
|
||||
@import "form-progress.css";
|
||||
@@ -70,8 +70,8 @@
|
||||
height: calc(1.25rem - 0.25rem);
|
||||
}
|
||||
|
||||
.navds-checkbox__input:focus-visible + .navds-checkbox__label::before,
|
||||
.navds-radio__input:focus-visible + .navds-radio__label::before {
|
||||
.navds-checkbox__input:focus + .navds-checkbox__label::before,
|
||||
.navds-radio__input:focus + .navds-radio__label::before {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
box-shadow:
|
||||
@@ -80,33 +80,13 @@
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
|
||||
@supports not selector(:focus-visible) {
|
||||
.navds-checkbox__input:focus + .navds-checkbox__label::before,
|
||||
.navds-radio__input:focus + .navds-radio__label::before {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ac-radio-checkbox-border, var(--a-border-default)),
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
}
|
||||
|
||||
.navds-checkbox__input:hover:focus-visible + .navds-checkbox__label::before,
|
||||
.navds-radio__input:hover:focus-visible + .navds-radio__label::before {
|
||||
.navds-checkbox__input:hover:focus + .navds-checkbox__label::before,
|
||||
.navds-radio__input:hover:focus + .navds-radio__label::before {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ac-radio-checkbox-action, var(--a-surface-action-hover)),
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
|
||||
@supports not selector(:focus-visible) {
|
||||
.navds-checkbox__input:hover:focus + .navds-checkbox__label::before,
|
||||
.navds-radio__input:hover:focus + .navds-radio__label::before {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ac-radio-checkbox-action, var(--a-surface-action-hover)),
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
}
|
||||
|
||||
.navds-checkbox__input:indeterminate + .navds-checkbox__label::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -163,24 +143,14 @@
|
||||
background-position: 0.25rem center;
|
||||
}
|
||||
|
||||
.navds-checkbox__input:indeterminate:focus-visible + .navds-checkbox__label::before,
|
||||
.navds-checkbox__input:checked:focus-visible + .navds-checkbox__label::before {
|
||||
.navds-checkbox__input:indeterminate:focus + .navds-checkbox__label::before,
|
||||
.navds-checkbox__input:checked:focus + .navds-checkbox__label::before {
|
||||
box-shadow:
|
||||
0 0 0 1px var(--ac-radio-checkbox-action, var(--a-surface-action-selected)),
|
||||
0 0 0 2px var(--ac-radio-checkbox-bg, var(--a-surface-default)),
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
|
||||
@supports not selector(:focus-visible) {
|
||||
.navds-checkbox__input:indeterminate:focus + .navds-checkbox__label::before,
|
||||
.navds-checkbox__input:checked:focus + .navds-checkbox__label::before {
|
||||
box-shadow:
|
||||
0 0 0 1px var(--ac-radio-checkbox-action, var(--a-surface-action-selected)),
|
||||
0 0 0 2px var(--ac-radio-checkbox-bg, var(--a-surface-default)),
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
}
|
||||
|
||||
.navds-radio__input:checked + .navds-radio__label::before {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ac-radio-checkbox-action, var(--a-surface-action-selected)),
|
||||
@@ -188,40 +158,23 @@
|
||||
background-color: var(--ac-radio-checkbox-action, var(--a-surface-action-selected));
|
||||
}
|
||||
|
||||
.navds-radio__input:checked:focus-visible + .navds-radio__label::before {
|
||||
.navds-radio__input:checked:focus + .navds-radio__label::before {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ac-radio-checkbox-action, var(--a-surface-action-selected)),
|
||||
inset 0 0 0 2px var(--ac-radio-checkbox-bg, var(--a-surface-default)),
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
|
||||
@supports not selector(:focus-visible) {
|
||||
.navds-radio__input:checked:focus + .navds-radio__label::before {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ac-radio-checkbox-action, var(--a-surface-action)),
|
||||
inset 0 0 0 2px var(--ac-radio-checkbox-bg, var(--a-surface-default)),
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
}
|
||||
|
||||
.navds-checkbox__input:hover:not(:disabled) + .navds-checkbox__label,
|
||||
.navds-radio__input:hover:not(:disabled) + .navds-radio__label {
|
||||
color: var(--ac-radio-checkbox-action, var(--a-surface-action-hover));
|
||||
}
|
||||
|
||||
.navds-checkbox__input:hover:not(:disabled):not(:checked):not(:indeterminate):not(:focus-visible)
|
||||
+ .navds-checkbox__label::before,
|
||||
.navds-radio__input:hover:not(:disabled):not(:checked):not(:focus-visible) + .navds-radio__label::before {
|
||||
.navds-checkbox__input:hover:not(:disabled):not(:checked):not(:indeterminate):not(:focus) + .navds-checkbox__label::before,
|
||||
.navds-radio__input:hover:not(:disabled):not(:checked):not(:focus) + .navds-radio__label::before {
|
||||
box-shadow: 0 0 0 2px var(--ac-radio-checkbox-action, var(--a-surface-action-hover));
|
||||
}
|
||||
|
||||
@supports not selector(:focus-visible) {
|
||||
.navds-checkbox__input:hover:not(:disabled):not(:checked):not(:indeterminate):not(:focus) + .navds-checkbox__label::before,
|
||||
.navds-radio__input:hover:not(:disabled):not(:checked):not(:focus) + .navds-radio__label::before {
|
||||
box-shadow: 0 0 0 2px var(--ac-radio-checkbox-action, var(--a-surface-action-hover));
|
||||
}
|
||||
}
|
||||
|
||||
.navds-checkbox__input:hover:not(:disabled):not(:checked):not(:indeterminate) + .navds-checkbox__label::before,
|
||||
.navds-radio__input:hover:not(:disabled):not(:checked) + .navds-radio__label::before {
|
||||
background-color: var(--ac-radio-checkbox-action-hover-bg, var(--a-surface-action-subtle));
|
||||
@@ -235,63 +188,32 @@
|
||||
}
|
||||
|
||||
.navds-checkbox--error
|
||||
> .navds-checkbox__input:focus-visible:not(:hover):not(:disabled):not(:checked):not(:indeterminate)
|
||||
> .navds-checkbox__input:focus:not(:hover):not(:disabled):not(:checked):not(:indeterminate)
|
||||
+ .navds-checkbox__label::before,
|
||||
.navds-radio--error > .navds-radio__input:focus-visible:not(:hover):not(:disabled):not(:checked) + .navds-radio__label::before {
|
||||
.navds-radio--error > .navds-radio__input:focus:not(:hover):not(:disabled):not(:checked) + .navds-radio__label::before {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ac-radio-checkbox-error-border, var(--a-border-danger)),
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
|
||||
@supports not selector(:focus-visible) {
|
||||
.navds-checkbox--error
|
||||
> .navds-checkbox__input:focus:not(:hover):not(:disabled):not(:checked):not(:indeterminate)
|
||||
+ .navds-checkbox__label::before,
|
||||
.navds-radio--error > .navds-radio__input:focus:not(:hover):not(:disabled):not(:checked) + .navds-radio__label::before {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ac-radio-checkbox-error-border, var(--a-border-danger)),
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
}
|
||||
|
||||
.navds-checkbox--error
|
||||
> .navds-checkbox__input:hover:not(:disabled):not(:checked):not(:indeterminate):not(:focus-visible)
|
||||
> .navds-checkbox__input:hover:not(:disabled):not(:checked):not(:indeterminate):not(:focus)
|
||||
+ .navds-checkbox__label::before,
|
||||
.navds-radio--error > .navds-radio__input:hover:not(:disabled):not(:checked):not(:focus-visible) + .navds-radio__label::before {
|
||||
.navds-radio--error > .navds-radio__input:hover:not(:disabled):not(:checked):not(:focus) + .navds-radio__label::before {
|
||||
background-color: var(--ac-radio-checkbox-error-hover-bg, var(--a-surface-danger-subtle));
|
||||
box-shadow: 0 0 0 2px var(--ac-radio-checkbox-error-border, var(--a-border-danger));
|
||||
}
|
||||
|
||||
.navds-checkbox--error
|
||||
> .navds-checkbox__input:focus-visible:hover:not(:disabled):not(:checked):not(:indeterminate)
|
||||
> .navds-checkbox__input:focus:hover:not(:disabled):not(:checked):not(:indeterminate)
|
||||
+ .navds-checkbox__label::before,
|
||||
.navds-radio--error > .navds-radio__input:focus-visible:hover:not(:disabled):not(:checked) + .navds-radio__label::before {
|
||||
.navds-radio--error > .navds-radio__input:focus:hover:not(:disabled):not(:checked) + .navds-radio__label::before {
|
||||
background-color: var(--ac-radio-checkbox-error-hover-bg, var(--a-surface-danger-subtle));
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ac-radio-checkbox-error-border, var(--a-border-danger)),
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
|
||||
@supports not selector(:focus-visible) {
|
||||
.navds-checkbox--error
|
||||
> .navds-checkbox__input:hover:not(:disabled):not(:checked):not(:indeterminate):not(:focus)
|
||||
+ .navds-checkbox__label::before,
|
||||
.navds-radio--error > .navds-radio__input:hover:not(:disabled):not(:checked):not(:focus) + .navds-radio__label::before {
|
||||
background-color: var(--ac-radio-checkbox-error-hover-bg, var(--a-surface-danger-subtle));
|
||||
box-shadow: 0 0 0 2px var(--ac-radio-checkbox-error-border, var(--a-border-danger));
|
||||
}
|
||||
|
||||
.navds-checkbox--error
|
||||
> .navds-checkbox__input:focus:hover:not(:disabled):not(:checked):not(:indeterminate)
|
||||
+ .navds-checkbox__label::before,
|
||||
.navds-radio--error > .navds-radio__input:focus:hover:not(:disabled):not(:checked) + .navds-radio__label::before {
|
||||
background-color: var(--ac-radio-checkbox-error-hover-bg, var(--a-surface-danger-subtle));
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ac-radio-checkbox-error-border, var(--a-border-danger)),
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
}
|
||||
|
||||
.navds-checkbox--disabled,
|
||||
.navds-radio--disabled {
|
||||
opacity: 0.3;
|
||||
@@ -323,16 +245,16 @@
|
||||
> .navds-checkbox__input:not(:disabled):not(:checked):not(:indeterminate)
|
||||
+ .navds-checkbox__label::before,
|
||||
.navds-checkbox--readonly
|
||||
> .navds-checkbox__input:hover:not(:checked):not(:indeterminate):not(:focus-visible)
|
||||
> .navds-checkbox__input:hover:not(:checked):not(:indeterminate):not(:focus)
|
||||
+ .navds-checkbox__label::before,
|
||||
.navds-radio--readonly > .navds-radio__input:not(:disabled):not(:checked) + .navds-radio__label::before,
|
||||
.navds-radio--readonly > .navds-radio__input:hover:not(:checked):not(:focus-visible) + .navds-radio__label::before {
|
||||
.navds-radio--readonly > .navds-radio__input:hover:not(:checked):not(:focus) + .navds-radio__label::before {
|
||||
background-color: var(--__ac-radio-checkbox-readonly-bg);
|
||||
box-shadow: 0 0 0 2px var(--__ac-radio-checkbox-readonly-border);
|
||||
}
|
||||
|
||||
.navds-checkbox--readonly > .navds-checkbox__input:focus-visible + .navds-checkbox__label::before,
|
||||
.navds-radio--readonly > .navds-radio__input:focus-visible + .navds-radio__label::before {
|
||||
.navds-checkbox--readonly > .navds-checkbox__input:focus + .navds-checkbox__label::before,
|
||||
.navds-radio--readonly > .navds-radio__input:focus + .navds-radio__label::before {
|
||||
--__ac-radio-checkbox-readonly-border: var(--a-border-subtle), var(--a-shadow-focus);
|
||||
}
|
||||
|
||||
@@ -362,8 +284,8 @@
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.navds-checkbox__input:focus-visible + .navds-checkbox__label::before,
|
||||
.navds-radio__input:focus-visible + .navds-radio__label::before {
|
||||
.navds-checkbox__input:focus + .navds-checkbox__label::before,
|
||||
.navds-radio__input:focus + .navds-radio__label::before {
|
||||
outline-color: highlight;
|
||||
}
|
||||
|
||||
@@ -395,7 +317,7 @@
|
||||
background-color: var(--__ac-radio-checkbox-high-contrast-text);
|
||||
}
|
||||
|
||||
.navds-radio__input:checked:focus-visible + .navds-radio__label::before {
|
||||
.navds-radio__input:checked:focus + .navds-radio__label::before {
|
||||
border: 1px solid var(--__ac-radio-checkbox-high-contrast-bg);
|
||||
outline: 2px solid highlight;
|
||||
outline-offset: 2px;
|
||||
@@ -406,7 +328,7 @@
|
||||
color: highlight;
|
||||
}
|
||||
|
||||
.navds-checkbox__input:focus-visible + .navds-checkbox__label::before {
|
||||
.navds-checkbox__input:focus + .navds-checkbox__label::before {
|
||||
outline: 2px solid var(--__ac-radio-checkbox-high-contrast-highlight);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
@import "panel.css";
|
||||
@import "link-panel.css";
|
||||
@import "read-more.css";
|
||||
@import "progress-bar.css";
|
||||
@import "skeleton.css";
|
||||
@import "stepper.css";
|
||||
@import "table.css";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-css",
|
||||
"version": "6.2.0",
|
||||
"version": "6.8.0",
|
||||
"description": "CSS for NAV Designsystem",
|
||||
"author": "Aksel | NAV designsystem team",
|
||||
"keywords": [
|
||||
@@ -27,7 +27,7 @@
|
||||
"css:get-version": "node config/get-version.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@navikt/ds-tokens": "^6.2.0",
|
||||
"@navikt/ds-tokens": "^6.8.0",
|
||||
"cssnano": "6.0.0",
|
||||
"fast-glob": "3.2.11",
|
||||
"lodash": "4.17.21",
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
.navds-hgrid {
|
||||
--__ac-hgrid-columns-xs: initial;
|
||||
--__ac-hgrid-columns-sm: initial;
|
||||
--__ac-hgrid-columns-md: initial;
|
||||
--__ac-hgrid-columns-lg: initial;
|
||||
--__ac-hgrid-columns-xl: initial;
|
||||
--__ac-hgrid-columns-2xl: initial;
|
||||
--__ac-hgrid-columns-sm: var(--__ac-hgrid-columns-xs);
|
||||
--__ac-hgrid-columns-md: var(--__ac-hgrid-columns-sm);
|
||||
--__ac-hgrid-columns-lg: var(--__ac-hgrid-columns-md);
|
||||
--__ac-hgrid-columns-xl: var(--__ac-hgrid-columns-lg);
|
||||
--__ac-hgrid-columns-2xl: var(--__ac-hgrid-columns-xl);
|
||||
--__ac-hgrid-columns: var(--__ac-hgrid-columns-xs);
|
||||
--__ac-hgrid-gap-xs: initial;
|
||||
--__ac-hgrid-gap-sm: initial;
|
||||
--__ac-hgrid-gap-md: initial;
|
||||
--__ac-hgrid-gap-lg: initial;
|
||||
--__ac-hgrid-gap-xl: initial;
|
||||
--__ac-hgrid-gap-2xl: initial;
|
||||
--__ac-hgrid-gap-sm: var(--__ac-hgrid-gap-xs);
|
||||
--__ac-hgrid-gap-md: var(--__ac-hgrid-gap-sm);
|
||||
--__ac-hgrid-gap-lg: var(--__ac-hgrid-gap-md);
|
||||
--__ac-hgrid-gap-xl: var(--__ac-hgrid-gap-lg);
|
||||
--__ac-hgrid-gap-2xl: var(--__ac-hgrid-gap-xl);
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-xs);
|
||||
--__ac-hgrid-align: initial;
|
||||
|
||||
@@ -23,56 +23,35 @@
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.navds-hgrid {
|
||||
--__ac-hgrid-columns: var(--__ac-hgrid-columns-sm, var(--__ac-hgrid-columns-xs));
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-sm, var(--__ac-hgrid-gap-xs));
|
||||
--__ac-hgrid-columns: var(--__ac-hgrid-columns-sm);
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.navds-hgrid {
|
||||
--__ac-hgrid-columns: var(--__ac-hgrid-columns-md, var(--__ac-hgrid-columns-sm, var(--__ac-hgrid-columns-xs)));
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-md, var(--__ac-hgrid-gap-sm, var(--__ac-hgrid-gap-xs)));
|
||||
--__ac-hgrid-columns: var(--__ac-hgrid-columns-md);
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-md);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.navds-hgrid {
|
||||
--__ac-hgrid-columns: var(
|
||||
--__ac-hgrid-columns-lg,
|
||||
var(--__ac-hgrid-columns-md, var(--__ac-hgrid-columns-sm, var(--__ac-hgrid-columns-xs)))
|
||||
);
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-lg, var(--__ac-hgrid-gap-md, var(--__ac-hgrid-gap-sm, var(--__ac-hgrid-gap-xs))));
|
||||
--__ac-hgrid-columns: var(--__ac-hgrid-columns-lg);
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-lg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.navds-hgrid {
|
||||
--__ac-hgrid-columns: var(
|
||||
--__ac-hgrid-columns-xl,
|
||||
var(--__ac-hgrid-columns-lg, var(--__ac-hgrid-columns-md, var(--__ac-hgrid-columns-sm, var(--__ac-hgrid-columns-xs))))
|
||||
);
|
||||
--__ac-hgrid-gap: var(
|
||||
--__ac-hgrid-gap-xl,
|
||||
var(--__ac-hgrid-gap-lg, var(--__ac-hgrid-gap-md, var(--__ac-hgrid-gap-sm, var(--__ac-hgrid-gap-xs))))
|
||||
);
|
||||
--__ac-hgrid-columns: var(--__ac-hgrid-columns-xl);
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-xl);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
.navds-hgrid {
|
||||
--__ac-hgrid-columns: var(
|
||||
--__ac-hgrid-columns-2xl,
|
||||
var(
|
||||
--__ac-hgrid-columns-xl,
|
||||
var(--__ac-hgrid-columns-lg, var(--__ac-hgrid-columns-md, var(--__ac-hgrid-columns-sm, var(--__ac-hgrid-columns-xs))))
|
||||
)
|
||||
);
|
||||
--__ac-hgrid-gap: var(
|
||||
--__ac-hgrid-gap-2xl,
|
||||
var(
|
||||
--__ac-hgrid-gap-xl,
|
||||
var(--__ac-hgrid-gap-lg, var(--__ac-hgrid-gap-md, var(--__ac-hgrid-gap-sm, var(--__ac-hgrid-gap-xs))))
|
||||
)
|
||||
);
|
||||
--__ac-hgrid-columns: var(--__ac-hgrid-columns-2xl);
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-2xl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
.navds-progress-bar {
|
||||
background: var(--a-surface-neutral-subtle);
|
||||
position: relative;
|
||||
border-radius: var(--a-border-radius-full);
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
inset 0 1px 3px 0 rgba(0 0 0 / 0.15),
|
||||
inset 0 0 1px 0 rgba(0 0 0 / 0.2);
|
||||
}
|
||||
|
||||
.navds-progress-bar--small {
|
||||
height: 12px;
|
||||
min-width: 12px;
|
||||
}
|
||||
|
||||
.navds-progress-bar--medium {
|
||||
height: 16px;
|
||||
min-width: 16px;
|
||||
}
|
||||
|
||||
.navds-progress-bar--large {
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.navds-progress-bar__foreground {
|
||||
--__ac-progress-bar-translate: initial;
|
||||
|
||||
transform: translateX(var(--__ac-progress-bar-translate));
|
||||
background: var(--ac-progress-bar-fg, var(--a-surface-alt-3));
|
||||
transform-origin: left;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
border-radius: inherit;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.navds-progress-bar__foreground--indeterminate {
|
||||
--__ac-progress-bar-simulated: initial;
|
||||
|
||||
animation-name: navds-progress-bar-indeterminate-grow, navds-progress-bar-indeterminate;
|
||||
animation-timing-function: ease-in-out, ease-in-out;
|
||||
animation-duration: var(--__ac-progress-bar-simulated), 2500ms;
|
||||
animation-fill-mode: forwards, none;
|
||||
animation-iteration-count: 1, infinite;
|
||||
animation-delay: 0s, var(--__ac-progress-bar-simulated);
|
||||
}
|
||||
|
||||
/* navds-progress-bar-indeterminate wave animation */
|
||||
@keyframes navds-progress-bar-indeterminate {
|
||||
0% {
|
||||
left: -50%;
|
||||
width: 50%;
|
||||
transform: translateX(0%);
|
||||
}
|
||||
|
||||
50% {
|
||||
animation-direction: reverse;
|
||||
left: 100%;
|
||||
transform: translateX(0%);
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
100% {
|
||||
left: -50%;
|
||||
transform: translateX(0%);
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes navds-progress-bar-indeterminate-grow {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translateX(-80%);
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translateX(-40%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(-20%);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translateX(-10%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(-10%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.navds-progress-bar__foreground {
|
||||
background: Highlight;
|
||||
}
|
||||
|
||||
.navds-progress-bar {
|
||||
outline: 1px solid transparent;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -79,7 +79,8 @@
|
||||
}
|
||||
|
||||
.navds-read-more__button:hover > .navds-read-more__expand-icon {
|
||||
transform: translateY(1px);
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
.navds-read-more--open > .navds-read-more__button > .navds-read-more__expand-icon {
|
||||
@@ -87,5 +88,5 @@
|
||||
}
|
||||
|
||||
.navds-read-more--open > .navds-read-more__button:hover > .navds-read-more__expand-icon {
|
||||
transform: translateY(-1px) rotate(-180deg);
|
||||
top: -1px;
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
.navds-tabs__tablist {
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
width: 100%;
|
||||
scroll-behavior: smooth;
|
||||
-ms-overflow-style: none; /* for Internet Explorer, Edge */
|
||||
scrollbar-width: none; /* for Firefox */
|
||||
@@ -83,6 +83,10 @@
|
||||
gap: var(--a-spacing-1);
|
||||
}
|
||||
|
||||
.navds-tabs__tab-inner > * {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.navds-tabs__tab-inner svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -117,6 +121,10 @@
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.navds-tabs__tab--fill {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.navds-tabs__tabpanel:focus-visible {
|
||||
outline: 2px solid transparent;
|
||||
box-shadow: inset 0 0 0 2px var(--a-border-focus);
|
||||
|
||||
@@ -245,7 +245,7 @@
|
||||
.navds-timeline__pin-wrapper::before {
|
||||
content: "";
|
||||
top: var(--navdsc-timeline-pin-size);
|
||||
height: calc(88% - var(--navdsc-timeline-pin-size));
|
||||
height: calc(100% - var(--navdsc-timeline-pin-size) * 2);
|
||||
width: 1px;
|
||||
margin: 0 auto;
|
||||
background: var(--a-surface-inverted);
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
gap: var(--a-spacing-2);
|
||||
}
|
||||
|
||||
.navds-toggle-group__wrapper--fill {
|
||||
justify-items: initial;
|
||||
}
|
||||
|
||||
.navds-toggle-group {
|
||||
border-radius: var(--a-border-radius-medium);
|
||||
background-color: var(--ac-toggle-group-bg, var(--a-surface-transparent));
|
||||
|
||||
@@ -217,6 +217,9 @@
|
||||
"--ac-popover-bg": "--a-surface-default",
|
||||
"--ac-popover-border": "--a-border-default"
|
||||
},
|
||||
"progress-bar": {
|
||||
"--ac-progress-bar-fg": "--a-surface-alt-3"
|
||||
},
|
||||
"readmore": {
|
||||
"--ac-read-more-text": "--a-text-action",
|
||||
"--ac-read-more-hover-bg": "--a-surface-hover",
|
||||
|
||||
@@ -1,5 +1,191 @@
|
||||
# @navikt/ds-react
|
||||
|
||||
## 6.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- :sparkles: Ny komponent: FormProgress ([#2855](https://github.com/navikt/aksel/pull/2855))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stepper.Step: Set aria-current to "step" instead of true ([#2920](https://github.com/navikt/aksel/pull/2920))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.8.0
|
||||
- @navikt/aksel-icons@6.8.0
|
||||
|
||||
## 6.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Progressbar: Tweak API, examples, stories and css ([#2892](https://github.com/navikt/aksel/pull/2892))
|
||||
|
||||
- Combobox: Remove 'Ingen søketreff' when custom options allowed ([#2895](https://github.com/navikt/aksel/pull/2895))
|
||||
|
||||
- Datepicker, MonthPicker: Rekkefølgen på årstall i Select er reversert slik at siste år er øverst. Dette er endret for å være bedre tilpasset ekspertsystemer der de mest relevante årene ble vist lengst unna musepeker ved klikk. ([#2882](https://github.com/navikt/aksel/pull/2882))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.7.1
|
||||
- @navikt/aksel-icons@6.7.1
|
||||
|
||||
## 6.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- :sparkles: Ny komponent: ProgressBar ([#2845](https://github.com/navikt/aksel/pull/2845))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.7.0
|
||||
- @navikt/aksel-icons@6.7.0
|
||||
|
||||
## 6.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Spacer: Er nå `span` (tidligere `div`). ([#2874](https://github.com/navikt/aksel/pull/2874))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.6.1
|
||||
- @navikt/aksel-icons@6.6.1
|
||||
|
||||
## 6.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- :sparkles: Ny komponent FormSummary ([#2802](https://github.com/navikt/aksel/pull/2802))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Combobox: Prevents "Enter" while Combobox is focused from submitting form. ([#2861](https://github.com/navikt/aksel/pull/2861))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.6.0
|
||||
- @navikt/aksel-icons@6.6.0
|
||||
|
||||
## 6.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Tabs: Ny prop `lazy` som rendrer innhold i TabPanel selv når panel er skjult (har fortsatt display:none) ([#2621](https://github.com/navikt/aksel/pull/2621))
|
||||
|
||||
- ToggleGroup: Erstattet bruk av dependency `@radix-ui/react-toggle-group` med egen implementasjon. ([#2620](https://github.com/navikt/aksel/pull/2620))
|
||||
|
||||
- Tabs: La til ny prop `fill` som lar Tabs.Tab-elementer strekke seg over tilgjengelig bredde. ([#2621](https://github.com/navikt/aksel/pull/2621))
|
||||
|
||||
- Tabs: Erstattet bruk av dependency `@radix-ui/react-tabs` med egen implementasjon. ([#2621](https://github.com/navikt/aksel/pull/2621))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- DatePicker/MonthPicker: Valgte datoer får nå `aria-pressed` for å bedre indikere valg for skjermleser. ([#2838](https://github.com/navikt/aksel/pull/2838))
|
||||
|
||||
- DatePicker/MonthPicker: `required`-prop stoppet ikke de-select av allerede valgt dato. ([#2838](https://github.com/navikt/aksel/pull/2838))
|
||||
|
||||
- ToggleGroup: La til ny prop `fill` som strekker ToggleGroup til å ta opp all tilgjengelig bredde. ([#2620](https://github.com/navikt/aksel/pull/2620))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.5.0
|
||||
- @navikt/aksel-icons@6.5.0
|
||||
|
||||
## 6.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.4.1
|
||||
- @navikt/aksel-icons@6.4.1
|
||||
|
||||
## 6.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- FileUpload.Item: :boom: Endret API og støtte for å sette ID på knappen ([#2824](https://github.com/navikt/aksel/pull/2824))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.4.0
|
||||
- @navikt/aksel-icons@6.4.0
|
||||
|
||||
## 6.3.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- DatePicker: Fjernet role gridcell fra dato-knapper. ([#2826](https://github.com/navikt/aksel/pull/2826))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.3.6
|
||||
- @navikt/aksel-icons@6.3.6
|
||||
|
||||
## 6.3.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Modal: Fjern tomt element ([#2819](https://github.com/navikt/aksel/pull/2819))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.3.5
|
||||
- @navikt/aksel-icons@6.3.5
|
||||
|
||||
## 6.3.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-react",
|
||||
"version": "6.2.0",
|
||||
"version": "6.8.0",
|
||||
"description": "React components from the Norwegian Labour and Welfare Administration.",
|
||||
"author": "Aksel, a team part of the Norwegian Labour and Welfare Administration.",
|
||||
"license": "MIT",
|
||||
@@ -24,7 +24,8 @@
|
||||
"/cjs",
|
||||
"/esm",
|
||||
"/src",
|
||||
"!**/*.tsbuildinfo"
|
||||
"!**/*.tsbuildinfo",
|
||||
"!**/*.stories.*"
|
||||
],
|
||||
"main": "./cjs/index.js",
|
||||
"module": "./esm/index.js",
|
||||
@@ -309,6 +310,16 @@
|
||||
"default": "./cjs/popover/index.js"
|
||||
}
|
||||
},
|
||||
"./ProgressBar": {
|
||||
"import": {
|
||||
"types": "./esm/progress-bar/index.d.ts",
|
||||
"default": "./esm/progress-bar/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./cjs/progress-bar/index.d.ts",
|
||||
"default": "./cjs/progress-bar/index.js"
|
||||
}
|
||||
},
|
||||
"./Provider": {
|
||||
"import": {
|
||||
"types": "./esm/provider/index.d.ts",
|
||||
@@ -539,6 +550,36 @@
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./FormSummary": {
|
||||
"import": {
|
||||
"types": "./esm/form/form-summary/index.d.ts",
|
||||
"default": "./esm/form/form-summary/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./cjs/form/form-summary/index.d.ts",
|
||||
"default": "./cjs/form/form-summary/index.js"
|
||||
}
|
||||
},
|
||||
"./FormProgress": {
|
||||
"import": {
|
||||
"types": "./esm/form/form-progress/index.d.ts",
|
||||
"default": "./esm/form/form-progress/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./cjs/form/form-progress/index.d.ts",
|
||||
"default": "./cjs/form/form-progress/index.js"
|
||||
}
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -552,18 +593,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "0.25.4",
|
||||
"@navikt/aksel-icons": "^6.2.0",
|
||||
"@navikt/ds-tokens": "^6.2.0",
|
||||
"@radix-ui/react-tabs": "1.0.0",
|
||||
"@radix-ui/react-toggle-group": "1.0.0",
|
||||
"@floating-ui/react-dom": "^2.0.9",
|
||||
"@navikt/aksel-icons": "^6.8.0",
|
||||
"@navikt/ds-tokens": "^6.8.0",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.0.0",
|
||||
"react-day-picker": "8.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "8.13.0",
|
||||
"@testing-library/dom": "9.3.4",
|
||||
"@testing-library/jest-dom": "^5.16.0",
|
||||
"@testing-library/react": "^13.3.0",
|
||||
"@testing-library/react": "^15.0.7",
|
||||
"@testing-library/user-event": "^14.2.0",
|
||||
"@types/faker": "5.5.8",
|
||||
"concurrently": "7.2.1",
|
||||
|
||||
@@ -183,3 +183,10 @@ export const Chromatic: Story = {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
};
|
||||
|
||||
export const ChromaticForcedColors: Story = {
|
||||
render: Chromatic.render,
|
||||
parameters: {
|
||||
chromatic: { disable: false, forcedColors: "active" },
|
||||
},
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import React, { HTMLAttributes, forwardRef } from "react";
|
||||
import { BodyLong } from "../typography";
|
||||
import Bubble from "./Bubble";
|
||||
|
||||
export const VARIANTS = ["subtle", "info", "neutral"] as const;
|
||||
export const POSITIONS = ["left", "right"] as const;
|
||||
export const SIZES = ["medium", "small"] as const;
|
||||
|
||||
@@ -28,7 +29,7 @@ export interface ChatProps extends HTMLAttributes<HTMLDivElement> {
|
||||
* Avoid using the same background as the surface behind Chat.
|
||||
* @default "neutral"
|
||||
*/
|
||||
variant?: "subtle" | "info" | "neutral";
|
||||
variant?: (typeof VARIANTS)[number];
|
||||
/**
|
||||
* Positions avatar and bubbles.
|
||||
* @default "left"
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { VStack } from "../layout/stack";
|
||||
import Chat, { POSITIONS, SIZES } from "./Chat";
|
||||
import Chat, { POSITIONS, SIZES, VARIANTS } from "./Chat";
|
||||
|
||||
export default {
|
||||
title: "ds-react/Chat",
|
||||
component: Chat,
|
||||
argTypes: {
|
||||
position: {
|
||||
control: { type: "radio" },
|
||||
options: POSITIONS,
|
||||
},
|
||||
toptextPosition: {
|
||||
control: { type: "radio" },
|
||||
options: POSITIONS,
|
||||
},
|
||||
size: {
|
||||
control: { type: "radio" },
|
||||
options: SIZES,
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
@@ -48,43 +34,49 @@ export const Controls: Story = {
|
||||
timestamp: "01.01.21 14:00",
|
||||
avatar: "ON",
|
||||
},
|
||||
argTypes: {
|
||||
position: {
|
||||
control: { type: "radio" },
|
||||
options: [...POSITIONS], // TypeScript does not like using readonly typed arrays here
|
||||
},
|
||||
toptextPosition: {
|
||||
control: { type: "radio" },
|
||||
options: [...POSITIONS],
|
||||
},
|
||||
size: {
|
||||
control: { type: "radio" },
|
||||
options: [...SIZES],
|
||||
},
|
||||
variant: {
|
||||
control: { type: "radio" },
|
||||
options: [...VARIANTS],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Size: Story = {
|
||||
render: () => (
|
||||
<VStack gap="4">
|
||||
<h3>Medium</h3>
|
||||
<Chat
|
||||
avatar="ON"
|
||||
name="Ola Normann"
|
||||
timestamp="01.01.21 14:00"
|
||||
size="medium"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Aute minim nisi sunt mollit duis sunt nulla minim non proident.
|
||||
</Chat.Bubble>
|
||||
<Chat.Bubble>Tempor fugiat amet eu sint in in ullamco.</Chat.Bubble>
|
||||
<Chat.Bubble>
|
||||
Adipisicing laborum est eu laborum est sit in commodo enim sint
|
||||
laboris labore nisi ut.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
<h3>Small</h3>
|
||||
<Chat
|
||||
avatar="ON"
|
||||
name="Ola Normann"
|
||||
timestamp="01.01.21 14:00"
|
||||
size="small"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Aute minim nisi sunt mollit duis sunt nulla minim non proident.
|
||||
</Chat.Bubble>
|
||||
<Chat.Bubble>Tempor fugiat amet eu sint in in ullamco.</Chat.Bubble>
|
||||
<Chat.Bubble>
|
||||
Adipisicing laborum est eu laborum est sit in commodo enim sint
|
||||
laboris labore nisi ut.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
{SIZES.map((size) => (
|
||||
<>
|
||||
<h3>{size}</h3>
|
||||
<Chat
|
||||
avatar="ON"
|
||||
name="Ola Normann"
|
||||
timestamp="01.01.21 14:00"
|
||||
size={size}
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Aute minim nisi sunt mollit duis sunt nulla minim non proident.
|
||||
</Chat.Bubble>
|
||||
<Chat.Bubble>Tempor fugiat amet eu sint in in ullamco.</Chat.Bubble>
|
||||
<Chat.Bubble>
|
||||
Adipisicing laborum est eu laborum est sit in commodo enim sint
|
||||
laboris labore nisi ut.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
</>
|
||||
))}
|
||||
</VStack>
|
||||
),
|
||||
};
|
||||
@@ -92,35 +84,21 @@ export const Size: Story = {
|
||||
export const Variants: Story = {
|
||||
render: () => (
|
||||
<VStack gap="4">
|
||||
<h3>Info</h3>
|
||||
<Chat avatar="NAV" name="NAV" timestamp="01.01.21 14:00" variant="info">
|
||||
<Chat.Bubble>
|
||||
Aute minim nisi sunt mollit duis sunt nulla minim non proident.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
<h3>Neutral</h3>
|
||||
<Chat
|
||||
avatar="KN"
|
||||
name="Kari Nordmann"
|
||||
timestamp="01.01.21 14:03"
|
||||
variant="neutral"
|
||||
position="right"
|
||||
>
|
||||
<Chat.Bubble>Tempor fugiat amet eu sint in in ullamco.</Chat.Bubble>
|
||||
</Chat>
|
||||
<h3>Subtle</h3>
|
||||
<Chat
|
||||
avatar="ON"
|
||||
name="Ola Nordmann"
|
||||
timestamp="01.01.21 14:07"
|
||||
variant="subtle"
|
||||
position="right"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Adipisicing laborum est eu laborum est sit in commodo enim sint
|
||||
laboris labore nisi ut.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
{VARIANTS.map((variant) => (
|
||||
<>
|
||||
<h3>{variant}</h3>
|
||||
<Chat
|
||||
avatar="NAV"
|
||||
name="NAV"
|
||||
timestamp="01.01.21 14:00"
|
||||
variant={variant}
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Aute minim nisi sunt mollit duis sunt nulla minim non proident.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
</>
|
||||
))}
|
||||
</VStack>
|
||||
),
|
||||
};
|
||||
@@ -131,8 +109,7 @@ export const Position: Story = {
|
||||
<h3>Default</h3>
|
||||
<Chat avatar="ON" name="Ola Normann" timestamp="01.01.21 14:00">
|
||||
<Chat.Bubble>
|
||||
Adipisicing laborum est eu laborum est sit in commodo enim sint
|
||||
laboris labore nisi ut.
|
||||
Reprehenderit pariatur officia exercitation laboris.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
<h3>Right</h3>
|
||||
@@ -159,10 +136,7 @@ export const Position: Story = {
|
||||
timestamp="01.01.21 15:00"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Exercitation irure Lorem cupidatat culpa anim cillum esse ullamco qui
|
||||
dolore laborum et Lorem. Labore sunt duis id Lorem voluptate commodo
|
||||
ea esse. Dolore esse aliqua proident ea ad commodo ut dolore voluptate
|
||||
labore sunt aute.
|
||||
Reprehenderit pariatur officia exercitation laboris.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
</VStack>
|
||||
@@ -171,8 +145,8 @@ export const Position: Story = {
|
||||
|
||||
export const ToptextPosition: Story = {
|
||||
render: () => (
|
||||
<VStack gap="4">
|
||||
<h3>Right</h3>
|
||||
<VStack gap="4" style={{ width: 500 }}>
|
||||
<h3>All right</h3>
|
||||
<Chat
|
||||
avatar="ON"
|
||||
name="Ola Normann"
|
||||
@@ -180,20 +154,31 @@ export const ToptextPosition: Story = {
|
||||
toptextPosition="right"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Adipisicing laborum est eu laborum est sit in commodo enim sint
|
||||
laboris labore nisi ut.
|
||||
Reprehenderit pariatur officia exercitation laboris.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
<h3>Left</h3>
|
||||
<Chat avatar="ON" name="Ola Normann" timestamp="01.01.21 15:00">
|
||||
<Chat.Bubble toptextPosition="left">
|
||||
Exercitation irure Lorem cupidatat culpa anim cillum esse ullamco qui
|
||||
dolore laborum et Lorem. Labore sunt duis id Lorem voluptate commodo
|
||||
ea esse. Dolore esse aliqua proident ea ad commodo ut dolore voluptate
|
||||
labore sunt aute.
|
||||
<h3>Single bubble right</h3>
|
||||
<Chat avatar="ON" name="Ola Normann" timestamp="01.01.21 14:00">
|
||||
<Chat.Bubble>
|
||||
Reprehenderit pariatur officia exercitation laboris.
|
||||
</Chat.Bubble>
|
||||
<Chat.Bubble timestamp="14:02" toptextPosition="right">
|
||||
Enim velit deserunt do quis.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
<h3>Left</h3>
|
||||
|
||||
<h3>All left</h3>
|
||||
<Chat
|
||||
avatar="ON"
|
||||
name="Ola Normann"
|
||||
timestamp="01.01.21 15:00"
|
||||
toptextPosition="left"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Reprehenderit pariatur officia exercitation laboris.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
<h3>All left, position right</h3>
|
||||
<Chat
|
||||
name="Kari Høyli"
|
||||
timestamp="01.01.21 14:32"
|
||||
@@ -203,25 +188,6 @@ export const ToptextPosition: Story = {
|
||||
<Chat.Bubble>
|
||||
Reprehenderit pariatur officia exercitation laboris.
|
||||
</Chat.Bubble>
|
||||
<Chat.Bubble>
|
||||
Enim velit deserunt do quis. Eu fugiat magna esse dolore ad sunt sit
|
||||
est dolore incididunt. Occaecat cupidatat magna.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
<h3>Right</h3>
|
||||
<Chat
|
||||
name="Kari Høyli"
|
||||
timestamp="01.01.21 14:32"
|
||||
position="right"
|
||||
toptextPosition="right"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Reprehenderit pariatur officia exercitation laboris.
|
||||
</Chat.Bubble>
|
||||
<Chat.Bubble>
|
||||
Enim velit deserunt do quis. Eu fugiat magna esse dolore ad sunt sit
|
||||
est dolore incididunt. Occaecat cupidatat magna.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
</VStack>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createContext } from "../util/create-context";
|
||||
import { CollapsibleBaseProps } from "./Collapsible.types";
|
||||
|
||||
export type CollapsibleContextProps = Pick<CollapsibleBaseProps, "open"> & {
|
||||
/**
|
||||
* Allows connecting trigger to content
|
||||
*/
|
||||
contentId: string;
|
||||
/**
|
||||
* Allows connecting content to trigger
|
||||
*/
|
||||
triggerId: string;
|
||||
/**
|
||||
* Callback for toggling open state
|
||||
*/
|
||||
onOpenToggle: () => void;
|
||||
/**
|
||||
* Allows always rendering children in content when closed
|
||||
*/
|
||||
lazy: boolean;
|
||||
/**
|
||||
* Utility for getting state as string. Usefull for data-attributes.
|
||||
*/
|
||||
state: "open" | "closed";
|
||||
};
|
||||
|
||||
export const [CollapsibleContextProvider, useCollapsibleContext] =
|
||||
createContext<CollapsibleContextProps>({
|
||||
name: "CollapsibleContext",
|
||||
errorMessage:
|
||||
"<Collapsible.Trigger> and <Collapsible.Content> must be used within a <Collapsible>",
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { Meta, StoryFn, StoryObj } from "@storybook/react";
|
||||
import React, { useState } from "react";
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "@navikt/aksel-icons";
|
||||
import { Button } from "../button";
|
||||
import { Box } from "../layout/box";
|
||||
import { HStack, VStack } from "../layout/stack";
|
||||
import { Collapsible } from "./Collapsible";
|
||||
|
||||
const meta: Meta<typeof Collapsible> = {
|
||||
title: "Utilities/Collapsible",
|
||||
component: Collapsible,
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Collapsible>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (...props) => (
|
||||
<Collapsible {...props}>
|
||||
<Collapsible.Trigger>Trigger</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Recusandae
|
||||
corporis maxime aliquam, voluptates nobis numquam, non odit optio
|
||||
architecto iure laborum possimus! Quibusdam sit ullam, consequatur sunt
|
||||
tempore optio aliquid!
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
),
|
||||
args: {
|
||||
open: false,
|
||||
defaultOpen: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const InContext: StoryFn<typeof Collapsible> = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<HStack justify="space-between" gap="4" align="center">
|
||||
Steg 1 av 8{" "}
|
||||
<Collapsible.Trigger asChild>
|
||||
<Button
|
||||
size="small"
|
||||
variant="tertiary"
|
||||
iconPosition="right"
|
||||
icon={
|
||||
open ? (
|
||||
<ChevronUpIcon aria-hidden />
|
||||
) : (
|
||||
<ChevronDownIcon aria-hidden />
|
||||
)
|
||||
}
|
||||
>
|
||||
Vis steg
|
||||
</Button>
|
||||
</Collapsible.Trigger>
|
||||
</HStack>
|
||||
<Collapsible.Content>
|
||||
<p>Lorem ipsum dolor sit</p>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export const Animated: StoryFn<typeof Collapsible> = () => (
|
||||
<Collapsible lazy>
|
||||
<Collapsible.Trigger asChild>
|
||||
<Button size="small" variant="secondary">
|
||||
Animer open/lukk
|
||||
</Button>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content className="collapsible">
|
||||
<div className="collapsibleContent">
|
||||
<Box padding="8" background="surface-alt-3-subtle">
|
||||
<div>lorem ipsum</div>
|
||||
</Box>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
);
|
||||
|
||||
Animated.decorators = [
|
||||
(Story) => (
|
||||
<div style={{ height: 300 }}>
|
||||
<style>{`
|
||||
.collapsible {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
overflow: hidden;
|
||||
transition: grid-template-rows 1s ease-in-out;
|
||||
}
|
||||
|
||||
.collapsible[data-state="closed"] {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.collapsible[data-state="open"] {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.collapsibleContent {
|
||||
min-height: 0;
|
||||
transition: visibility 1s;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.collapsible[data-state="open"] .collapsibleContent {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
`}</style>
|
||||
{Story()}
|
||||
</div>
|
||||
),
|
||||
];
|
||||
|
||||
export const Lazy: StoryFn<typeof Collapsible> = () => (
|
||||
<Collapsible lazy>
|
||||
<Collapsible.Trigger>Trigger</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Recusandae
|
||||
corporis maxime aliquam, voluptates nobis numquam, non odit optio
|
||||
architecto iure laborum possimus! Quibusdam sit ullam, consequatur sunt
|
||||
tempore optio aliquid!
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
);
|
||||
|
||||
export const AsChild: StoryFn<typeof Collapsible> = () => (
|
||||
<Collapsible>
|
||||
<Collapsible.Trigger asChild>
|
||||
<Button>Button</Button>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content asChild>
|
||||
<Box padding="4" background="surface-alt-3-subtle">
|
||||
<div>lorem ipsum</div>
|
||||
</Box>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
);
|
||||
|
||||
export const DefaultOpen: StoryFn<typeof Collapsible> = () => (
|
||||
<Collapsible defaultOpen>
|
||||
<Collapsible.Trigger asChild>
|
||||
<Button>Button</Button>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content asChild>
|
||||
<Box padding="4" background="surface-alt-3-subtle">
|
||||
<div>lorem ipsum</div>
|
||||
</Box>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
);
|
||||
|
||||
export const ControlledOpen: StoryFn<typeof Collapsible> = () => (
|
||||
<Collapsible open>
|
||||
<Collapsible.Trigger asChild>
|
||||
<Button>Button</Button>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content asChild>
|
||||
<Box padding="4" background="surface-alt-3-subtle">
|
||||
<div>lorem ipsum</div>
|
||||
</Box>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
);
|
||||
|
||||
export const Disabled: StoryFn<typeof Collapsible> = ({ open = false }) => (
|
||||
<Collapsible open={open}>
|
||||
<Collapsible.Trigger asChild disabled>
|
||||
<Button>Button</Button>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content asChild>
|
||||
<Box padding="4" background="surface-alt-3-subtle">
|
||||
<div>lorem ipsum</div>
|
||||
</Box>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
);
|
||||
|
||||
export const Chromatic: Story = {
|
||||
render: () => (
|
||||
<VStack gap="8">
|
||||
<div>
|
||||
<h2>In context</h2>
|
||||
<InContext />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Animated</h2>
|
||||
<Animated />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Lazy</h2>
|
||||
<Lazy />
|
||||
</div>
|
||||
<div>
|
||||
<h2>AsChild</h2>
|
||||
<AsChild />
|
||||
</div>
|
||||
<div>
|
||||
<h2>DefaultOpen</h2>
|
||||
<DefaultOpen />
|
||||
</div>
|
||||
<div>
|
||||
<h2>ControlledOpen</h2>
|
||||
<ControlledOpen />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Disabled</h2>
|
||||
<Disabled />
|
||||
<Disabled open />
|
||||
</div>
|
||||
</VStack>
|
||||
),
|
||||
parameters: {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { forwardRef, useCallback } from "react";
|
||||
import { useControllableState, useId } from "../util/hooks";
|
||||
import { CollapsibleContextProvider } from "./Collapsible.context";
|
||||
import { CollapsibleBaseProps } from "./Collapsible.types";
|
||||
import CollapsibleContent from "./parts/Collapsible.Content";
|
||||
import CollapsibleTrigger from "./parts/Collapsible.Trigger";
|
||||
|
||||
export interface CollapsibleProps
|
||||
extends CollapsibleBaseProps,
|
||||
React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
interface CollapsibleComponent
|
||||
extends React.ForwardRefExoticComponent<
|
||||
CollapsibleProps & React.RefAttributes<HTMLDivElement>
|
||||
> {
|
||||
/**
|
||||
* @see 🏷️ {@link CollapsibleTriggerProps}
|
||||
*/
|
||||
Trigger: typeof CollapsibleTrigger;
|
||||
/**
|
||||
* @see 🏷️ {@link CollapsibleContentProps}
|
||||
*/
|
||||
Content: typeof CollapsibleContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible is a component that allows you to toggle visibility of content.
|
||||
*
|
||||
* @example
|
||||
* ```jsx
|
||||
* <Collapsible>
|
||||
* <Collapsible.Trigger>Trigger</Collapsible.Trigger>
|
||||
* <Collapsible.Content>
|
||||
* Lorem ipsum dolor sit amet consectetur adipisicing elit. Recusandae
|
||||
* corporis maxime aliquam, voluptates nobis numquam, non odit optio
|
||||
* architecto iure laborum possimus! Quibusdam sit ullam, consequatur sunt
|
||||
* tempore optio aliquid!
|
||||
* </Collapsible.Content>
|
||||
* </Collapsible>
|
||||
* ```
|
||||
*
|
||||
* @example With asChild
|
||||
* ```jsx
|
||||
* <Collapsible>
|
||||
* <Collapsible.Trigger asChild>
|
||||
* <Button>Button</Button>
|
||||
* </Collapsible.Trigger>
|
||||
* <Collapsible.Content asChild>
|
||||
* <Box padding="4" background="surface-alt-3-subtle">
|
||||
* <div>lorem ipsum</div>
|
||||
* </Box>
|
||||
* </Collapsible.Content>
|
||||
* </Collapsible>
|
||||
* ```
|
||||
*/
|
||||
export const Collapsible = forwardRef<HTMLDivElement, CollapsibleProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
lazy = false,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [_open, setOpen] = useControllableState({
|
||||
value: open,
|
||||
defaultValue: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
});
|
||||
|
||||
const internalId = useId();
|
||||
const state = _open ? "open" : "closed";
|
||||
|
||||
return (
|
||||
<CollapsibleContextProvider
|
||||
open={_open}
|
||||
onOpenToggle={useCallback(
|
||||
() => setOpen((prevOpen) => !prevOpen),
|
||||
[setOpen],
|
||||
)}
|
||||
contentId={`collapsible-content-${internalId}`}
|
||||
triggerId={`collapsible-trigger-${internalId}`}
|
||||
lazy={lazy}
|
||||
state={state}
|
||||
>
|
||||
<div ref={ref} data-state={state} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContextProvider>
|
||||
);
|
||||
},
|
||||
) as CollapsibleComponent;
|
||||
|
||||
Collapsible.Trigger = CollapsibleTrigger;
|
||||
Collapsible.Content = CollapsibleContent;
|
||||
|
||||
export default Collapsible;
|
||||
@@ -0,0 +1,19 @@
|
||||
export interface CollapsibleBaseProps {
|
||||
/**
|
||||
* The open state of the collapsible when it is initially rendered. Use when you do not need to control its open state.
|
||||
*/
|
||||
defaultOpen?: boolean;
|
||||
/**
|
||||
* The controlled open state of the collapsible. Must be used in conjunction with onOpenChange.
|
||||
*/
|
||||
open?: boolean;
|
||||
/**
|
||||
* Event handler called when the open state of the collapsible changes.
|
||||
*/
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/**
|
||||
* Render the collapsible content lazily. This means that the content will not be rendered until the collapsible is open.
|
||||
* @default false
|
||||
*/
|
||||
lazy?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
export { default as Collapsible, type CollapsibleProps } from "./Collapsible";
|
||||
export {
|
||||
default as CollapsibleTrigger,
|
||||
type CollapsibleTriggerProps,
|
||||
} from "./parts/Collapsible.Trigger";
|
||||
export {
|
||||
default as CollapsibleContent,
|
||||
type CollapsibleContentProps,
|
||||
} from "./parts/Collapsible.Content";
|
||||
@@ -0,0 +1,39 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { Slot } from "../../util/Slot";
|
||||
import { useCollapsibleContext } from "../Collapsible.context";
|
||||
|
||||
export interface CollapsibleContentProps
|
||||
extends Omit<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
"hidden" | "aria-controls" | "id"
|
||||
> {
|
||||
/**
|
||||
* When true, will render element as its child. This merges classes, styles and event handlers.
|
||||
* @default false
|
||||
*/
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export const CollapsibleContent = forwardRef<
|
||||
HTMLDivElement,
|
||||
CollapsibleContentProps
|
||||
>(({ children, asChild, ...rest }, ref) => {
|
||||
const ctx = useCollapsibleContext();
|
||||
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
{...rest}
|
||||
data-state={ctx.state}
|
||||
hidden={!ctx.open}
|
||||
aria-controls={ctx.open ? ctx.triggerId : undefined}
|
||||
id={ctx.contentId}
|
||||
>
|
||||
{ctx.lazy || ctx.open ? children : null}
|
||||
</Comp>
|
||||
);
|
||||
});
|
||||
|
||||
export default CollapsibleContent;
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { Slot } from "../../util/Slot";
|
||||
import { composeEventHandlers } from "../../util/composeEventHandlers";
|
||||
import { useCollapsibleContext } from "../Collapsible.context";
|
||||
|
||||
export interface CollapsibleTriggerProps
|
||||
extends Omit<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"id" | "aria-controls" | "aria-expanded"
|
||||
> {
|
||||
/**
|
||||
* When true, will render element as its child. This merges classes, styles and event handlers.
|
||||
* @default false
|
||||
*/
|
||||
asChild?: boolean;
|
||||
/**
|
||||
* You may disable aria-expanded if the accessable name of the trigger is changed when toggled, e.g. "Show"/"Hide".
|
||||
*/
|
||||
"aria-expanded"?: undefined;
|
||||
}
|
||||
|
||||
export const CollapsibleTrigger = forwardRef<
|
||||
HTMLButtonElement,
|
||||
CollapsibleTriggerProps
|
||||
>(({ children, asChild, onClick, ...rest }, ref) => {
|
||||
const ctx = useCollapsibleContext();
|
||||
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
type="button"
|
||||
data-state={ctx.state}
|
||||
onClick={composeEventHandlers(onClick, ctx.onOpenToggle)}
|
||||
aria-expanded={ctx.open}
|
||||
{...rest}
|
||||
id={ctx.triggerId}
|
||||
aria-controls={ctx.open ? ctx.contentId : undefined}
|
||||
>
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
});
|
||||
|
||||
export default CollapsibleTrigger;
|
||||
@@ -33,6 +33,7 @@ export interface CopyButtonProps
|
||||
/**
|
||||
* Text shown when button is clicked.
|
||||
* Only set if used with `text`-prop.
|
||||
* @default "Kopiert!"
|
||||
*/
|
||||
activeText?: string;
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import { expect, userEvent, within } from "@storybook/test";
|
||||
import { isSameDay } from "date-fns";
|
||||
import React, { useId, useState } from "react";
|
||||
import { Button } from "../../button";
|
||||
@@ -160,6 +161,7 @@ export const UseDatepicker = () => {
|
||||
fromDate: new Date("Aug 23 2019"),
|
||||
toDate: new Date("Feb 23 2024"),
|
||||
onDateChange: console.log,
|
||||
onValidate: console.log,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -439,6 +441,39 @@ export const WeekDayClick = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const Required = {
|
||||
render: () => {
|
||||
const { datepickerProps } = useDatepicker({
|
||||
defaultSelected: new Date("Apr 10 2024"),
|
||||
required: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ height: "20rem" }}>
|
||||
<DatePicker.Standalone {...datepickerProps} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const button10 = canvas.getByRole("button", { pressed: true });
|
||||
|
||||
await userEvent.click(button10);
|
||||
|
||||
expect(button10.ariaPressed).toBe("true");
|
||||
|
||||
const button17 = canvas.getByText("17").closest("button");
|
||||
|
||||
expect(button17?.ariaPressed).toBe("false");
|
||||
|
||||
button17 && (await userEvent.click(button17));
|
||||
|
||||
expect(button17?.ariaPressed).toBe("true");
|
||||
expect(button10.ariaPressed).toBe("false");
|
||||
},
|
||||
};
|
||||
|
||||
export const ModalDemo = () => {
|
||||
const { datepickerProps, inputProps } = useDatepicker({
|
||||
fromDate: new Date("Aug 23 2019"),
|
||||
|
||||
@@ -20,8 +20,11 @@ const DayButton = (props: DayProps) => {
|
||||
name="day"
|
||||
ref={buttonRef}
|
||||
{...dayRender.buttonProps}
|
||||
role={undefined}
|
||||
aria-label={dateTime}
|
||||
aria-hidden={dayRender.activeModifiers.outside}
|
||||
aria-selected={undefined}
|
||||
aria-pressed={!!dayRender.activeModifiers.selected}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -37,7 +37,11 @@ export const DropdownCaption = ({ displayMonth, id }: CaptionProps) => {
|
||||
const handleMonthChange: React.ChangeEventHandler<HTMLSelectElement> = (e) =>
|
||||
goToMonth(setMonth(startOfMonth(displayMonth), Number(e.target.value)));
|
||||
|
||||
const years = getYears(fromDate, toDate, displayMonth.getFullYear());
|
||||
const years = getYears(
|
||||
fromDate,
|
||||
toDate,
|
||||
displayMonth.getFullYear(),
|
||||
).reverse();
|
||||
const months = getMonths(fromDate, toDate, displayMonth);
|
||||
|
||||
const previousLabel = labelPrevious(previousMonth, { locale });
|
||||
|
||||
@@ -229,12 +229,16 @@ export const useDatepicker = (
|
||||
|
||||
/* Only allow de-selecting if not required */
|
||||
const handleDayClick: DayClickEventHandler = (day, { selected }) => {
|
||||
if (selected && required) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (day && !selected) {
|
||||
handleOpen(false);
|
||||
anchorRef?.focus();
|
||||
}
|
||||
|
||||
if (!required && selected) {
|
||||
if (selected) {
|
||||
updateDate(undefined);
|
||||
setInputValue("");
|
||||
updateValidation({ isValidDate: false, isEmpty: true });
|
||||
@@ -272,7 +276,7 @@ export const useDatepicker = (
|
||||
) {
|
||||
updateDate(undefined);
|
||||
updateValidation({
|
||||
isInvalid: isValidDate(day),
|
||||
isInvalid: !isValidDate(day),
|
||||
isWeekend: disableWeekends && isWeekend(day),
|
||||
isDisabled: disabled && isMatch(day, disabled),
|
||||
isValidDate: false,
|
||||
|
||||
@@ -225,13 +225,17 @@ export const useMonthpicker = (
|
||||
|
||||
/* Only allow de-selecting if not required */
|
||||
const handleMonthClick = (month?: Date) => {
|
||||
if (!month && required) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (month) {
|
||||
handleOpen(false);
|
||||
setYear(month);
|
||||
anchorRef?.focus();
|
||||
}
|
||||
|
||||
if (!required && !month) {
|
||||
if (!month) {
|
||||
updateMonth(undefined);
|
||||
updateValidation({ isValidMonth: false, isEmpty: true });
|
||||
setInputValue("");
|
||||
|
||||
@@ -66,6 +66,7 @@ export const MonthButton = ({
|
||||
type="button"
|
||||
onClick={() => onSelect(isSelected ? undefined : month)}
|
||||
disabled={isDisabled}
|
||||
aria-pressed={!!isSelected}
|
||||
className={cl("navds-date__month-button", {
|
||||
"rdp-day_today": dateIsInCurrentMonth(month, year),
|
||||
"rdp-day_selected": isSelected,
|
||||
|
||||
@@ -35,7 +35,7 @@ export const MonthCaption = () => {
|
||||
if (!years.map((x) => x.getFullYear()).includes(year.getFullYear())) {
|
||||
years.push(setYear(startOfYear(new Date()), year.getFullYear()));
|
||||
}
|
||||
years.sort((a, b) => a.getFullYear() - b.getFullYear());
|
||||
years.sort((a, b) => b.getFullYear() - a.getFullYear());
|
||||
}
|
||||
|
||||
const handleYearChange = (e) =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Meta, StoryFn, StoryObj } from "@storybook/react";
|
||||
import { expect, userEvent, within } from "@storybook/test";
|
||||
import { setYear } from "date-fns";
|
||||
import React, { useId, useState } from "react";
|
||||
import { Button } from "../../button";
|
||||
@@ -121,25 +122,37 @@ export const UseMonthpickerFormat = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const Required = () => {
|
||||
const { inputProps, monthpickerProps } = useMonthpicker({
|
||||
locale: "nb",
|
||||
defaultSelected: new Date(),
|
||||
disabled: [new Date("Apr 1 2022")],
|
||||
required: true,
|
||||
});
|
||||
export const Required = {
|
||||
render: () => {
|
||||
const { monthpickerProps } = useMonthpicker({
|
||||
defaultSelected: new Date("Apr 10 2024"),
|
||||
required: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ height: "20rem" }}>
|
||||
<MonthPicker {...monthpickerProps}>
|
||||
<MonthPicker.Input
|
||||
{...inputProps}
|
||||
label="Velg måned"
|
||||
variant="monthpicker"
|
||||
/>
|
||||
</MonthPicker>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{ height: "20rem" }}>
|
||||
<MonthPicker.Standalone {...monthpickerProps} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const buttonApr = canvas.getByRole("button", { pressed: true });
|
||||
|
||||
await userEvent.click(buttonApr);
|
||||
|
||||
expect(buttonApr.ariaPressed).toBe("true");
|
||||
|
||||
const buttonSep = canvas.getByText("september").closest("button");
|
||||
|
||||
expect(buttonSep?.ariaPressed).toBe("false");
|
||||
|
||||
buttonSep && (await userEvent.click(buttonSep));
|
||||
|
||||
expect(buttonSep?.ariaPressed).toBe("true");
|
||||
expect(buttonApr.ariaPressed).toBe("false");
|
||||
},
|
||||
};
|
||||
|
||||
export const UserControlled = () => {
|
||||
@@ -193,7 +206,6 @@ export const Chromatic: Story = {
|
||||
<DisabledMonths />
|
||||
<UseMonthpicker />
|
||||
<UseMonthpickerFormat />
|
||||
<Required />
|
||||
<UserControlled />
|
||||
<FollowYear />
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import React from "react";
|
||||
import { XMarkIcon } from "@navikt/aksel-icons";
|
||||
|
||||
interface ClearButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
handleClear: (event: any) => void;
|
||||
clearButtonLabel?: string;
|
||||
}
|
||||
|
||||
export const ClearButton: React.FC<ClearButtonProps> = ({
|
||||
handleClear,
|
||||
clearButtonLabel,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="navds-combobox__button-clear"
|
||||
{...rest}
|
||||
>
|
||||
<span className="navds-sr-only">
|
||||
{clearButtonLabel ? clearButtonLabel : "Tøm"}
|
||||
</span>
|
||||
<XMarkIcon aria-hidden />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClearButton;
|
||||
@@ -1,57 +1,31 @@
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef, useRef } from "react";
|
||||
import React, { forwardRef } from "react";
|
||||
import { BodyShort, ErrorMessage, Label } from "../../typography";
|
||||
import { useMergeRefs } from "../../util/hooks/useMergeRefs";
|
||||
import ClearButton from "./ClearButton";
|
||||
import ComboboxWrapper from "./ComboboxWrapper";
|
||||
import FilteredOptions from "./FilteredOptions/FilteredOptions";
|
||||
import { useFilteredOptionsContext } from "./FilteredOptions/filteredOptionsContext";
|
||||
import Input from "./Input/Input";
|
||||
import { useInputContext } from "./Input/inputContext";
|
||||
import SelectedOptions from "./SelectedOptions/SelectedOptions";
|
||||
import { useSelectedOptionsContext } from "./SelectedOptions/selectedOptionsContext";
|
||||
import ToggleListButton from "./ToggleListButton";
|
||||
import { useInputContext } from "./Input/Input.context";
|
||||
import { InputController } from "./Input/InputController";
|
||||
import { ComboboxProps } from "./types";
|
||||
|
||||
export const Combobox = forwardRef<
|
||||
HTMLInputElement,
|
||||
Omit<ComboboxProps, "onChange" | "options" | "size" | "onClear" | "value">
|
||||
>((props, ref) => {
|
||||
const {
|
||||
className,
|
||||
hideLabel = false,
|
||||
description,
|
||||
label,
|
||||
clearButton = true,
|
||||
clearButtonLabel,
|
||||
toggleListButton = true,
|
||||
toggleListButtonLabel,
|
||||
inputClassName,
|
||||
shouldShowSelectedOptions = true,
|
||||
...rest
|
||||
} = props;
|
||||
const { className, hideLabel = false, description, label, ...rest } = props;
|
||||
|
||||
const toggleListButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const { activeDecendantId, toggleIsListOpen } = useFilteredOptionsContext();
|
||||
const { selectedOptions } = useSelectedOptionsContext();
|
||||
const { toggleIsListOpen } = useFilteredOptionsContext();
|
||||
|
||||
const {
|
||||
clearInput,
|
||||
error,
|
||||
errorId,
|
||||
focusInput,
|
||||
hasError,
|
||||
inputDescriptionId,
|
||||
inputProps,
|
||||
inputRef,
|
||||
value,
|
||||
showErrorMsg,
|
||||
size = "medium",
|
||||
} = useInputContext();
|
||||
|
||||
const mergedInputRef = useMergeRefs(inputRef, ref);
|
||||
|
||||
return (
|
||||
<ComboboxWrapper
|
||||
className={className}
|
||||
@@ -59,7 +33,6 @@ export const Combobox = forwardRef<
|
||||
inputProps={inputProps}
|
||||
inputSize={size}
|
||||
toggleIsListOpen={toggleIsListOpen}
|
||||
toggleListButtonRef={toggleListButtonRef}
|
||||
>
|
||||
<Label
|
||||
htmlFor={inputProps.id}
|
||||
@@ -83,50 +56,7 @@ export const Combobox = forwardRef<
|
||||
</BodyShort>
|
||||
)}
|
||||
<div className="navds-combobox__wrapper">
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className={cl(
|
||||
"navds-combobox__wrapper-inner navds-text-field__input",
|
||||
{
|
||||
"navds-combobox__wrapper-inner--virtually-unfocused":
|
||||
activeDecendantId !== undefined,
|
||||
},
|
||||
)}
|
||||
onClick={focusInput}
|
||||
>
|
||||
{!shouldShowSelectedOptions ? (
|
||||
<Input
|
||||
id={inputProps.id}
|
||||
ref={mergedInputRef}
|
||||
inputClassName={inputClassName}
|
||||
{...rest}
|
||||
/>
|
||||
) : (
|
||||
<SelectedOptions selectedOptions={selectedOptions} size={size}>
|
||||
<Input
|
||||
id={inputProps.id}
|
||||
ref={mergedInputRef}
|
||||
inputClassName={inputClassName}
|
||||
{...rest}
|
||||
/>
|
||||
</SelectedOptions>
|
||||
)}
|
||||
<div>
|
||||
{value && clearButton && (
|
||||
<ClearButton
|
||||
handleClear={clearInput}
|
||||
clearButtonLabel={clearButtonLabel}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
)}
|
||||
{toggleListButton && (
|
||||
<ToggleListButton
|
||||
toggleListButtonLabel={toggleListButtonLabel}
|
||||
ref={toggleListButtonRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<InputController ref={ref} {...rest} />
|
||||
<FilteredOptions />
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import Combobox from "./Combobox";
|
||||
import { FilteredOptionsProvider } from "./FilteredOptions/filteredOptionsContext";
|
||||
import { InputContextProvider } from "./Input/inputContext";
|
||||
import { InputContextProvider } from "./Input/Input.context";
|
||||
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={{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import cl from "clsx";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { useInputContext } from "./Input/Input.context";
|
||||
|
||||
type ComboboxWrapperProps = {
|
||||
children: any;
|
||||
@@ -10,7 +11,6 @@ type ComboboxWrapperProps = {
|
||||
};
|
||||
inputSize: string;
|
||||
toggleIsListOpen: (isListOpen: boolean) => void;
|
||||
toggleListButtonRef: React.RefObject<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
const ComboboxWrapper = ({
|
||||
@@ -20,15 +20,16 @@ const ComboboxWrapper = ({
|
||||
inputProps,
|
||||
inputSize,
|
||||
toggleIsListOpen,
|
||||
toggleListButtonRef,
|
||||
}: ComboboxWrapperProps) => {
|
||||
const { toggleOpenButtonRef } = useInputContext();
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const [hasFocusWithin, setHasFocusWithin] = useState(false);
|
||||
|
||||
function onFocusInsideWrapper(e) {
|
||||
if (
|
||||
!wrapperRef.current?.contains(e.relatedTarget) &&
|
||||
toggleListButtonRef?.current !== e.target
|
||||
toggleOpenButtonRef?.current !== e.target
|
||||
) {
|
||||
toggleIsListOpen(true);
|
||||
setHasFocusWithin(true);
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
const CheckIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="13"
|
||||
viewBox="0 0 16 13"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
fill="#005B82"
|
||||
d="M14.2014 0L16 1.89047L4.77943 13L0 8.39552L1.79361 6.5L4.77418 9.3019L14.2014 0Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckIcon;
|
||||
@@ -3,8 +3,10 @@ import React from "react";
|
||||
import { CheckmarkIcon, PlusIcon } from "@navikt/aksel-icons";
|
||||
import { Loader } from "../../../loader";
|
||||
import { BodyShort, Label } from "../../../typography";
|
||||
import { useInputContext } from "../Input/inputContext";
|
||||
import { useInputContext } from "../Input/Input.context";
|
||||
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,13 +32,13 @@ 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
|
||||
isLoading || // Render loading message
|
||||
(!isLoading && filteredOptions.length === 0); // Render no hits message
|
||||
(!isLoading && filteredOptions.length === 0 && !allowNewValues); // Render no hits message
|
||||
|
||||
const shouldRenderFilteredOptionsList =
|
||||
(allowNewValues && isValueNew && !maxSelected?.isLimitReached) || // Render add new option
|
||||
@@ -70,7 +72,7 @@ const FilteredOptions = () => {
|
||||
<Loader title="Søker..." />
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && filteredOptions.length === 0 && (
|
||||
{!isLoading && filteredOptions.length === 0 && !allowNewValues && (
|
||||
<div
|
||||
className="navds-combobox__list-item--no-options"
|
||||
id={filteredOptionsUtil.getNoHitsId(id)}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,33 +1,24 @@
|
||||
import cl from "clsx";
|
||||
import React, {
|
||||
SetStateAction,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { SetStateAction, useCallback, useMemo, useState } from "react";
|
||||
import { createContext } from "../../../util/create-context";
|
||||
import { useClientLayoutEffect, usePrevious } from "../../../util/hooks";
|
||||
import { useInputContext } from "../Input/inputContext";
|
||||
import { useInputContext } from "../Input/Input.context";
|
||||
import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
|
||||
import { useCustomOptionsContext } from "../customOptionsContext";
|
||||
import { ComboboxProps } from "../types";
|
||||
import { toComboboxOption } from "../combobox-utils";
|
||||
import { useComboboxCustomOptions } from "../customOptionsContext";
|
||||
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 = {
|
||||
type FilteredOptionsContextValue = {
|
||||
activeDecendantId?: string;
|
||||
allowNewValues?: boolean;
|
||||
ariaDescribedBy?: string;
|
||||
@@ -36,20 +27,23 @@ 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;
|
||||
};
|
||||
const FilteredOptionsContext = createContext<FilteredOptionsContextType>(
|
||||
{} as FilteredOptionsContextType,
|
||||
);
|
||||
const [FilteredOptionsContextProvider, useFilteredOptionsContext] =
|
||||
createContext<FilteredOptionsContextValue>({
|
||||
name: "FilteredOptionsContext",
|
||||
errorMessage:
|
||||
"useFilteredOptionsContext must be used within a FilteredOptionsProvider",
|
||||
});
|
||||
|
||||
export const FilteredOptionsProvider = ({
|
||||
const FilteredOptionsProvider = ({
|
||||
children,
|
||||
value: props,
|
||||
}: FilteredOptionsProps) => {
|
||||
@@ -71,28 +65,18 @@ export const FilteredOptionsProvider = ({
|
||||
setSearchTerm,
|
||||
shouldAutocomplete,
|
||||
} = useInputContext();
|
||||
const { selectedOptions, maxSelected } = useSelectedOptionsContext();
|
||||
const { maxSelected } = useSelectedOptionsContext();
|
||||
|
||||
const [isInternalListOpen, setInternalListOpen] = useState(false);
|
||||
const { customOptions } = useCustomOptionsContext();
|
||||
const { customOptions } = useComboboxCustomOptions();
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (externalFilteredOptions) {
|
||||
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,15 +88,23 @@ 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,
|
||||
...customOptions.reduce(
|
||||
(acc, customOption) => ({
|
||||
...acc,
|
||||
[filteredOptionsUtils.getOptionId(id, customOption.label)]:
|
||||
customOption,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
},
|
||||
),
|
||||
[allowNewValues, id, options, value],
|
||||
[allowNewValues, customOptions, id, options, value],
|
||||
);
|
||||
|
||||
useClientLayoutEffect(() => {
|
||||
@@ -123,7 +115,7 @@ export const FilteredOptionsProvider = ({
|
||||
filteredOptions.length > 0
|
||||
) {
|
||||
setValue(
|
||||
`${searchTerm}${filteredOptions[0].substring(searchTerm.length)}`,
|
||||
`${searchTerm}${filteredOptions[0].label.substring(searchTerm.length)}`,
|
||||
);
|
||||
setSearchTerm(searchTerm);
|
||||
}
|
||||
@@ -157,11 +149,14 @@ export const FilteredOptionsProvider = ({
|
||||
|
||||
const ariaDescribedBy = useMemo(() => {
|
||||
let activeOption;
|
||||
if (!isLoading && filteredOptions.length === 0) {
|
||||
if (!isLoading && filteredOptions.length === 0 && !allowNewValues) {
|
||||
activeOption = filteredOptionsUtils.getNoHitsId(id);
|
||||
} else if ((value && value !== "") || isLoading) {
|
||||
} else if (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);
|
||||
}
|
||||
@@ -182,6 +177,7 @@ export const FilteredOptionsProvider = ({
|
||||
shouldAutocomplete,
|
||||
filteredOptions,
|
||||
id,
|
||||
allowNewValues,
|
||||
]);
|
||||
|
||||
const currentOption = useMemo(
|
||||
@@ -213,18 +209,10 @@ export const FilteredOptionsProvider = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<FilteredOptionsContext.Provider value={filteredOptionsState}>
|
||||
<FilteredOptionsContextProvider {...filteredOptionsState}>
|
||||
{children}
|
||||
</FilteredOptionsContext.Provider>
|
||||
</FilteredOptionsContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useFilteredOptionsContext = () => {
|
||||
const context = useContext(FilteredOptionsContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useFilteredOptionsContext must be used within a FilteredOptionsProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
export { FilteredOptionsProvider, useFilteredOptionsContext };
|
||||
+30
-33
@@ -1,18 +1,18 @@
|
||||
import React, {
|
||||
ChangeEvent,
|
||||
ChangeEventHandler,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createContext } from "../../../util/create-context";
|
||||
import { useClientLayoutEffect } from "../../../util/hooks";
|
||||
import { FormFieldType, useFormField } from "../../useFormField";
|
||||
import { ComboboxProps } from "../types";
|
||||
|
||||
interface InputContextType extends FormFieldType {
|
||||
clearInput: (event: React.PointerEvent | React.KeyboardEvent) => void;
|
||||
interface InputContextValue extends FormFieldType {
|
||||
clearInput: NonNullable<ComboboxProps["onClear"]>;
|
||||
error?: string;
|
||||
focusInput: () => void;
|
||||
inputRef: React.RefObject<HTMLInputElement>;
|
||||
@@ -22,11 +22,16 @@ interface InputContextType extends FormFieldType {
|
||||
searchTerm: string;
|
||||
setSearchTerm: React.Dispatch<React.SetStateAction<string>>;
|
||||
shouldAutocomplete?: boolean;
|
||||
toggleOpenButtonRef: React.RefObject<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
const InputContext = createContext<InputContextType>({} as InputContextType);
|
||||
const [InputContextProvider, useInputContext] =
|
||||
createContext<InputContextValue>({
|
||||
name: "InputContext",
|
||||
errorMessage: "useInputContext must be used within an InputContextProvider",
|
||||
});
|
||||
|
||||
export const InputContextProvider = ({ children, value: props }) => {
|
||||
const InputProvider = ({ children, value: props }) => {
|
||||
const {
|
||||
defaultValue = "",
|
||||
description,
|
||||
@@ -52,6 +57,7 @@ export const InputContextProvider = ({ children, value: props }) => {
|
||||
"comboboxfield",
|
||||
);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const toggleOpenButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [internalValue, setInternalValue] = useState<string>(defaultValue);
|
||||
|
||||
const value = useMemo(
|
||||
@@ -79,7 +85,7 @@ export const InputContextProvider = ({ children, value: props }) => {
|
||||
);
|
||||
|
||||
const clearInput = useCallback(
|
||||
(event: React.PointerEvent | React.KeyboardEvent) => {
|
||||
(event: React.PointerEvent | React.KeyboardEvent | React.MouseEvent) => {
|
||||
onClear?.(event);
|
||||
externalOnChange?.(null, "");
|
||||
setValue("");
|
||||
@@ -98,33 +104,24 @@ export const InputContextProvider = ({ children, value: props }) => {
|
||||
}
|
||||
}, [value, searchTerm, shouldAutocomplete]);
|
||||
|
||||
const contextValue = {
|
||||
...formFieldProps,
|
||||
clearInput,
|
||||
error,
|
||||
focusInput,
|
||||
inputRef,
|
||||
value,
|
||||
setValue,
|
||||
onChange,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
shouldAutocomplete,
|
||||
toggleOpenButtonRef,
|
||||
};
|
||||
|
||||
return (
|
||||
<InputContext.Provider
|
||||
value={{
|
||||
...formFieldProps,
|
||||
clearInput,
|
||||
error,
|
||||
focusInput,
|
||||
inputRef,
|
||||
value,
|
||||
setValue,
|
||||
onChange,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
shouldAutocomplete,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</InputContext.Provider>
|
||||
<InputContextProvider {...contextValue}>{children}</InputContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useInputContext = () => {
|
||||
const context = useContext(InputContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useInputContext must be used within an InputContextProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
export { InputProvider as InputContextProvider, useInputContext };
|
||||
@@ -9,7 +9,7 @@ import { omit } from "../../../util";
|
||||
import filteredOptionsUtil from "../FilteredOptions/filtered-options-util";
|
||||
import { useFilteredOptionsContext } from "../FilteredOptions/filteredOptionsContext";
|
||||
import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
|
||||
import { useInputContext } from "./inputContext";
|
||||
import { useInputContext } from "./Input.context";
|
||||
|
||||
interface InputProps
|
||||
extends Omit<InputHTMLAttributes<HTMLInputElement>, "value"> {
|
||||
@@ -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,13 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
if (value === "") {
|
||||
const lastSelectedOption =
|
||||
selectedOptions[selectedOptions.length - 1];
|
||||
removeSelectedOption(lastSelectedOption);
|
||||
if (lastSelectedOption) {
|
||||
removeSelectedOption(lastSelectedOption);
|
||||
}
|
||||
}
|
||||
} else if (e.key === "Enter" || e.key === "Accept") {
|
||||
if (activeDecendantId || value) {
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if (e.key === "ArrowDown") {
|
||||
// Check that cursor position is at the end of the input field,
|
||||
@@ -176,6 +186,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
{...omit(inputProps, ["aria-invalid"])}
|
||||
ref={ref}
|
||||
value={value}
|
||||
onBlur={() => virtualFocus.moveFocusToTop()}
|
||||
onChange={onChangeHandler}
|
||||
type="text"
|
||||
role="combobox"
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef } from "react";
|
||||
import { XMarkIcon } from "@navikt/aksel-icons";
|
||||
import { useMergeRefs } from "../../../util/hooks";
|
||||
import { useFilteredOptionsContext } from "../FilteredOptions/filteredOptionsContext";
|
||||
import SelectedOptions from "../SelectedOptions/SelectedOptions";
|
||||
import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
|
||||
import { ComboboxProps } from "../types";
|
||||
import Input from "./Input";
|
||||
import { useInputContext } from "./Input.context";
|
||||
import ToggleListButton from "./ToggleListButton";
|
||||
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
export const InputController = forwardRef<
|
||||
HTMLInputElement,
|
||||
Omit<
|
||||
ComboboxProps,
|
||||
| "label"
|
||||
| "description"
|
||||
| "hideLabel"
|
||||
| "onChange"
|
||||
| "options"
|
||||
| "size"
|
||||
| "onClear"
|
||||
| "value"
|
||||
>
|
||||
>((props, ref) => {
|
||||
const {
|
||||
clearButton = true,
|
||||
clearButtonLabel,
|
||||
toggleListButton = true,
|
||||
toggleListButtonLabel,
|
||||
inputClassName,
|
||||
shouldShowSelectedOptions = true,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const {
|
||||
clearInput,
|
||||
focusInput,
|
||||
inputProps,
|
||||
value,
|
||||
size = "medium",
|
||||
inputRef,
|
||||
toggleOpenButtonRef,
|
||||
} = useInputContext();
|
||||
|
||||
const { activeDecendantId } = useFilteredOptionsContext();
|
||||
const { selectedOptions } = useSelectedOptionsContext();
|
||||
|
||||
const mergedInputRef = useMergeRefs(inputRef, ref);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cl("navds-combobox__wrapper-inner navds-text-field__input", {
|
||||
"navds-combobox__wrapper-inner--virtually-unfocused":
|
||||
activeDecendantId !== undefined,
|
||||
})}
|
||||
onClick={focusInput}
|
||||
>
|
||||
{!shouldShowSelectedOptions ? (
|
||||
<Input
|
||||
id={inputProps.id}
|
||||
ref={mergedInputRef}
|
||||
inputClassName={inputClassName}
|
||||
{...rest}
|
||||
/>
|
||||
) : (
|
||||
<SelectedOptions selectedOptions={selectedOptions} size={size}>
|
||||
<Input
|
||||
id={inputProps.id}
|
||||
ref={mergedInputRef}
|
||||
inputClassName={inputClassName}
|
||||
{...rest}
|
||||
/>
|
||||
</SelectedOptions>
|
||||
)}
|
||||
<div>
|
||||
{value && clearButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearInput}
|
||||
className="navds-combobox__button-clear"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<span className="navds-sr-only">
|
||||
{clearButtonLabel ? clearButtonLabel : "Tøm"}
|
||||
</span>
|
||||
<XMarkIcon aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
{toggleListButton && (
|
||||
<ToggleListButton
|
||||
toggleListButtonLabel={toggleListButtonLabel}
|
||||
ref={toggleOpenButtonRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "@navikt/aksel-icons";
|
||||
import { useFilteredOptionsContext } from "./FilteredOptions/filteredOptionsContext";
|
||||
import { useFilteredOptionsContext } from "../FilteredOptions/filteredOptionsContext";
|
||||
|
||||
interface ToggleListButtonProps {
|
||||
toggleListButtonLabel?: string;
|
||||
@@ -1,15 +1,16 @@
|
||||
import React from "react";
|
||||
import { Chips } from "../../../chips";
|
||||
import { useInputContext } from "../Input/inputContext";
|
||||
import { useInputContext } from "../Input/Input.context";
|
||||
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}
|
||||
|
||||
@@ -1,47 +1,37 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { createContext } from "../../../util/create-context";
|
||||
import { usePrevious } from "../../../util/hooks";
|
||||
import { useInputContext } from "../Input/inputContext";
|
||||
import { useCustomOptionsContext } from "../customOptionsContext";
|
||||
import { ComboboxProps, MaxSelected } from "../types";
|
||||
import { useInputContext } from "../Input/Input.context";
|
||||
import { isInList } from "../combobox-utils";
|
||||
import { useComboboxCustomOptions } from "../customOptionsContext";
|
||||
import { ComboboxOption, ComboboxProps, MaxSelected } from "../types";
|
||||
|
||||
type SelectedOptionsContextType = {
|
||||
addSelectedOption: (option: string) => void;
|
||||
type SelectedOptionsContextValue = {
|
||||
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;
|
||||
};
|
||||
|
||||
const SelectedOptionsContext = createContext<SelectedOptionsContextType>(
|
||||
{} as SelectedOptionsContextType,
|
||||
);
|
||||
const [SelectedOptionsContextProvider, useSelectedOptionsContext] =
|
||||
createContext<SelectedOptionsContextValue>();
|
||||
|
||||
export const SelectedOptionsProvider = ({
|
||||
const SelectedOptionsProvider = ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: any;
|
||||
value: Pick<
|
||||
ComboboxProps,
|
||||
| "allowNewValues"
|
||||
| "isMultiSelect"
|
||||
| "options"
|
||||
| "selectedOptions"
|
||||
| "onToggleSelected"
|
||||
| "maxSelected"
|
||||
>;
|
||||
"allowNewValues" | "isMultiSelect" | "onToggleSelected" | "maxSelected"
|
||||
> & { options: ComboboxOption[]; selectedOptions?: ComboboxOption[] };
|
||||
}) => {
|
||||
const { clearInput, focusInput } = useInputContext();
|
||||
const {
|
||||
@@ -49,7 +39,7 @@ export const SelectedOptionsProvider = ({
|
||||
removeCustomOption,
|
||||
addCustomOption,
|
||||
setCustomOptions,
|
||||
} = useCustomOptionsContext();
|
||||
} = useComboboxCustomOptions();
|
||||
const {
|
||||
allowNewValues,
|
||||
isMultiSelect,
|
||||
@@ -58,7 +48,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 +58,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 +72,7 @@ export const SelectedOptionsProvider = ({
|
||||
setSelectedOptions([option]);
|
||||
setCustomOptions([]);
|
||||
}
|
||||
onToggleSelected?.(option, true, isCustomOption);
|
||||
onToggleSelected?.(option.value, true, isCustomOption);
|
||||
},
|
||||
[
|
||||
addCustomOption,
|
||||
@@ -95,8 +85,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 +96,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 +123,7 @@ export const SelectedOptionsProvider = ({
|
||||
],
|
||||
);
|
||||
|
||||
const prevSelectedOptions = usePrevious<string[]>(selectedOptions);
|
||||
const prevSelectedOptions = usePrevious<ComboboxOption[]>(selectedOptions);
|
||||
|
||||
const isLimitReached =
|
||||
!!maxSelected?.limit && selectedOptions.length >= maxSelected.limit;
|
||||
@@ -150,18 +143,10 @@ export const SelectedOptionsProvider = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectedOptionsContext.Provider value={selectedOptionsState}>
|
||||
<SelectedOptionsContextProvider {...selectedOptionsState}>
|
||||
{children}
|
||||
</SelectedOptionsContext.Provider>
|
||||
</SelectedOptionsContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSelectedOptionsContext = () => {
|
||||
const context = useContext(SelectedOptionsContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useSelectedOptionsContext must be used within a SelectedOptionsProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
export { SelectedOptionsProvider, useSelectedOptionsContext };
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
+34
-4
@@ -1,10 +1,9 @@
|
||||
/* eslint-disable testing-library/no-unnecessary-act -- https://kentcdodds.com/blog/fix-the-not-wrapped-in-act-warning */
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { act, 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 { UNSAFE_Combobox } from "./index";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { UNSAFE_Combobox } from "../index";
|
||||
|
||||
const options = [
|
||||
"banana",
|
||||
@@ -123,4 +122,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();
|
||||
});
|
||||
});
|
||||
@@ -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 }}
|
||||
@@ -429,13 +464,13 @@ export const RemoveSelectedMultiSelectTest: StoryObject = {
|
||||
},
|
||||
};
|
||||
|
||||
export const AddWhenAddNewDisabledTest: StoryObject = {
|
||||
export const AllowNewValuesTest: StoryObject = {
|
||||
render: () => {
|
||||
return (
|
||||
<UNSAFE_Combobox
|
||||
options={options}
|
||||
label="Hva er dine favorittfrukter?"
|
||||
isMultiSelect
|
||||
allowNewValues
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -462,6 +497,35 @@ export const AddWhenAddNewDisabledTest: StoryObject = {
|
||||
},
|
||||
};
|
||||
|
||||
export const DisallowNewValuesTest: StoryObject = {
|
||||
render: () => {
|
||||
return (
|
||||
<UNSAFE_Combobox options={options} label="Hva er dine favorittfrukter?" />
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
|
||||
|
||||
userEvent.click(input);
|
||||
await userEvent.type(input, "aaa", { delay: 200 });
|
||||
await sleep(250);
|
||||
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{Enter}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{Escape}");
|
||||
await sleep(250);
|
||||
|
||||
const invalidSelect = canvas.queryByLabelText("aaa slett");
|
||||
expect(invalidSelect).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const TestThatCallbacksOnlyFireWhenExpected: StoryObj<{
|
||||
onChange: ReturnType<typeof fn>;
|
||||
onClear: ReturnType<typeof fn>;
|
||||
@@ -589,3 +653,71 @@ export const TestHoverAndFocusSwitching: StoryObject = {
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const TestEnterNotSubmittingForm: StoryObj<{
|
||||
onSubmit: ReturnType<typeof fn>;
|
||||
}> = {
|
||||
args: {
|
||||
onSubmit: fn(),
|
||||
},
|
||||
render: ({ onSubmit }) => {
|
||||
return (
|
||||
<form action="https://www.aksel.nav.no" method="get" onSubmit={onSubmit}>
|
||||
<UNSAFE_Combobox
|
||||
options={options}
|
||||
label="Hva er dine favorittfrukter?"
|
||||
isMultiSelect
|
||||
allowNewValues
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
args.onSubmit.mockClear();
|
||||
const canvas = within(canvasElement);
|
||||
const waitTime = 0; // Change for debugging
|
||||
|
||||
await sleep(waitTime);
|
||||
|
||||
const getInput = () =>
|
||||
canvas.getByRole("combobox", {
|
||||
name: "Hva er dine favorittfrukter?",
|
||||
});
|
||||
|
||||
const getOption = (name: string, selected: boolean) =>
|
||||
canvas.getByRole("option", { name, selected });
|
||||
await userEvent.click(getInput(), { delay: waitTime });
|
||||
|
||||
await userEvent.keyboard("{ArrowDown}", { delay: waitTime });
|
||||
expect(getInput().getAttribute("aria-activedescendant")).toBe(
|
||||
getOption("banana", false).getAttribute("id"),
|
||||
);
|
||||
|
||||
await userEvent.keyboard("{Enter}", { delay: waitTime });
|
||||
expect(args.onSubmit).not.toHaveBeenCalled();
|
||||
expect(getOption("banana", true)).toBeVisible();
|
||||
|
||||
await userEvent.keyboard("{Shift>}{Tab}", { delay: waitTime });
|
||||
|
||||
await userEvent.keyboard("{Enter}", { delay: waitTime });
|
||||
expect(args.onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.keyboard("test"); // Type option that does not exist
|
||||
await userEvent.keyboard("{Enter}", { delay: waitTime });
|
||||
expect(args.onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.keyboard("{ArrowDown}", { delay: waitTime }); // Select "test" custom option
|
||||
expect(
|
||||
canvas.getByRole("option", { name: "test", selected: true }),
|
||||
).toBeVisible();
|
||||
await userEvent.keyboard("{Enter}", { delay: waitTime }); // De-select "test"
|
||||
expect(
|
||||
canvas.queryByRole("option", { name: "test", selected: false }),
|
||||
).toBeNull();
|
||||
expect(args.onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.keyboard("{Escape}", { delay: waitTime }); // Clear input field
|
||||
await userEvent.keyboard("{Enter}", { delay: waitTime }); // Enter on empty Input with closed list
|
||||
expect(args.onSubmit).toHaveBeenCalledOnce();
|
||||
},
|
||||
};
|
||||
@@ -1,32 +1,37 @@
|
||||
import React, { createContext, useCallback, useContext, useState } from "react";
|
||||
import { useInputContext } from "./Input/inputContext";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { createContext } from "../../util/create-context";
|
||||
import { useInputContext } from "./Input/Input.context";
|
||||
import { ComboboxOption } from "./types";
|
||||
|
||||
type CustomOptionsContextType = {
|
||||
customOptions: string[];
|
||||
removeCustomOption: (option: string) => void;
|
||||
addCustomOption: (option: string) => void;
|
||||
setCustomOptions: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
type CustomOptionsContextValue = {
|
||||
customOptions: ComboboxOption[];
|
||||
removeCustomOption: (option: ComboboxOption) => void;
|
||||
addCustomOption: (option: ComboboxOption) => void;
|
||||
setCustomOptions: React.Dispatch<React.SetStateAction<ComboboxOption[]>>;
|
||||
};
|
||||
|
||||
const CustomOptionsContext = createContext<CustomOptionsContextType>(
|
||||
{} as CustomOptionsContextType,
|
||||
);
|
||||
const [ComboboxCustomOptionsProvider, useComboboxCustomOptions] =
|
||||
createContext<CustomOptionsContextValue>({
|
||||
name: "ComboboxCustomOptions",
|
||||
errorMessage:
|
||||
"useComboboxCustomOptions must be used within a ComboboxCustomOptionsProvider",
|
||||
});
|
||||
|
||||
export const CustomOptionsProvider = ({
|
||||
const CustomOptionsProvider = ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
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 +39,7 @@ export const CustomOptionsProvider = ({
|
||||
);
|
||||
|
||||
const addCustomOption = useCallback(
|
||||
(option: string) => {
|
||||
(option: ComboboxOption) => {
|
||||
if (isMultiSelect) {
|
||||
setCustomOptions((prevOptions) => [...prevOptions, option]);
|
||||
} else {
|
||||
@@ -53,18 +58,10 @@ export const CustomOptionsProvider = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomOptionsContext.Provider value={customOptionsState}>
|
||||
<ComboboxCustomOptionsProvider {...customOptionsState}>
|
||||
{children}
|
||||
</CustomOptionsContext.Provider>
|
||||
</ComboboxCustomOptionsProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useCustomOptionsContext = () => {
|
||||
const context = useContext(CustomOptionsContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useCustomOptionsContext must be used within a CustomOptionsProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
export { CustomOptionsProvider, useComboboxCustomOptions };
|
||||
@@ -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,19 @@ export interface ComboboxProps
|
||||
* Callback function triggered whenever the input field is cleared.
|
||||
*
|
||||
* @param event
|
||||
* @returns
|
||||
*/
|
||||
onClear?: (event: React.PointerEvent | React.KeyboardEvent) => void;
|
||||
onClear?: (
|
||||
event: React.PointerEvent | React.KeyboardEvent | React.MouseEvent,
|
||||
) => 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 +121,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 "../../util/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 { ComponentTranslation } from "../../util/i18n/i18n.types";
|
||||
import { FileUploadLocaleContextProvider } from "./FileUpload.context";
|
||||
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 = ({ 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,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 { useI18n } from "../../../../util/i18n/i18n.context";
|
||||
import { omit } from "../../../../util/omit";
|
||||
import { useFormField } from "../../../useFormField";
|
||||
import { useFileUploadTranslation } from "../../FileUpload.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 { ComponentTranslation } from "../../../../util/i18n/i18n.types";
|
||||
import { FormFieldProps } from "../../../useFormField";
|
||||
import { FileUploadBaseProps } from "../../FileUpload.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 { useI18n } from "../../../../util/i18n/i18n.context";
|
||||
import { ComponentTranslation } from "../../../../util/i18n/i18n.types";
|
||||
import { useFileUploadTranslation } from "../../FileUpload.context";
|
||||
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;
|
||||
});
|
||||
}
|
||||
Loaded 100 of 536 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user