mirror of
https://github.com/navikt/aksel.git
synced 2026-10-13 09:12:03 +00:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@@ -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,19 @@
|
||||
# @navikt/aksel-icons
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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.3.6",
|
||||
"author": "Aksel | NAV",
|
||||
"homepage": "https://aksel.nav.no/ikoner",
|
||||
"repository": {
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @navikt/aksel-stylelint
|
||||
|
||||
## 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.3.6",
|
||||
"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.3.6",
|
||||
"@navikt/ds-tokens": "^6.3.6",
|
||||
"concurrently": "7.2.1",
|
||||
"postcss-selector-parser": "^6.0.13",
|
||||
"postcss-value-parser": "^4.2.0",
|
||||
|
||||
@@ -1,5 +1,54 @@
|
||||
# @navikt/aksel
|
||||
|
||||
## 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.3.6",
|
||||
"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.3.6",
|
||||
"axios": "1.6.0",
|
||||
"chalk": "4.1.0",
|
||||
"clipboardy": "^2.3.0",
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# @navikt/ds-css
|
||||
|
||||
## 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
|
||||
|
||||
@@ -120,6 +120,11 @@ const StyleMappings = {
|
||||
dependencies: [typoCss],
|
||||
},
|
||||
{ component: "Fieldset", main: formCss, dependencies: [typoCss] },
|
||||
{
|
||||
component: "FileUpload",
|
||||
main: formCss,
|
||||
dependencies: [typoCss, "button.css", "loader.css", "link.css"],
|
||||
},
|
||||
{ component: "Grid", main: "grid.css", dependencies: [typoCss] },
|
||||
{
|
||||
component: "GuidePanel",
|
||||
|
||||
@@ -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-xlarge);
|
||||
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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-css",
|
||||
"version": "6.2.0",
|
||||
"version": "6.3.6",
|
||||
"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.3.6",
|
||||
"cssnano": "6.0.0",
|
||||
"fast-glob": "3.2.11",
|
||||
"lodash": "4.17.21",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,5 +1,83 @@
|
||||
# @navikt/ds-react
|
||||
|
||||
## 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.3.6",
|
||||
"description": "React components from the Norwegian Labour and Welfare Administration.",
|
||||
"author": "Aksel, a team part of the Norwegian Labour and Welfare Administration.",
|
||||
"license": "MIT",
|
||||
@@ -539,6 +539,16 @@
|
||||
"default": "./cjs/form/textfield/index.js"
|
||||
}
|
||||
},
|
||||
"./FileUpload": {
|
||||
"import": {
|
||||
"types": "./esm/form/file-upload/index.d.ts",
|
||||
"default": "./esm/form/file-upload/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./cjs/form/file-upload/index.d.ts",
|
||||
"default": "./cjs/form/file-upload/index.js"
|
||||
}
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -552,8 +562,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "0.25.4",
|
||||
"@navikt/aksel-icons": "^6.2.0",
|
||||
"@navikt/ds-tokens": "^6.2.0",
|
||||
"@navikt/aksel-icons": "^6.3.6",
|
||||
"@navikt/ds-tokens": "^6.3.6",
|
||||
"@radix-ui/react-tabs": "1.0.0",
|
||||
"@radix-ui/react-toggle-group": "1.0.0",
|
||||
"clsx": "^2.1.0",
|
||||
|
||||
@@ -160,6 +160,7 @@ export const UseDatepicker = () => {
|
||||
fromDate: new Date("Aug 23 2019"),
|
||||
toDate: new Date("Feb 23 2024"),
|
||||
onDateChange: console.log,
|
||||
onValidate: console.log,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -20,6 +20,7 @@ const DayButton = (props: DayProps) => {
|
||||
name="day"
|
||||
ref={buttonRef}
|
||||
{...dayRender.buttonProps}
|
||||
role={undefined}
|
||||
aria-label={dateTime}
|
||||
aria-hidden={dayRender.activeModifiers.outside}
|
||||
/>
|
||||
|
||||
@@ -272,7 +272,7 @@ export const useDatepicker = (
|
||||
) {
|
||||
updateDate(undefined);
|
||||
updateValidation({
|
||||
isInvalid: isValidDate(day),
|
||||
isInvalid: !isValidDate(day),
|
||||
isWeekend: disableWeekends && isWeekend(day),
|
||||
isDisabled: disabled && isMatch(day, disabled),
|
||||
isValidDate: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta } from "@storybook/react";
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "../button";
|
||||
import Dropdown from "./Dropdown";
|
||||
@@ -6,10 +6,15 @@ import Dropdown from "./Dropdown";
|
||||
export default {
|
||||
title: "ds-react/Dropdown",
|
||||
component: Dropdown,
|
||||
} as Meta;
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof Dropdown>;
|
||||
|
||||
export const Default = () => {
|
||||
return (
|
||||
type Story = StoryObj<typeof Dropdown>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Dropdown onSelect={(event) => console.log(event)}>
|
||||
<Dropdown.Toggle>Toggle</Dropdown.Toggle>
|
||||
<Dropdown.Menu strategy="fixed">
|
||||
@@ -33,10 +38,10 @@ export const Default = () => {
|
||||
</Dropdown.Menu.List>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
),
|
||||
};
|
||||
|
||||
export const DefaultOpen = {
|
||||
export const DefaultOpen: Story = {
|
||||
render: () => (
|
||||
<Dropdown onSelect={(event) => console.log(event)} defaultOpen>
|
||||
<Button as={Dropdown.Toggle}>Toggle</Button>
|
||||
@@ -57,20 +62,41 @@ export const DefaultOpen = {
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
),
|
||||
args: { chromatic: { delay: 300 } },
|
||||
};
|
||||
|
||||
export const ControlledOpen = () => {
|
||||
const [openState, setOpenState] = useState(true);
|
||||
return (
|
||||
<Dropdown onSelect={(event) => console.log(event)} open={openState}>
|
||||
<Button as={Dropdown.Toggle} onClick={() => setOpenState(!openState)}>
|
||||
Toggle
|
||||
</Button>
|
||||
<Dropdown.Menu
|
||||
strategy="fixed"
|
||||
onClose={() => console.log("ONCLOSE CONTROLLED")}
|
||||
>
|
||||
export const ControlledOpen: Story = {
|
||||
render: () => {
|
||||
const [openState, setOpenState] = useState(true);
|
||||
return (
|
||||
<Dropdown onSelect={(event) => console.log(event)} open={openState}>
|
||||
<Button as={Dropdown.Toggle} onClick={() => setOpenState(!openState)}>
|
||||
Toggle
|
||||
</Button>
|
||||
<Dropdown.Menu
|
||||
strategy="fixed"
|
||||
onClose={() => console.log("ONCLOSE CONTROLLED")}
|
||||
>
|
||||
<Dropdown.Menu.GroupedList>
|
||||
<Dropdown.Menu.GroupedList.Heading>
|
||||
Systemer og oppslagsverk
|
||||
</Dropdown.Menu.GroupedList.Heading>
|
||||
<Dropdown.Menu.GroupedList.Item
|
||||
onClick={() => console.log("GroupedList.Item-click")}
|
||||
>
|
||||
Gosys
|
||||
</Dropdown.Menu.GroupedList.Item>
|
||||
</Dropdown.Menu.GroupedList>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Chromatic: Story = {
|
||||
render: () => (
|
||||
<Dropdown onSelect={(event) => console.log(event)} open>
|
||||
<Dropdown.Toggle>Toggle</Dropdown.Toggle>
|
||||
<Dropdown.Menu strategy="fixed">
|
||||
<Dropdown.Menu.GroupedList>
|
||||
<Dropdown.Menu.GroupedList.Heading>
|
||||
Systemer og oppslagsverk
|
||||
@@ -81,8 +107,18 @@ export const ControlledOpen = () => {
|
||||
Gosys
|
||||
</Dropdown.Menu.GroupedList.Item>
|
||||
</Dropdown.Menu.GroupedList>
|
||||
<Dropdown.Menu.Divider />
|
||||
<Dropdown.Menu.List>
|
||||
<Dropdown.Menu.List.Item onClick={() => console.log("Item-click")}>
|
||||
Gosys
|
||||
</Dropdown.Menu.List.Item>
|
||||
<Dropdown.Menu.List.Item>Psys</Dropdown.Menu.List.Item>
|
||||
<Dropdown.Menu.List.Item disabled>Infotrygd</Dropdown.Menu.List.Item>
|
||||
</Dropdown.Menu.List>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
),
|
||||
parameters: {
|
||||
chromatic: { disable: false, delay: 300 },
|
||||
},
|
||||
};
|
||||
ControlledOpen.args = { chromatic: { delay: 300 } };
|
||||
@@ -1,5 +1,6 @@
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef, useRef, useState } from "react";
|
||||
import React, { forwardRef, useRef } from "react";
|
||||
import { useControllableState } from "../util/hooks";
|
||||
import { OverridableComponent } from "../util/types";
|
||||
import ExpansionCardContent, {
|
||||
ExpansionCardContentProps,
|
||||
@@ -116,24 +117,24 @@ export const ExpansionCard = forwardRef<HTMLDivElement, ExpansionCardProps>(
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [_open, _setOpen] = useState(defaultOpen);
|
||||
|
||||
const shouldFade = useRef<boolean>(!(Boolean(open) || defaultOpen));
|
||||
|
||||
const handleOpen = () => {
|
||||
if (open === undefined) {
|
||||
const newOpen = !_open;
|
||||
_setOpen(newOpen);
|
||||
onToggle?.(newOpen);
|
||||
} else {
|
||||
onToggle?.(!open);
|
||||
}
|
||||
shouldFade.current = true;
|
||||
};
|
||||
const [_open, _setOpen] = useControllableState({
|
||||
value: open,
|
||||
onChange: (newValue) => {
|
||||
onToggle?.(newValue);
|
||||
shouldFade.current = true;
|
||||
},
|
||||
defaultValue: defaultOpen,
|
||||
});
|
||||
|
||||
return (
|
||||
<ExpansionCardContext.Provider
|
||||
value={{ open: open ?? _open, toggleOpen: handleOpen, size }}
|
||||
value={{
|
||||
open: open ?? _open,
|
||||
toggleOpen: () => _setOpen((x) => !x),
|
||||
size,
|
||||
}}
|
||||
>
|
||||
<section
|
||||
{...rest}
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface CheckboxProps
|
||||
*/
|
||||
indeterminate?: boolean;
|
||||
/**
|
||||
* Adds a description to extend labling of Checkbox.
|
||||
* Adds a description to extend labeling of Checkbox.
|
||||
*/
|
||||
description?: string;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import Combobox from "./Combobox";
|
||||
import { FilteredOptionsProvider } from "./FilteredOptions/filteredOptionsContext";
|
||||
import { InputContextProvider } from "./Input/inputContext";
|
||||
import { SelectedOptionsProvider } from "./SelectedOptions/selectedOptionsContext";
|
||||
import { mapToComboboxOptionArray } from "./combobox-utils";
|
||||
import { CustomOptionsProvider } from "./customOptionsContext";
|
||||
import { ComboboxProps } from "./types";
|
||||
|
||||
@@ -36,15 +37,15 @@ const ComboboxProvider = forwardRef<HTMLInputElement, ComboboxProps>(
|
||||
defaultValue,
|
||||
error,
|
||||
errorId,
|
||||
filteredOptions,
|
||||
filteredOptions: externalFilteredOptions,
|
||||
id,
|
||||
isListOpen,
|
||||
isLoading = false,
|
||||
isMultiSelect,
|
||||
onToggleSelected,
|
||||
selectedOptions,
|
||||
selectedOptions: externalSelectedOptions,
|
||||
maxSelected,
|
||||
options,
|
||||
options: externalOptions,
|
||||
value,
|
||||
onChange,
|
||||
onClear,
|
||||
@@ -52,6 +53,9 @@ const ComboboxProvider = forwardRef<HTMLInputElement, ComboboxProps>(
|
||||
size,
|
||||
...rest
|
||||
} = props;
|
||||
const options = mapToComboboxOptionArray(externalOptions) || [];
|
||||
const filteredOptions = mapToComboboxOptionArray(externalFilteredOptions);
|
||||
const selectedOptions = mapToComboboxOptionArray(externalSelectedOptions);
|
||||
return (
|
||||
<InputContextProvider
|
||||
value={{
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Loader } from "../../../loader";
|
||||
import { BodyShort, Label } from "../../../typography";
|
||||
import { useInputContext } from "../Input/inputContext";
|
||||
import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
|
||||
import { isInList, toComboboxOption } from "../combobox-utils";
|
||||
import { ComboboxOption } from "../types";
|
||||
import filteredOptionsUtil from "./filtered-options-util";
|
||||
import { useFilteredOptionsContext } from "./filteredOptionsContext";
|
||||
|
||||
@@ -30,8 +32,8 @@ const FilteredOptions = () => {
|
||||
const { isMultiSelect, selectedOptions, toggleOption, maxSelected } =
|
||||
useSelectedOptionsContext();
|
||||
|
||||
const isDisabled = (option) =>
|
||||
maxSelected?.isLimitReached && !selectedOptions.includes(option);
|
||||
const isDisabled = (option: ComboboxOption) =>
|
||||
maxSelected?.isLimitReached && !isInList(option.value, selectedOptions);
|
||||
|
||||
const shouldRenderNonSelectables =
|
||||
maxSelected?.isLimitReached || // Render maxSelected message
|
||||
@@ -102,8 +104,8 @@ const FilteredOptions = () => {
|
||||
}
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
toggleOption(value, event);
|
||||
if (!isMultiSelect && !selectedOptions.includes(value))
|
||||
toggleOption(toComboboxOption(value), event);
|
||||
if (!isMultiSelect && !isInList(value, selectedOptions))
|
||||
toggleIsListOpen(false);
|
||||
}}
|
||||
id={filteredOptionsUtil.getAddNewOptionId(id)}
|
||||
@@ -132,21 +134,23 @@ const FilteredOptions = () => {
|
||||
className={cl("navds-combobox__list-item", {
|
||||
"navds-combobox__list-item--focus":
|
||||
activeDecendantId ===
|
||||
filteredOptionsUtil.getOptionId(id, option),
|
||||
"navds-combobox__list-item--selected":
|
||||
selectedOptions.includes(option),
|
||||
filteredOptionsUtil.getOptionId(id, option.label),
|
||||
"navds-combobox__list-item--selected": isInList(
|
||||
option.value,
|
||||
selectedOptions,
|
||||
),
|
||||
})}
|
||||
data-no-focus={isDisabled(option) || undefined}
|
||||
id={filteredOptionsUtil.getOptionId(id, option)}
|
||||
key={option}
|
||||
id={filteredOptionsUtil.getOptionId(id, option.label)}
|
||||
key={option.label}
|
||||
tabIndex={-1}
|
||||
onMouseMove={() => {
|
||||
if (
|
||||
activeDecendantId !==
|
||||
filteredOptionsUtil.getOptionId(id, option)
|
||||
filteredOptionsUtil.getOptionId(id, option.label)
|
||||
) {
|
||||
virtualFocus.moveFocusToElement(
|
||||
filteredOptionsUtil.getOptionId(id, option),
|
||||
filteredOptionsUtil.getOptionId(id, option.label),
|
||||
);
|
||||
setIsMouseLastUsedInputDevice(true);
|
||||
}
|
||||
@@ -156,16 +160,19 @@ const FilteredOptions = () => {
|
||||
return;
|
||||
}
|
||||
toggleOption(option, event);
|
||||
if (!isMultiSelect && !selectedOptions.includes(option)) {
|
||||
if (
|
||||
!isMultiSelect &&
|
||||
!isInList(option.value, selectedOptions)
|
||||
) {
|
||||
toggleIsListOpen(false);
|
||||
}
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={selectedOptions.includes(option)}
|
||||
aria-selected={isInList(option.value, selectedOptions)}
|
||||
aria-disabled={isDisabled(option) || undefined}
|
||||
>
|
||||
<BodyShort size={size}>{option}</BodyShort>
|
||||
{selectedOptions.includes(option) && <CheckmarkIcon />}
|
||||
<BodyShort size={size}>{option.label}</BodyShort>
|
||||
{isInList(option.value, selectedOptions) && <CheckmarkIcon />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { ComboboxOption } from "../types";
|
||||
|
||||
const normalizeText = (text: string): string =>
|
||||
typeof text === "string" ? text.toLocaleLowerCase().trim() : "";
|
||||
|
||||
const isPartOfText = (value, text) =>
|
||||
const isPartOfText = (value: string, text: string) =>
|
||||
normalizeText(text).startsWith(normalizeText(value ?? ""));
|
||||
|
||||
const isValueInList = (value, list) =>
|
||||
list?.find((listItem) => normalizeText(value) === normalizeText(listItem));
|
||||
|
||||
const getMatchingValuesFromList = (value, list, alwaysIncluded) =>
|
||||
list?.filter(
|
||||
(listItem) =>
|
||||
isPartOfText(value, listItem) || alwaysIncluded.includes(listItem),
|
||||
);
|
||||
const getMatchingValuesFromList = (value: string, list: ComboboxOption[]) =>
|
||||
list.filter((listItem) => isPartOfText(value, listItem.label));
|
||||
|
||||
const getFilteredOptionsId = (comboboxId: string) =>
|
||||
`${comboboxId}-filtered-options`;
|
||||
@@ -34,7 +30,6 @@ const getMaxSelectedOptionsId = (comboboxId: string) =>
|
||||
export default {
|
||||
normalizeText,
|
||||
isPartOfText,
|
||||
isValueInList,
|
||||
getMatchingValuesFromList,
|
||||
getFilteredOptionsId,
|
||||
getAddNewOptionId,
|
||||
|
||||
@@ -10,21 +10,18 @@ import React, {
|
||||
import { useClientLayoutEffect, usePrevious } from "../../../util/hooks";
|
||||
import { useInputContext } from "../Input/inputContext";
|
||||
import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
|
||||
import { toComboboxOption } from "../combobox-utils";
|
||||
import { useCustomOptionsContext } from "../customOptionsContext";
|
||||
import { ComboboxProps } from "../types";
|
||||
import { ComboboxOption, ComboboxProps } from "../types";
|
||||
import filteredOptionsUtils from "./filtered-options-util";
|
||||
import useVirtualFocus, { VirtualFocusType } from "./useVirtualFocus";
|
||||
|
||||
type FilteredOptionsProps = {
|
||||
children: any;
|
||||
value: Pick<
|
||||
ComboboxProps,
|
||||
| "allowNewValues"
|
||||
| "filteredOptions"
|
||||
| "isListOpen"
|
||||
| "isLoading"
|
||||
| "options"
|
||||
>;
|
||||
children: React.ReactNode;
|
||||
value: Pick<ComboboxProps, "allowNewValues" | "isListOpen" | "isLoading"> & {
|
||||
filteredOptions?: ComboboxOption[];
|
||||
options: ComboboxOption[];
|
||||
};
|
||||
};
|
||||
|
||||
type FilteredOptionsContextType = {
|
||||
@@ -36,12 +33,12 @@ type FilteredOptionsContextType = {
|
||||
>;
|
||||
isListOpen: boolean;
|
||||
isLoading?: boolean;
|
||||
filteredOptions: string[];
|
||||
filteredOptions: ComboboxOption[];
|
||||
isMouseLastUsedInputDevice: boolean;
|
||||
setIsMouseLastUsedInputDevice: React.Dispatch<SetStateAction<boolean>>;
|
||||
isValueNew: boolean;
|
||||
toggleIsListOpen: (newState?: boolean) => void;
|
||||
currentOption?: string;
|
||||
currentOption?: ComboboxOption;
|
||||
shouldAutocomplete?: boolean;
|
||||
virtualFocus: VirtualFocusType;
|
||||
};
|
||||
@@ -71,7 +68,7 @@ export const FilteredOptionsProvider = ({
|
||||
setSearchTerm,
|
||||
shouldAutocomplete,
|
||||
} = useInputContext();
|
||||
const { selectedOptions, maxSelected } = useSelectedOptionsContext();
|
||||
const { maxSelected } = useSelectedOptionsContext();
|
||||
|
||||
const [isInternalListOpen, setInternalListOpen] = useState(false);
|
||||
const { customOptions } = useCustomOptionsContext();
|
||||
@@ -81,18 +78,8 @@ export const FilteredOptionsProvider = ({
|
||||
return externalFilteredOptions;
|
||||
}
|
||||
const opts = [...customOptions, ...options];
|
||||
return filteredOptionsUtils.getMatchingValuesFromList(
|
||||
searchTerm,
|
||||
opts,
|
||||
selectedOptions,
|
||||
);
|
||||
}, [
|
||||
customOptions,
|
||||
externalFilteredOptions,
|
||||
options,
|
||||
searchTerm,
|
||||
selectedOptions,
|
||||
]);
|
||||
return filteredOptionsUtils.getMatchingValuesFromList(searchTerm, opts);
|
||||
}, [customOptions, externalFilteredOptions, options, searchTerm]);
|
||||
|
||||
const previousSearchTerm = usePrevious(searchTerm);
|
||||
|
||||
@@ -104,11 +91,11 @@ export const FilteredOptionsProvider = ({
|
||||
options.reduce(
|
||||
(map, _option) => ({
|
||||
...map,
|
||||
[filteredOptionsUtils.getOptionId(id, _option)]: _option,
|
||||
[filteredOptionsUtils.getOptionId(id, _option.label)]: _option,
|
||||
}),
|
||||
{
|
||||
[filteredOptionsUtils.getAddNewOptionId(id)]: allowNewValues
|
||||
? value
|
||||
? toComboboxOption(value)
|
||||
: undefined,
|
||||
},
|
||||
),
|
||||
@@ -123,7 +110,7 @@ export const FilteredOptionsProvider = ({
|
||||
filteredOptions.length > 0
|
||||
) {
|
||||
setValue(
|
||||
`${searchTerm}${filteredOptions[0].substring(searchTerm.length)}`,
|
||||
`${searchTerm}${filteredOptions[0].label.substring(searchTerm.length)}`,
|
||||
);
|
||||
setSearchTerm(searchTerm);
|
||||
}
|
||||
@@ -161,7 +148,10 @@ export const FilteredOptionsProvider = ({
|
||||
activeOption = filteredOptionsUtils.getNoHitsId(id);
|
||||
} else if ((value && value !== "") || isLoading) {
|
||||
if (shouldAutocomplete && filteredOptions[0]) {
|
||||
activeOption = filteredOptionsUtils.getOptionId(id, filteredOptions[0]);
|
||||
activeOption = filteredOptionsUtils.getOptionId(
|
||||
id,
|
||||
filteredOptions[0].label,
|
||||
);
|
||||
} else if (isListOpen && isLoading) {
|
||||
activeOption = filteredOptionsUtils.getIsLoadingId(id);
|
||||
}
|
||||
|
||||
@@ -43,17 +43,17 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
|
||||
const onEnter = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
const isTextInSelectedOptions = (text: string) => {
|
||||
return selectedOptions.find(
|
||||
(item) => item.toLocaleLowerCase() === text.toLocaleLowerCase(),
|
||||
const isTextInSelectedOptions = (text: string) =>
|
||||
selectedOptions.some(
|
||||
(option) =>
|
||||
option.label.toLocaleLowerCase() === text.toLocaleLowerCase(),
|
||||
);
|
||||
};
|
||||
|
||||
if (currentOption) {
|
||||
event.preventDefault();
|
||||
// Selecting a value from the dropdown / FilteredOptions
|
||||
toggleOption(currentOption, event);
|
||||
if (!isMultiSelect && !isTextInSelectedOptions(currentOption)) {
|
||||
if (!isMultiSelect && !isTextInSelectedOptions(currentOption.label)) {
|
||||
toggleIsListOpen(false);
|
||||
}
|
||||
} else if (shouldAutocomplete && isTextInSelectedOptions(value)) {
|
||||
@@ -64,11 +64,15 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
event.preventDefault();
|
||||
// Autocompleting or adding a new value
|
||||
const selectedValue =
|
||||
allowNewValues && isValueNew ? value : filteredOptions[0];
|
||||
allowNewValues && isValueNew
|
||||
? { label: value, value }
|
||||
: filteredOptions[0];
|
||||
toggleOption(selectedValue, event);
|
||||
if (
|
||||
!isMultiSelect &&
|
||||
!isTextInSelectedOptions(filteredOptions[0] || selectedValue)
|
||||
!isTextInSelectedOptions(
|
||||
filteredOptions[0].label || selectedValue.label,
|
||||
)
|
||||
) {
|
||||
toggleIsListOpen(false);
|
||||
}
|
||||
@@ -120,7 +124,9 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
if (value === "") {
|
||||
const lastSelectedOption =
|
||||
selectedOptions[selectedOptions.length - 1];
|
||||
removeSelectedOption(lastSelectedOption);
|
||||
if (lastSelectedOption) {
|
||||
removeSelectedOption(lastSelectedOption);
|
||||
}
|
||||
}
|
||||
} else if (e.key === "ArrowDown") {
|
||||
// Check that cursor position is at the end of the input field,
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import React from "react";
|
||||
import { Chips } from "../../../chips";
|
||||
import { useInputContext } from "../Input/inputContext";
|
||||
import { ComboboxOption } from "../types";
|
||||
import { useSelectedOptionsContext } from "./selectedOptionsContext";
|
||||
|
||||
interface SelectedOptionsProps {
|
||||
selectedOptions?: string[];
|
||||
selectedOptions?: ComboboxOption[];
|
||||
size?: "medium" | "small";
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Option = ({ option }: { option: string }) => {
|
||||
const Option = ({ option }: { option: ComboboxOption }) => {
|
||||
const { isMultiSelect, removeSelectedOption } = useSelectedOptionsContext();
|
||||
const { focusInput } = useInputContext();
|
||||
|
||||
@@ -21,11 +22,13 @@ const Option = ({ option }: { option: string }) => {
|
||||
|
||||
if (!isMultiSelect) {
|
||||
return (
|
||||
<div className="navds-combobox__selected-options--no-bg">{option}</div>
|
||||
<div className="navds-combobox__selected-options--no-bg">
|
||||
{option.label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Chips.Removable onClick={onClick}>{option}</Chips.Removable>;
|
||||
return <Chips.Removable onClick={onClick}>{option.label}</Chips.Removable>;
|
||||
};
|
||||
|
||||
const SelectedOptions: React.FC<SelectedOptionsProps> = ({
|
||||
@@ -37,7 +40,7 @@ const SelectedOptions: React.FC<SelectedOptionsProps> = ({
|
||||
<Chips className="navds-combobox__selected-options" size={size}>
|
||||
{selectedOptions.length
|
||||
? selectedOptions.map((option, i) => (
|
||||
<Option key={option + i} option={option} />
|
||||
<Option key={option.label + i} option={option} />
|
||||
))
|
||||
: []}
|
||||
{children}
|
||||
|
||||
@@ -7,19 +7,20 @@ import React, {
|
||||
} from "react";
|
||||
import { usePrevious } from "../../../util/hooks";
|
||||
import { useInputContext } from "../Input/inputContext";
|
||||
import { isInList } from "../combobox-utils";
|
||||
import { useCustomOptionsContext } from "../customOptionsContext";
|
||||
import { ComboboxProps, MaxSelected } from "../types";
|
||||
import { ComboboxOption, ComboboxProps, MaxSelected } from "../types";
|
||||
|
||||
type SelectedOptionsContextType = {
|
||||
addSelectedOption: (option: string) => void;
|
||||
addSelectedOption: (option: ComboboxOption) => void;
|
||||
isMultiSelect?: boolean;
|
||||
removeSelectedOption: (option: string) => void;
|
||||
prevSelectedOptions?: string[];
|
||||
selectedOptions: string[];
|
||||
removeSelectedOption: (option: ComboboxOption) => void;
|
||||
prevSelectedOptions?: ComboboxOption[];
|
||||
selectedOptions: ComboboxOption[];
|
||||
maxSelected?: MaxSelected & { isLimitReached: boolean };
|
||||
setSelectedOptions: (any) => void;
|
||||
toggleOption: (
|
||||
option: string,
|
||||
option: ComboboxOption,
|
||||
event: React.KeyboardEvent | React.PointerEvent,
|
||||
) => void;
|
||||
};
|
||||
@@ -35,13 +36,8 @@ export const SelectedOptionsProvider = ({
|
||||
children: any;
|
||||
value: Pick<
|
||||
ComboboxProps,
|
||||
| "allowNewValues"
|
||||
| "isMultiSelect"
|
||||
| "options"
|
||||
| "selectedOptions"
|
||||
| "onToggleSelected"
|
||||
| "maxSelected"
|
||||
>;
|
||||
"allowNewValues" | "isMultiSelect" | "onToggleSelected" | "maxSelected"
|
||||
> & { options: ComboboxOption[]; selectedOptions?: ComboboxOption[] };
|
||||
}) => {
|
||||
const { clearInput, focusInput } = useInputContext();
|
||||
const {
|
||||
@@ -58,7 +54,9 @@ export const SelectedOptionsProvider = ({
|
||||
options,
|
||||
maxSelected,
|
||||
} = value;
|
||||
const [internalSelectedOptions, setSelectedOptions] = useState<string[]>([]);
|
||||
const [internalSelectedOptions, setSelectedOptions] = useState<
|
||||
ComboboxOption[]
|
||||
>([]);
|
||||
const selectedOptions = useMemo(
|
||||
() =>
|
||||
externalSelectedOptions ?? [...customOptions, ...internalSelectedOptions],
|
||||
@@ -66,10 +64,8 @@ export const SelectedOptionsProvider = ({
|
||||
);
|
||||
|
||||
const addSelectedOption = useCallback(
|
||||
(option: string) => {
|
||||
const isCustomOption = !options
|
||||
.map((opt) => opt.toLowerCase())
|
||||
.includes(option?.toLowerCase?.());
|
||||
(option: ComboboxOption) => {
|
||||
const isCustomOption = !isInList(option, options);
|
||||
if (isCustomOption) {
|
||||
allowNewValues && addCustomOption(option);
|
||||
!isMultiSelect && setSelectedOptions([]);
|
||||
@@ -82,7 +78,7 @@ export const SelectedOptionsProvider = ({
|
||||
setSelectedOptions([option]);
|
||||
setCustomOptions([]);
|
||||
}
|
||||
onToggleSelected?.(option, true, isCustomOption);
|
||||
onToggleSelected?.(option.value, true, isCustomOption);
|
||||
},
|
||||
[
|
||||
addCustomOption,
|
||||
@@ -95,8 +91,8 @@ export const SelectedOptionsProvider = ({
|
||||
);
|
||||
|
||||
const removeSelectedOption = useCallback(
|
||||
(option: string) => {
|
||||
const isCustomOption = customOptions.includes(option);
|
||||
(option: ComboboxOption) => {
|
||||
const isCustomOption = isInList(option, customOptions);
|
||||
if (isCustomOption) {
|
||||
removeCustomOption(option);
|
||||
} else {
|
||||
@@ -106,14 +102,17 @@ export const SelectedOptionsProvider = ({
|
||||
),
|
||||
);
|
||||
}
|
||||
onToggleSelected?.(option, false, isCustomOption);
|
||||
onToggleSelected?.(option.value, false, isCustomOption);
|
||||
},
|
||||
[customOptions, onToggleSelected, removeCustomOption],
|
||||
);
|
||||
|
||||
const toggleOption = useCallback(
|
||||
(option: string, event: React.KeyboardEvent | React.PointerEvent) => {
|
||||
if (selectedOptions.includes(option)) {
|
||||
(
|
||||
option: ComboboxOption,
|
||||
event: React.KeyboardEvent | React.PointerEvent,
|
||||
) => {
|
||||
if (isInList(option.value, selectedOptions)) {
|
||||
removeSelectedOption(option);
|
||||
} else {
|
||||
addSelectedOption(option);
|
||||
@@ -130,7 +129,7 @@ export const SelectedOptionsProvider = ({
|
||||
],
|
||||
);
|
||||
|
||||
const prevSelectedOptions = usePrevious<string[]>(selectedOptions);
|
||||
const prevSelectedOptions = usePrevious<ComboboxOption[]>(selectedOptions);
|
||||
|
||||
const isLimitReached =
|
||||
!!maxSelected?.limit && selectedOptions.length >= maxSelected.limit;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
isInList,
|
||||
mapToComboboxOptionArray,
|
||||
toComboboxOption,
|
||||
} from "./combobox-utils";
|
||||
|
||||
const list = [
|
||||
{ label: "Hjelpemidler", value: "HJE" },
|
||||
{ label: "Oppfølging", value: "OPP" },
|
||||
{ label: "Sykepenger", value: "SYK" },
|
||||
{ label: "Sykemelding", value: "SYM" },
|
||||
];
|
||||
|
||||
describe("isInList", () => {
|
||||
test("finds a string value in a list of ComboboxOptions", () => {
|
||||
expect(isInList("Oppfølging", list)).toBe(true);
|
||||
expect(isInList("SYM", list)).toBe(true);
|
||||
expect(isInList("Arbeidsavklaringspenger", list)).toBe(false);
|
||||
expect(isInList("AAP", list)).toBe(false);
|
||||
});
|
||||
|
||||
test("finds a ComboboxOption in a list of ComboboxOptions", () => {
|
||||
expect(isInList({ label: "Oppfølging", value: "OPP" }, list)).toBe(true);
|
||||
expect(isInList({ label: "Sykemelding", value: "SYM" }, list)).toBe(true);
|
||||
expect(
|
||||
isInList({ label: "Arbeidsavklaringspenger", value: "AAP" }, list),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isInList({ label: "Arbeidsavklaringspenger", value: "AAP" }, list),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for ComboboxOptions which do not match both label and value", () => {
|
||||
expect(isInList({ label: "Oppfølging", value: "SYM" }, list)).toBe(false);
|
||||
expect(isInList({ label: "Sykemelding", value: "OPP" }, list)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapToComboboxOptionArray", () => {
|
||||
test("maps an array of strings to an array of ComboboxOptions", () => {
|
||||
const stringArray = ["Hjelpemidler", "Oppfølging", "Sykepenger"];
|
||||
const comboboxOptions = [
|
||||
{ label: "Hjelpemidler", value: "Hjelpemidler" },
|
||||
{ label: "Oppfølging", value: "Oppfølging" },
|
||||
{ label: "Sykepenger", value: "Sykepenger" },
|
||||
];
|
||||
expect(mapToComboboxOptionArray(stringArray)).toEqual(comboboxOptions);
|
||||
});
|
||||
|
||||
test("does not change an array of ComboboxOptions", () => {
|
||||
const comboboxOptions = [
|
||||
{ label: "Hjelpemidler", value: "Hjelpemidler" },
|
||||
{ label: "Oppfølging", value: "Oppfølging" },
|
||||
{ label: "Sykepenger", value: "Sykepenger" },
|
||||
];
|
||||
expect(mapToComboboxOptionArray(comboboxOptions)).toEqual(comboboxOptions);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toComboboxOption", () => {
|
||||
test("creates a ComboboxOption from a string", () => {
|
||||
expect(toComboboxOption("Hjelpemidler")).toEqual({
|
||||
label: "Hjelpemidler",
|
||||
value: "Hjelpemidler",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ComboboxOption } from "./types";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param option ComboboxOption will be compared by both label and value, while a string option is checked if it matches either the label or value in the list.
|
||||
* @param list
|
||||
*/
|
||||
const isInList = (option: ComboboxOption | string, list: ComboboxOption[]) => {
|
||||
if (typeof option === "string") {
|
||||
return list.some(
|
||||
(listItem) => listItem.label === option || listItem.value === option,
|
||||
);
|
||||
} else {
|
||||
return list.some(
|
||||
(listItem) =>
|
||||
listItem.label === option.label && listItem.value === option.value,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const toComboboxOption = (value: string): ComboboxOption => ({
|
||||
label: value,
|
||||
value,
|
||||
});
|
||||
|
||||
const mapToComboboxOptionArray = (options?: string[] | ComboboxOption[]) => {
|
||||
return options?.map((option: string | ComboboxOption) =>
|
||||
typeof option === "string" ? toComboboxOption(option) : option,
|
||||
);
|
||||
};
|
||||
|
||||
export { isInList, mapToComboboxOptionArray, toComboboxOption };
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Meta, StoryFn, StoryObj } from "@storybook/react";
|
||||
import { expect, fn, userEvent, within } from "@storybook/test";
|
||||
import React, { useId, useMemo, useRef, useState } from "react";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { Chips } from "../../chips";
|
||||
import { TextField } from "../textfield";
|
||||
import { ComboboxProps, UNSAFE_Combobox } from "./index";
|
||||
@@ -30,10 +30,10 @@ const options = [
|
||||
"grape fruit",
|
||||
];
|
||||
|
||||
export const Default: StoryFunction = (props) => {
|
||||
const id = useId();
|
||||
return <UNSAFE_Combobox {...props} id={id} />;
|
||||
};
|
||||
export const Default: StoryFunction = (props) => (
|
||||
<UNSAFE_Combobox {...props} id="combobox" />
|
||||
);
|
||||
|
||||
Default.args = {
|
||||
options,
|
||||
label: "Hva er dine favorittfrukter?",
|
||||
@@ -57,10 +57,9 @@ Default.argTypes = {
|
||||
};
|
||||
|
||||
export const MultiSelect: StoryFunction = (props) => {
|
||||
const id = useId();
|
||||
return (
|
||||
<UNSAFE_Combobox
|
||||
id={id}
|
||||
id="combobox-with-multiselect"
|
||||
label="Komboboks - velg flere"
|
||||
options={props.options}
|
||||
isMultiSelect={props.isMultiSelect}
|
||||
@@ -75,11 +74,55 @@ MultiSelect.args = {
|
||||
size: "medium",
|
||||
};
|
||||
|
||||
export const MultiSelectWithComplexOptions: StoryFunction = (props) => {
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
|
||||
return (
|
||||
<>
|
||||
<UNSAFE_Combobox
|
||||
{...props}
|
||||
options={props.options.map((option) => ({
|
||||
...option,
|
||||
label: `${option.label} [${option.value}]`,
|
||||
}))}
|
||||
id="combobox-with-complex-options"
|
||||
label="Velg temakoder"
|
||||
allowNewValues
|
||||
onToggleSelected={(value, isSelected) =>
|
||||
isSelected
|
||||
? setSelectedOptions([...selectedOptions, value])
|
||||
: setSelectedOptions(selectedOptions.filter((o) => o !== value))
|
||||
}
|
||||
selectedOptions={selectedOptions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
MultiSelectWithComplexOptions.args = {
|
||||
options: [
|
||||
{ label: "Hjelpemidler", value: "HJE" },
|
||||
{ label: "Oppfølging", value: "OPP" },
|
||||
{ label: "Sykepenger", value: "SYK" },
|
||||
{ label: "Sykemelding", value: "SYM" },
|
||||
{ label: "Foreldre- og svangerskapspenger", value: "FOR" },
|
||||
{ label: "Arbeidsavklaringspenger", value: "AAP" },
|
||||
{ label: "Uføretrygd", value: "UFO" },
|
||||
{ label: "Pensjon", value: "PEN" },
|
||||
{ label: "Barnetrygd", value: "BAR" },
|
||||
{ label: "Kontantstøtte", value: "KON" },
|
||||
{ label: "Bostøtte", value: "BOS" },
|
||||
{ label: "Barnebidrag", value: "BBI" },
|
||||
{ label: "Bidragsforskudd", value: "BIF" },
|
||||
{ label: "Grunn- og hjelpestønad", value: "GRU" },
|
||||
],
|
||||
isMultiSelect: true,
|
||||
size: "medium",
|
||||
};
|
||||
|
||||
export const WithAddNewOptions: StoryFunction = (props) => {
|
||||
const id = useId();
|
||||
return (
|
||||
<UNSAFE_Combobox
|
||||
id={id}
|
||||
id="combobox-with-add-new-options"
|
||||
label="Komboboks med mulighet for å legge til nye verdier"
|
||||
options={props.options}
|
||||
allowNewValues={props.allowNewValues}
|
||||
@@ -95,10 +138,9 @@ WithAddNewOptions.args = {
|
||||
};
|
||||
|
||||
export const MultiSelectWithAddNewOptions: StoryFunction = (props) => {
|
||||
const id = useId();
|
||||
return (
|
||||
<UNSAFE_Combobox
|
||||
id={id}
|
||||
id="combobox-with-multiselect-and-add-new-options"
|
||||
isMultiSelect={props.isMultiSelect}
|
||||
label="Multiselect komboboks med mulighet for å legge til nye verdier"
|
||||
options={props.options}
|
||||
@@ -120,7 +162,6 @@ export const MultiSelectWithExternalChips: StoryFn<{
|
||||
}> = (props) => {
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
|
||||
const [value, setValue] = useState("");
|
||||
const id = useId();
|
||||
|
||||
const toggleSelected = (option: string) =>
|
||||
selectedOptions.includes(option)
|
||||
@@ -142,6 +183,7 @@ export const MultiSelectWithExternalChips: StoryFn<{
|
||||
</Chips>
|
||||
)}
|
||||
<UNSAFE_Combobox
|
||||
id="combobox-with-external-chips"
|
||||
options={props.options}
|
||||
selectedOptions={selectedOptions}
|
||||
onToggleSelected={(option) => toggleSelected(option)}
|
||||
@@ -154,7 +196,6 @@ export const MultiSelectWithExternalChips: StoryFn<{
|
||||
}
|
||||
label="Komboboks"
|
||||
size="medium"
|
||||
id={id}
|
||||
shouldShowSelectedOptions={false}
|
||||
/>
|
||||
</>
|
||||
@@ -166,19 +207,16 @@ MultiSelectWithExternalChips.args = {
|
||||
options,
|
||||
};
|
||||
|
||||
export const Loading: StoryFunction = (props) => {
|
||||
const id = useId();
|
||||
return (
|
||||
<UNSAFE_Combobox
|
||||
id={id}
|
||||
label="Komboboks (laster)"
|
||||
options={[]}
|
||||
selectedOptions={[]}
|
||||
isListOpen={props.isListOpen}
|
||||
isLoading={props.isLoading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export const Loading: StoryFunction = (props) => (
|
||||
<UNSAFE_Combobox
|
||||
id="combobox-with-loading-indicator"
|
||||
label="Komboboks (laster)"
|
||||
options={[]}
|
||||
selectedOptions={[]}
|
||||
isListOpen={props.isListOpen}
|
||||
isLoading={props.isLoading}
|
||||
/>
|
||||
);
|
||||
|
||||
Loading.args = {
|
||||
isLoading: true,
|
||||
@@ -186,11 +224,10 @@ Loading.args = {
|
||||
};
|
||||
|
||||
export const ComboboxWithNoHits: StoryFunction = (props) => {
|
||||
const id = useId();
|
||||
const [value, setValue] = useState(props.value);
|
||||
return (
|
||||
<UNSAFE_Combobox
|
||||
id={id}
|
||||
id="combobox-with-no-hits"
|
||||
label="Komboboks (uten søketreff)"
|
||||
options={props.options}
|
||||
value={value}
|
||||
@@ -210,7 +247,6 @@ export const Controlled: StoryFn<{
|
||||
options: string[];
|
||||
initialSelectedOptions: string[];
|
||||
}> = (props) => {
|
||||
const id = useId();
|
||||
const [value, setValue] = useState(props.value);
|
||||
const [selectedOptions, setSelectedOptions] = useState(
|
||||
props.initialSelectedOptions,
|
||||
@@ -238,7 +274,7 @@ export const Controlled: StoryFn<{
|
||||
<br />
|
||||
<UNSAFE_Combobox
|
||||
label="Hva er dine favorittfrukter?"
|
||||
id={id}
|
||||
id="combobox-controlled"
|
||||
filteredOptions={filteredOptions}
|
||||
isMultiSelect
|
||||
options={props.options}
|
||||
@@ -292,7 +328,6 @@ export const ComboboxSizes = () => (
|
||||
);
|
||||
|
||||
export const MaxSelectedOptions: StoryFunction = () => {
|
||||
const id = useId();
|
||||
const [value, setValue] = useState<string | undefined>("");
|
||||
const [selectedOptions, setSelectedOptions] = useState([
|
||||
options[0],
|
||||
@@ -301,7 +336,7 @@ export const MaxSelectedOptions: StoryFunction = () => {
|
||||
const comboboxRef = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<UNSAFE_Combobox
|
||||
id={id}
|
||||
id="combobox-with-max-selected-options"
|
||||
label="Komboboks med begrenset antall valg"
|
||||
options={options}
|
||||
maxSelected={{ limit: 2 }}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React, { useId } from "react";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { UNSAFE_Combobox } from "./index";
|
||||
|
||||
const options = [
|
||||
@@ -123,4 +123,35 @@ describe("Combobox state-handling", () => {
|
||||
await screen.findByRole("option", { name: "banana" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("Should handle complex options with label and value", async () => {
|
||||
const onToggleSelected = vi.fn();
|
||||
render(
|
||||
<App
|
||||
options={[
|
||||
{ label: "Hjelpemidler [HJE]", value: "HJE" },
|
||||
{ label: "Oppfølging [OPP]", value: "OPP" },
|
||||
{ label: "Sykepenger [SYK]", value: "SYK" },
|
||||
{ label: "Sykemelding [SYM]", value: "SYM" },
|
||||
]}
|
||||
onToggleSelected={onToggleSelected}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
const bananaOption = screen.getByRole("option", {
|
||||
name: "Hjelpemidler [HJE]",
|
||||
selected: false,
|
||||
});
|
||||
await act(async () => {
|
||||
await userEvent.click(bananaOption);
|
||||
});
|
||||
expect(onToggleSelected).toHaveBeenCalledWith("HJE", true, false);
|
||||
expect(
|
||||
screen.getByRole("option", {
|
||||
name: "Hjelpemidler [HJE]",
|
||||
selected: true,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { createContext, useCallback, useContext, useState } from "react";
|
||||
import { useInputContext } from "./Input/inputContext";
|
||||
import { ComboboxOption } from "./types";
|
||||
|
||||
type CustomOptionsContextType = {
|
||||
customOptions: string[];
|
||||
removeCustomOption: (option: string) => void;
|
||||
addCustomOption: (option: string) => void;
|
||||
setCustomOptions: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
customOptions: ComboboxOption[];
|
||||
removeCustomOption: (option: ComboboxOption) => void;
|
||||
addCustomOption: (option: ComboboxOption) => void;
|
||||
setCustomOptions: React.Dispatch<React.SetStateAction<ComboboxOption[]>>;
|
||||
};
|
||||
|
||||
const CustomOptionsContext = createContext<CustomOptionsContextType>(
|
||||
@@ -19,14 +20,14 @@ export const CustomOptionsProvider = ({
|
||||
children: any;
|
||||
value: { isMultiSelect?: boolean };
|
||||
}) => {
|
||||
const [customOptions, setCustomOptions] = useState<string[]>([]);
|
||||
const [customOptions, setCustomOptions] = useState<ComboboxOption[]>([]);
|
||||
const { focusInput } = useInputContext();
|
||||
const { isMultiSelect } = value;
|
||||
|
||||
const removeCustomOption = useCallback(
|
||||
(option: string) => {
|
||||
(option: ComboboxOption) => {
|
||||
setCustomOptions((prevCustomOptions) =>
|
||||
prevCustomOptions.filter((o) => o !== option),
|
||||
prevCustomOptions.filter((o) => o.label !== option.label),
|
||||
);
|
||||
focusInput();
|
||||
},
|
||||
@@ -34,7 +35,7 @@ export const CustomOptionsProvider = ({
|
||||
);
|
||||
|
||||
const addCustomOption = useCallback(
|
||||
(option: string) => {
|
||||
(option: ComboboxOption) => {
|
||||
if (isMultiSelect) {
|
||||
setCustomOptions((prevOptions) => [...prevOptions, option]);
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import React, { ChangeEvent, InputHTMLAttributes } from "react";
|
||||
import { FormFieldProps } from "../useFormField";
|
||||
|
||||
/**
|
||||
* A more complex version of options for the Combobox.
|
||||
* Used for separating the label and the value of the option.
|
||||
*/
|
||||
export type ComboboxOption = {
|
||||
/**
|
||||
* The label to display in the dropdown list
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* The programmatic value of the option, for use internally. Will be returned from onToggleSelected.
|
||||
*/
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type MaxSelected = {
|
||||
/**
|
||||
* The limit for maximum selected options
|
||||
@@ -20,9 +35,9 @@ export interface ComboboxProps
|
||||
*/
|
||||
label: React.ReactNode;
|
||||
/**
|
||||
* List of options to use for autocompletion.
|
||||
* List of options
|
||||
*/
|
||||
options: string[];
|
||||
options: string[] | ComboboxOption[];
|
||||
/**
|
||||
* If enabled, adds an option to add the value of the input as an option whenever there are no options matching the value.
|
||||
*/
|
||||
@@ -42,7 +57,7 @@ export interface ComboboxProps
|
||||
* If provided, this overrides the internal search logic in the component.
|
||||
* Useful for e.g. searching on a server or when overriding the search algorithm to search for synonyms or similar.
|
||||
*/
|
||||
filteredOptions?: string[];
|
||||
filteredOptions?: string[] | ComboboxOption[];
|
||||
/**
|
||||
* Optionally hide the label visually.
|
||||
* Not recommended, but can be considered for e.g. search fields in the top menu.
|
||||
@@ -75,7 +90,6 @@ export interface ComboboxProps
|
||||
* Callback function triggered whenever the value of the input field is triggered.
|
||||
*
|
||||
* @param event
|
||||
* @returns
|
||||
*/
|
||||
onChange?: (
|
||||
event: ChangeEvent<HTMLInputElement> | null,
|
||||
@@ -85,19 +99,17 @@ export interface ComboboxProps
|
||||
* Callback function triggered whenever the input field is cleared.
|
||||
*
|
||||
* @param event
|
||||
* @returns
|
||||
*/
|
||||
onClear?: (event: React.PointerEvent | React.KeyboardEvent) => void;
|
||||
/**
|
||||
* Callback function triggered whenever an option is selected or de-selected.
|
||||
*
|
||||
* @param option The selected option.
|
||||
* @param isSelected Whether the option has been selected or unselected.
|
||||
* @param isCustomOption Whether the option comes from user input, instead of from the list.
|
||||
* @returns
|
||||
* @param option The option value
|
||||
* @param isSelected Whether the option has been selected or unselected
|
||||
* @param isCustomOption Whether the option comes from user input, instead of from the list
|
||||
*/
|
||||
onToggleSelected?: (
|
||||
option: string,
|
||||
option: ComboboxOption["value"],
|
||||
isSelected: boolean,
|
||||
isCustomOption: boolean,
|
||||
) => void;
|
||||
@@ -107,7 +119,7 @@ export interface ComboboxProps
|
||||
* Use this prop when controlling the selected state outside for the component,
|
||||
* e.g. for a filter, where options can be toggled elsewhere/programmatically.
|
||||
*/
|
||||
selectedOptions?: string[];
|
||||
selectedOptions?: string[] | ComboboxOption[];
|
||||
/**
|
||||
* Options for the maximum number of selected options.
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Meta } from "@storybook/react";
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { VStack } from "../../layout/stack";
|
||||
import { ErrorSummary } from "./ErrorSummary";
|
||||
|
||||
export default {
|
||||
@@ -18,9 +19,14 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof ErrorSummary>;
|
||||
|
||||
export const Default = {
|
||||
type Story = StoryObj<typeof ErrorSummary>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (props) => (
|
||||
<ErrorSummary
|
||||
heading="Feiloppsummering komponent"
|
||||
@@ -35,11 +41,41 @@ export const Default = {
|
||||
),
|
||||
};
|
||||
|
||||
export const Small = () => (
|
||||
<ErrorSummary heading="Feiloppsummering komponent" size="small">
|
||||
<ErrorSummary.Item href="#1">Checkbox må fylles ut</ErrorSummary.Item>
|
||||
<ErrorSummary.Item href="#2">
|
||||
Tekstfeltet må ha en godkjent e-mail
|
||||
</ErrorSummary.Item>
|
||||
</ErrorSummary>
|
||||
);
|
||||
export const Small: Story = {
|
||||
render: () => (
|
||||
<ErrorSummary heading="Feiloppsummering komponent" size="small">
|
||||
<ErrorSummary.Item href="#1">Checkbox må fylles ut</ErrorSummary.Item>
|
||||
<ErrorSummary.Item href="#2">
|
||||
Tekstfeltet må ha en godkjent e-mail
|
||||
</ErrorSummary.Item>
|
||||
</ErrorSummary>
|
||||
),
|
||||
};
|
||||
|
||||
export const Chromatic: Story = {
|
||||
render: () => (
|
||||
<VStack gap="4">
|
||||
<div>
|
||||
<h2>Default</h2>
|
||||
<ErrorSummary heading="Feiloppsummering komponent">
|
||||
<ErrorSummary.Item href="#1">Checkbox må fylles ut</ErrorSummary.Item>
|
||||
<ErrorSummary.Item href="#2">
|
||||
Tekstfeltet må ha en godkjent e-mail
|
||||
</ErrorSummary.Item>
|
||||
</ErrorSummary>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Small</h2>
|
||||
<ErrorSummary heading="Feiloppsummering komponent" size="small">
|
||||
<ErrorSummary.Item href="#1">Checkbox må fylles ut</ErrorSummary.Item>
|
||||
<ErrorSummary.Item href="#2">
|
||||
Tekstfeltet må ha en godkjent e-mail
|
||||
</ErrorSummary.Item>
|
||||
</ErrorSummary>
|
||||
</div>
|
||||
</VStack>
|
||||
),
|
||||
parameters: {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Meta } from "@storybook/react";
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { VStack } from "../../layout/stack";
|
||||
import TextField from "../textfield/TextField";
|
||||
import { Fieldset } from "./index";
|
||||
|
||||
@@ -26,7 +27,12 @@ export default {
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof Fieldset>;
|
||||
|
||||
type Story = StoryObj<typeof Fieldset>;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
@@ -35,88 +41,117 @@ const content = (
|
||||
</>
|
||||
);
|
||||
|
||||
export const Default = {
|
||||
render: (props) => {
|
||||
return (
|
||||
<Fieldset legend="Mollit eiusmod" {...props}>
|
||||
{content}
|
||||
</Fieldset>
|
||||
);
|
||||
},
|
||||
const contentWithError = (
|
||||
<>
|
||||
<TextField label="Textfield label" hideLabel error="Må være fylt ut" />
|
||||
<TextField label="Textfield label" hideLabel />
|
||||
</>
|
||||
);
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
legend: "Mollit eiusmod",
|
||||
description:
|
||||
"Do ullamco amet mollit labore tempor minim cupidatat dolore voluptate velit irure.",
|
||||
errorPropagation: true,
|
||||
children: content,
|
||||
},
|
||||
};
|
||||
|
||||
export const Small = () => (
|
||||
<Fieldset
|
||||
size="small"
|
||||
legend="Mollit eiusmod"
|
||||
description="Do ullamco amet mollit labore tempor minim cupidatat dolore voluptate velit irure."
|
||||
>
|
||||
{content}
|
||||
</Fieldset>
|
||||
);
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
legend: "Mollit eiusmod",
|
||||
description:
|
||||
"Do ullamco amet mollit labore tempor minim cupidatat dolore voluptate velit irure.",
|
||||
errorPropagation: true,
|
||||
children: content,
|
||||
size: "small",
|
||||
},
|
||||
};
|
||||
|
||||
export const Description = () => (
|
||||
<Fieldset
|
||||
legend="Mollit eiusmod"
|
||||
description="Esse cupidatat reprehenderit est culpa consectetur sit dolor esse."
|
||||
>
|
||||
<TextField
|
||||
label="Textfield label"
|
||||
description="Amet quis cillum incididunt "
|
||||
/>
|
||||
<TextField
|
||||
label="Textfield label"
|
||||
description="Enim et occaecat voluptate labore sit do exercitation laborum non "
|
||||
/>
|
||||
</Fieldset>
|
||||
);
|
||||
export const ErrorPropagation: Story = {
|
||||
args: {
|
||||
legend: "Mollit eiusmod",
|
||||
description:
|
||||
"Do ullamco amet mollit labore tempor minim cupidatat dolore voluptate velit irure.",
|
||||
errorPropagation: false,
|
||||
children: contentWithError,
|
||||
},
|
||||
};
|
||||
|
||||
export const ErrorPropagation = () => (
|
||||
<Fieldset
|
||||
legend="Mollit eiusmod"
|
||||
error="Fieldsett error"
|
||||
errorPropagation={false}
|
||||
>
|
||||
<TextField label="Textfield label" hideLabel error="Må være fylt ut" />
|
||||
<TextField label="Textfield label" hideLabel />
|
||||
</Fieldset>
|
||||
);
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
legend: "Mollit eiusmod",
|
||||
description:
|
||||
"Do ullamco amet mollit labore tempor minim cupidatat dolore voluptate velit irure.",
|
||||
children: content,
|
||||
error: "Laborum officia nisi aliqua esse minim in amet.",
|
||||
},
|
||||
};
|
||||
|
||||
export const Error = () => (
|
||||
<div className="colgap">
|
||||
<Fieldset
|
||||
legend="Mollit eiusmod"
|
||||
error="Laborum officia nisi aliqua esse minim in amet."
|
||||
>
|
||||
{content}
|
||||
</Fieldset>
|
||||
<Fieldset
|
||||
size="small"
|
||||
legend="Mollit eiusmod"
|
||||
error="Laborum officia nisi aliqua esse minim in amet."
|
||||
>
|
||||
{content}
|
||||
</Fieldset>
|
||||
</div>
|
||||
);
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
legend: "Mollit eiusmod",
|
||||
description:
|
||||
"Do ullamco amet mollit labore tempor minim cupidatat dolore voluptate velit irure.",
|
||||
children: content,
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled = () => (
|
||||
<div className="colgap">
|
||||
<Fieldset legend="Mollit eiusmod" disabled>
|
||||
{content}
|
||||
</Fieldset>
|
||||
<Fieldset size="small" legend="Mollit eiusmod" disabled>
|
||||
{content}
|
||||
</Fieldset>
|
||||
</div>
|
||||
);
|
||||
export const HideLegend: Story = {
|
||||
args: {
|
||||
legend: "Mollit eiusmod",
|
||||
description:
|
||||
"Do ullamco amet mollit labore tempor minim cupidatat dolore voluptate velit irure.",
|
||||
children: content,
|
||||
hideLegend: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const HideLegend = () => (
|
||||
<Fieldset legend="Mollit eiusmod" hideLegend>
|
||||
{content}
|
||||
</Fieldset>
|
||||
);
|
||||
export const Chromatic: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<VStack gap="4">
|
||||
<div>
|
||||
<h2>Default</h2>
|
||||
{/* @ts-expect-error Args are Partial here */}
|
||||
<Fieldset {...Default.args} />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Small</h2>
|
||||
{/* @ts-expect-error Args are Partial here */}
|
||||
<Fieldset {...Small.args} />
|
||||
</div>
|
||||
<div>
|
||||
<h2>ErrorPropagation</h2>
|
||||
{/* @ts-expect-error Args are Partial here */}
|
||||
<Fieldset {...ErrorPropagation.args} />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Error</h2>
|
||||
{/* @ts-expect-error Args are Partial here */}
|
||||
<Fieldset {...Error.args} />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Error small</h2>
|
||||
{/* @ts-expect-error Args are Partial here */}
|
||||
<Fieldset {...Error.args} size="small" />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Disabled</h2>
|
||||
{/* @ts-expect-error Args are Partial here */}
|
||||
<Fieldset {...Disabled.args} />
|
||||
</div>
|
||||
<div>
|
||||
<h2>HideLegend</h2>
|
||||
{/* @ts-expect-error Args are Partial here */}
|
||||
<Fieldset {...HideLegend.args} />
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createContext } from "../../util/create-context";
|
||||
import { ComponentTranslation } from "./i18n/i18n.types";
|
||||
|
||||
export type FileUploadLocaleContextProps = {
|
||||
translations?: ComponentTranslation<"FileUpload">;
|
||||
};
|
||||
|
||||
export const [FileUploadLocaleContextProvider, useFileUploadTranslation] =
|
||||
createContext<FileUploadLocaleContextProps>();
|
||||
@@ -0,0 +1,142 @@
|
||||
import cl from "clsx";
|
||||
import React, { HTMLAttributes, forwardRef } from "react";
|
||||
import { FileUploadLocaleContextProvider } from "./FileUpload.context";
|
||||
import { ComponentTranslation } from "./i18n/i18n.types";
|
||||
import Trigger from "./parts/Trigger";
|
||||
import Dropzone from "./parts/dropzone/Dropzone";
|
||||
import Item from "./parts/item/Item";
|
||||
|
||||
interface FileUploadProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* i18n-API for customizing texts and labels
|
||||
*/
|
||||
translations?: ComponentTranslation<"FileUpload">;
|
||||
}
|
||||
|
||||
interface FileUploadComponent
|
||||
extends React.ForwardRefExoticComponent<
|
||||
FileUploadProps & React.RefAttributes<HTMLDivElement>
|
||||
> {
|
||||
/**
|
||||
* Framed area to drag-n-drop files, upload files with button-click or copy-paste.
|
||||
* @example
|
||||
* Single file
|
||||
* ```jsx
|
||||
* <FileUpload.Dropzone
|
||||
* label="Last opp fil"
|
||||
* multiple={false}
|
||||
* onSelect={onSelect}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Multiple files
|
||||
* ```jsx
|
||||
* <FileUpload.Dropzone
|
||||
* label="Last opp fil"
|
||||
* multiple={true}
|
||||
* onSelect={onSelect}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Error
|
||||
* ```jsx
|
||||
* <FileUpload.Dropzone
|
||||
* label="Last opp filer"
|
||||
* onSelect={onSelect}
|
||||
* error={error}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
Dropzone: typeof Dropzone;
|
||||
|
||||
/**
|
||||
* Displays a file with status, file size, action and error message.
|
||||
* @example
|
||||
* Single
|
||||
* ```jsx
|
||||
* <FileUpload.Item file={file} status="uploading" />
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Multiple items can be semantically grouped as a list.
|
||||
* ```jsx
|
||||
* <FileUpload>
|
||||
* <VStack gap="4" as="ul">
|
||||
* <FileUpload.Item as="li" file={file} />
|
||||
* <FileUpload.Item as="li" file={file2} />
|
||||
* <FileUpload.Item as="li" file={file3} status="uploading" />
|
||||
* </VStack>
|
||||
* </FileUpload>
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Custom file object
|
||||
* ```jsx
|
||||
* <FileUpload.Item file={{ name: "fileName.pdf", size: 1_048_576 }} />
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Error
|
||||
* ```jsx
|
||||
* <FileUpload.Item file={file} error="Something went wrong" />
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Status & actions
|
||||
* ```jsx
|
||||
* <FileUpload.Item file={file} status="uploading" />
|
||||
* <FileUpload.Item file={file} status="downloading" />
|
||||
* <FileUpload.Item file={file} itemAction="retry" onRetry={...} />
|
||||
* <FileUpload.Item file={file} itemAction="delete" onDelete={...} />
|
||||
* ```
|
||||
*/
|
||||
Item: typeof Item;
|
||||
/**
|
||||
* Wrapper for a button to trigger file select.
|
||||
* @example
|
||||
* ```jsx
|
||||
* <FileUpload.Trigger onSelect={...}>
|
||||
* <Button variant="secondary">Last opp filer</Button>
|
||||
* </FileUpload.Trigger>
|
||||
* ```
|
||||
*/
|
||||
Trigger: typeof Trigger;
|
||||
}
|
||||
|
||||
/**
|
||||
* A set of components used to upload and display files.
|
||||
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/fileupload)
|
||||
* @example Dropzone
|
||||
* ```jsx
|
||||
* <FileUpload.Dropzone />
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Items
|
||||
* ```jsx
|
||||
* <VStack gap="4" as="ul">
|
||||
* <FileUpload.Item as="li" file={myFile} />
|
||||
* <FileUpload.Item as="li" file={mySecondFile} />
|
||||
* </VStack>
|
||||
* ```
|
||||
*/
|
||||
export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(
|
||||
({ children, className, translations, ...rest }: FileUploadProps, ref) => {
|
||||
return (
|
||||
<FileUploadLocaleContextProvider translations={translations}>
|
||||
<div ref={ref} {...rest} className={cl("navds-file-upload", className)}>
|
||||
{children}
|
||||
</div>
|
||||
</FileUploadLocaleContextProvider>
|
||||
);
|
||||
},
|
||||
) as FileUploadComponent;
|
||||
|
||||
FileUpload.Dropzone = Dropzone;
|
||||
FileUpload.Item = Item;
|
||||
FileUpload.Trigger = Trigger;
|
||||
|
||||
export default FileUpload;
|
||||
@@ -0,0 +1,57 @@
|
||||
export const fileRejectionReason = {
|
||||
FileType: "fileType" as const,
|
||||
FileSize: "fileSize" as const,
|
||||
};
|
||||
|
||||
export type FileRejectionReason =
|
||||
(typeof fileRejectionReason)[keyof typeof fileRejectionReason];
|
||||
|
||||
export type FileRejected = {
|
||||
file: File;
|
||||
error: true;
|
||||
reasons: string[];
|
||||
};
|
||||
export type FileAccepted = { file: File; error: false };
|
||||
|
||||
export type FileObject = FileRejected | FileAccepted;
|
||||
export type FileRejectedPartitioned = {
|
||||
file: File;
|
||||
reasons: string[];
|
||||
};
|
||||
export type FilesPartitioned = {
|
||||
accepted: File[];
|
||||
rejected: FileRejectedPartitioned[];
|
||||
};
|
||||
|
||||
export interface FileUploadBaseProps {
|
||||
/**
|
||||
* Indicates if it is possible to select multiple files at once.
|
||||
* @default true
|
||||
*/
|
||||
multiple?: boolean;
|
||||
/**
|
||||
* Indicates which file types to accept.
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept
|
||||
*/
|
||||
accept?: string;
|
||||
/**
|
||||
* Maximum size of a file to accept
|
||||
*/
|
||||
maxSizeInBytes?: number;
|
||||
/**
|
||||
* Custom validator that is used to decide if a file is accepted or rejected.
|
||||
* @return true if the file is accepted, otherwise a string with the reason for rejection
|
||||
*/
|
||||
validator?: (file: File) => true | string;
|
||||
/**
|
||||
* Callback triggered on file select
|
||||
*/
|
||||
onSelect: (files: FileObject[], partitionedFiles: FilesPartitioned) => void;
|
||||
/**
|
||||
* Disables the dropzone when current >= max, unless `disabled` prop is set to `false`.
|
||||
*/
|
||||
fileLimit?: {
|
||||
max: number;
|
||||
current: number;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import { fireEvent, within } from "@storybook/test";
|
||||
import React from "react";
|
||||
import { ImageIcon } from "@navikt/aksel-icons";
|
||||
import {
|
||||
type FileObject,
|
||||
UNSAFE_FileUpload as FileUpload,
|
||||
type FilesPartitioned,
|
||||
} from ".";
|
||||
|
||||
const meta: Meta<typeof FileUpload.Dropzone> = {
|
||||
title: "ds-react/FileUpload/Dropzone",
|
||||
component: FileUpload.Dropzone,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: 500, maxWidth: "100%" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const onSelect = (
|
||||
allFiles: FileObject[],
|
||||
{ accepted, rejected }: FilesPartitioned,
|
||||
) => {
|
||||
alert(
|
||||
`Lastet opp ${allFiles.length} filer. Accepted: ${accepted.length}. Rejected: ${rejected.length}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const Default: StoryObj<typeof FileUpload.Dropzone> = {
|
||||
render: (props) => <FileUpload.Dropzone {...props} onSelect={console.log} />,
|
||||
args: {
|
||||
label: "Last opp filer",
|
||||
description: "",
|
||||
error: "",
|
||||
multiple: true,
|
||||
accept: "",
|
||||
maxSizeInBytes: 0,
|
||||
fileLimit: { max: 2, current: 1 },
|
||||
},
|
||||
argTypes: {
|
||||
disabled: { control: { type: "boolean" } },
|
||||
},
|
||||
parameters: { chromatic: { disable: true } },
|
||||
};
|
||||
|
||||
export const States: StoryObj = {
|
||||
render: () => (
|
||||
<div>
|
||||
<h2>Disabled</h2>
|
||||
<FileUpload.Dropzone
|
||||
label="Disabled prop"
|
||||
onSelect={console.log}
|
||||
disabled
|
||||
/>
|
||||
<FileUpload.Dropzone
|
||||
label="FileLimit disabled"
|
||||
onSelect={console.log}
|
||||
fileLimit={{ max: 1, current: 2 }}
|
||||
/>
|
||||
|
||||
<h2>Error</h2>
|
||||
<FileUpload.Dropzone
|
||||
label="Last opp filer"
|
||||
onSelect={onSelect}
|
||||
error="Du må laste opp en fil"
|
||||
description="Bruk filtype DOC, PPT eller PDF. Maks filstørrelse 10 MB."
|
||||
/>
|
||||
|
||||
<h2>Dragging</h2>
|
||||
<FileUpload.Dropzone
|
||||
label="Drag over test"
|
||||
multiple={false}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
States.play = async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const button = canvas.getByText("Velg fil");
|
||||
fireEvent.dragEnter(button);
|
||||
};
|
||||
|
||||
export const Translation: StoryObj = {
|
||||
render: () => (
|
||||
<div>
|
||||
<h2>Single file</h2>
|
||||
<FileUpload.Dropzone
|
||||
label="Last opp fil"
|
||||
multiple={false}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
|
||||
<h2>Custom texts</h2>
|
||||
<FileUpload.Dropzone
|
||||
translations={{
|
||||
dragAndDropMultiple: "Dra og slipp bilder i format .png",
|
||||
buttonMultiple: "Velg bilder",
|
||||
disabled: "Du kan ikke laste opp flere bilder",
|
||||
}}
|
||||
label="Last opp bilder"
|
||||
onSelect={console.log}
|
||||
icon={ImageIcon}
|
||||
/>
|
||||
|
||||
<h3>Disabled</h3>
|
||||
<FileUpload.Dropzone
|
||||
translations={{
|
||||
disabled: "Du kan ikke laste opp flere bilder",
|
||||
}}
|
||||
label="Last opp bilder"
|
||||
onSelect={console.log}
|
||||
icon={ImageIcon}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
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"
|
||||
onRetry={() => onRetry(fileTxt)}
|
||||
onDelete={() => onDelete(fileTxt)}
|
||||
/>
|
||||
<h2>Item Actions</h2>
|
||||
<h3>status + delete</h3>
|
||||
<FileUpload.Item
|
||||
file={fileDocx}
|
||||
onDelete={() => onDelete(fileDocx)}
|
||||
onRetry={() => onRetry(fileDocx)}
|
||||
status="uploading"
|
||||
itemAction="delete"
|
||||
/>
|
||||
<h3>status + retry</h3>
|
||||
<FileUpload.Item
|
||||
file={fileDocx}
|
||||
onDelete={() => onDelete(fileDocx)}
|
||||
onRetry={() => onRetry(fileDocx)}
|
||||
status="downloading"
|
||||
itemAction="retry"
|
||||
/>
|
||||
<h3>retry</h3>
|
||||
<FileUpload.Item
|
||||
file={fileCsv}
|
||||
onRetry={() => onRetry(fileCsv)}
|
||||
itemAction="retry"
|
||||
/>
|
||||
<h3>delete</h3>
|
||||
<FileUpload.Item
|
||||
file={filePptx}
|
||||
onDelete={() => onDelete(filePptx)}
|
||||
itemAction="delete"
|
||||
/>
|
||||
<h3>retry + error</h3>
|
||||
<FileUpload.Item
|
||||
file={fileCsv}
|
||||
error="Error og onRetry"
|
||||
onRetry={() => onRetry(fileCsv)}
|
||||
itemAction="retry"
|
||||
/>
|
||||
<h3>delete + error</h3>
|
||||
<FileUpload.Item
|
||||
file={filePptx}
|
||||
error="Error og onDelete"
|
||||
onDelete={() => onDelete(filePptx)}
|
||||
itemAction="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,236 @@
|
||||
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,
|
||||
...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"}
|
||||
itemAction="delete"
|
||||
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}
|
||||
onDelete={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 }}
|
||||
onDelete={() => null}
|
||||
/>
|
||||
<FileUpload.Item
|
||||
file={{ name: "eksempel.png", size: 200000 }}
|
||||
itemAction="retry"
|
||||
onRetry={() => null}
|
||||
/>
|
||||
<FileUpload.Item
|
||||
file={{ name: "eksempel.png", size: 200000 }}
|
||||
status="downloading"
|
||||
/>
|
||||
<FileUpload.Item
|
||||
file={{ name: "eksempel.png", size: 200000 }}
|
||||
status="uploading"
|
||||
/>
|
||||
<FileUpload.Item
|
||||
file={{ name: "eksempel.png", size: 200000 }}
|
||||
status="uploading"
|
||||
translations={{ uploading: "Sender bilde..." }}
|
||||
/>
|
||||
</VStack>
|
||||
</FileUpload>
|
||||
);
|
||||
|
||||
export const TriggerWithButton: StoryObj<typeof FileUpload.Trigger> = {
|
||||
render: (props) => {
|
||||
return (
|
||||
<FileUpload.Trigger {...props} onSelect={console.log}>
|
||||
<Button variant="secondary" icon={<UploadIcon aria-hidden />}>
|
||||
Last opp filer
|
||||
</Button>
|
||||
</FileUpload.Trigger>
|
||||
);
|
||||
},
|
||||
args: {
|
||||
multiple: true,
|
||||
accept: "",
|
||||
maxSizeInBytes: 0,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { TranslationObject } from "./i18n.types";
|
||||
|
||||
/**
|
||||
* https://github.com/Shopify/polaris/blob/main/polaris-react/src/utilities/get.ts#L3
|
||||
*/
|
||||
const OBJECT_NOTATION_MATCHER = /(\w+)/g;
|
||||
|
||||
export function get(
|
||||
keypath: string | string[],
|
||||
...objs: (TranslationObject | undefined)[]
|
||||
) {
|
||||
const keys = Array.isArray(keypath) ? keypath : getKeypath(keypath);
|
||||
|
||||
for (const obj of objs) {
|
||||
if (!obj) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let acc: string | TranslationObject = obj;
|
||||
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const val = acc[keys[i]];
|
||||
if (val === undefined) {
|
||||
continue;
|
||||
}
|
||||
acc = val;
|
||||
}
|
||||
|
||||
if (typeof acc === "string") {
|
||||
return acc;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Error translating key. The keypath does not resolve to a string.",
|
||||
);
|
||||
}
|
||||
|
||||
function getKeypath(str: string) {
|
||||
const path: string[] = [];
|
||||
let result: RegExpExecArray | null;
|
||||
while ((result = OBJECT_NOTATION_MATCHER.exec(str))) {
|
||||
const [, first, second] = result;
|
||||
path.push(first || second);
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { I18nContext, useI18n } from "./i18n.context";
|
||||
|
||||
describe("useI18n", () => {
|
||||
test("should throw error if key is not found", () => {
|
||||
const { result } = renderHook(() => useI18n("FileUpload"));
|
||||
const translate = result.current;
|
||||
// @ts-expect-error - Testing nonexistent key
|
||||
expect(() => translate("item.nonexistentKey")).toThrowError();
|
||||
});
|
||||
|
||||
test("should return the translated text from I18nContext", () => {
|
||||
const i18n = { FileUpload: { item: { uploading: "Test translation" } } };
|
||||
const { result } = renderHook(() => useI18n("FileUpload"), {
|
||||
wrapper: ({ children }) => (
|
||||
<I18nContext.Provider value={i18n}>{children}</I18nContext.Provider>
|
||||
),
|
||||
});
|
||||
const translate = result.current;
|
||||
expect(translate("item.uploading")).toBe("Test translation");
|
||||
});
|
||||
|
||||
test("should return the translated text from first context object", () => {
|
||||
const i18n1 = {
|
||||
FileUpload: { item: { uploading: "Correct translation" } },
|
||||
};
|
||||
const i18n2 = { FileUpload: { item: { uploading: "Wrong translation" } } };
|
||||
const { result } = renderHook(() => useI18n("FileUpload"), {
|
||||
wrapper: ({ children }) => (
|
||||
<I18nContext.Provider value={[i18n1, i18n2]}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
),
|
||||
});
|
||||
const translate = result.current;
|
||||
expect(translate("item.uploading")).toBe("Correct translation");
|
||||
});
|
||||
|
||||
test("should return the translated text from second context object", () => {
|
||||
const i18n1 = { FileUpload: { item: { uploading: "Foo" } } };
|
||||
const i18n2 = {
|
||||
FileUpload: { item: { downloading: "Correct translation" } },
|
||||
};
|
||||
const { result } = renderHook(() => useI18n("FileUpload"), {
|
||||
wrapper: ({ children }) => (
|
||||
<I18nContext.Provider value={[i18n1, i18n2]}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
),
|
||||
});
|
||||
const translate = result.current;
|
||||
expect(translate("item.downloading")).toBe("Correct translation");
|
||||
});
|
||||
|
||||
test("should return the translated text from first local object", () => {
|
||||
const i18n1 = { item: { uploading: "Correct translation" } };
|
||||
const i18n2 = { item: { uploading: "Wrong translation" } };
|
||||
const { result } = renderHook(() => useI18n("FileUpload", i18n1, i18n2));
|
||||
const translate = result.current;
|
||||
expect(translate("item.uploading")).toBe("Correct translation");
|
||||
});
|
||||
|
||||
test("should return the translated text from second local object", () => {
|
||||
const i18n1 = { item: { uploading: "Foo" } };
|
||||
const i18n2 = { item: { downloading: "Correct translation" } };
|
||||
const { result } = renderHook(() => useI18n("FileUpload", i18n1, i18n2));
|
||||
const translate = result.current;
|
||||
expect(translate("item.downloading")).toBe("Correct translation");
|
||||
});
|
||||
|
||||
test("should replace placeholders in the translated text", () => {
|
||||
const i18n = {
|
||||
item: { uploading: "Hello, {name}. You have {cnt} messages." },
|
||||
};
|
||||
const { result } = renderHook(() => useI18n("FileUpload", i18n));
|
||||
const translate = result.current;
|
||||
expect(
|
||||
translate("item.uploading", { replacements: { name: "John", cnt: 3 } }),
|
||||
).toBe("Hello, John. You have 3 messages.");
|
||||
});
|
||||
|
||||
test("should throw an error if replacement key is not found", () => {
|
||||
const i18n = { item: { uploading: "Hello, {name}" } };
|
||||
const { result } = renderHook(() => useI18n("FileUpload", i18n));
|
||||
const translate = result.current;
|
||||
expect(() =>
|
||||
translate("item.uploading", { replacements: { other: "John" } }),
|
||||
).toThrowError();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { get } from "./get";
|
||||
import {
|
||||
Component,
|
||||
ComponentTranslation,
|
||||
TranslationDictionary,
|
||||
} from "./i18n.types";
|
||||
import nb from "./locales/nb";
|
||||
|
||||
/**
|
||||
* https://regex101.com/r/LYKWi3/1
|
||||
*/
|
||||
const REPLACE_REGEX = /{[^}]*}/g;
|
||||
|
||||
export const I18nContext = createContext<
|
||||
TranslationDictionary | TranslationDictionary[]
|
||||
>(nb);
|
||||
|
||||
/* https://dev.to/pffigueiredo/typescript-utility-keyof-nested-object-2pa3 */
|
||||
type NestedKeyOf<ObjectType extends object> = {
|
||||
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object
|
||||
? `${Key}.${NestedKeyOf<ObjectType[Key]>}`
|
||||
: `${Key}`;
|
||||
}[keyof ObjectType & (string | number)];
|
||||
|
||||
export function useI18n<T extends Component>(
|
||||
componentName: T,
|
||||
...local: (ComponentTranslation<T> | undefined)[]
|
||||
) {
|
||||
const i18n = useContext(I18nContext);
|
||||
|
||||
/**
|
||||
* https://github.com/Shopify/polaris/blob/2115f9ba2f5bcbf2ad15745233501bff2db81ecf/polaris-react/src/utilities/i18n/I18n.ts#L24
|
||||
*/
|
||||
const translate = (
|
||||
keypath: NestedKeyOf<(typeof nb)[T]>,
|
||||
options?: { replacements: Record<string, string | number> },
|
||||
) => {
|
||||
const text = get(
|
||||
keypath,
|
||||
...local,
|
||||
...(Array.isArray(i18n)
|
||||
? i18n.map((t) => t[componentName])
|
||||
: [i18n[componentName]]),
|
||||
);
|
||||
|
||||
if (options?.replacements) {
|
||||
return text.replace(REPLACE_REGEX, (match) => {
|
||||
const replacement = match.substring(1, match.length - 1);
|
||||
|
||||
if (options.replacements[replacement] === undefined) {
|
||||
const replacementData = JSON.stringify(options.replacements);
|
||||
|
||||
throw new Error(
|
||||
`Error translating key '${keypath}'. No replacement syntax ({}) found for key '${replacement}'. The following replacements were passed: '${replacementData}'`,
|
||||
);
|
||||
}
|
||||
|
||||
return options.replacements[replacement] as string; // can also be a number, but JS doesn't mind...
|
||||
});
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
return translate;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import nb from "./locales/nb";
|
||||
|
||||
export interface TranslationObject {
|
||||
[key: string]: string | TranslationObject | undefined;
|
||||
}
|
||||
|
||||
export interface TranslationDictionary {
|
||||
[key: string]: TranslationObject | undefined;
|
||||
}
|
||||
|
||||
/* https://stackoverflow.com/questions/47914536/use-partial-in-nested-property-with-typescripts */
|
||||
type RecursivePartial<T> = {
|
||||
[P in keyof T]?: RecursivePartial<T[P]>;
|
||||
};
|
||||
|
||||
export type Component = keyof typeof nb;
|
||||
|
||||
export type ComponentTranslation<T extends Component> = RecursivePartial<
|
||||
(typeof nb)[T]
|
||||
>;
|
||||
@@ -0,0 +1,20 @@
|
||||
export default {
|
||||
FileUpload: {
|
||||
dropzone: {
|
||||
button: "Velg fil",
|
||||
buttonMultiple: "Velg filer",
|
||||
dragAndDrop: "Dra og slipp filen her",
|
||||
dragAndDropMultiple: "Dra og slipp filer her",
|
||||
drop: "Slipp",
|
||||
or: "eller",
|
||||
disabled: "Filopplasting er deaktivert",
|
||||
disabledFilelimit: "Du kan ikke laste opp flere filer",
|
||||
},
|
||||
item: {
|
||||
retryButtonTitle: "Prøv å laste opp filen på nytt",
|
||||
deleteButtonTitle: "Slett filen",
|
||||
uploading: "Laster opp…",
|
||||
downloading: "Laster ned…",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { TranslationDictionary, TranslationObject } from "./i18n.types";
|
||||
|
||||
export function merge(...objs: TranslationDictionary[]) {
|
||||
let final: TranslationDictionary = {};
|
||||
|
||||
for (const obj of objs) {
|
||||
final = mergeRecursively(final, obj);
|
||||
}
|
||||
|
||||
return final;
|
||||
}
|
||||
|
||||
function mergeRecursively<T>(
|
||||
objA: T,
|
||||
objB: TranslationDictionary | TranslationObject,
|
||||
) {
|
||||
const objARes = { ...objA };
|
||||
|
||||
for (const key in objB) {
|
||||
if (!(key in objB)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const a = objARes[key];
|
||||
const b = objB[key];
|
||||
|
||||
if (b && typeof b !== "string" && typeof a !== "string") {
|
||||
objARes[key] = mergeRecursively(a, b);
|
||||
} else {
|
||||
objARes[key] = b;
|
||||
}
|
||||
}
|
||||
|
||||
return objARes;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
export { default as UNSAFE_FileUpload } from "./FileUpload";
|
||||
export { default as UNSAFE_FileUploadDropzone } from "./parts/dropzone/Dropzone";
|
||||
export { type FileUploadDropzoneProps } from "./parts/dropzone/dropzone.types";
|
||||
export {
|
||||
default as UNSAFE_FileUploadTrigger,
|
||||
type FileUploadTriggerProps,
|
||||
} from "./parts/Trigger";
|
||||
export {
|
||||
type FileObject,
|
||||
type FileRejected,
|
||||
type FileAccepted,
|
||||
type FileRejectedPartitioned,
|
||||
type FilesPartitioned,
|
||||
type FileRejectionReason,
|
||||
} from "./FileUpload.types";
|
||||
export {
|
||||
default as UNSAFE_FileUploadItem,
|
||||
type FileUploadItemProps,
|
||||
} from "./parts/item/Item";
|
||||
export { type FileItem, type FileMetadata } from "./parts/item/Item.types";
|
||||
@@ -0,0 +1,48 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { Slot } from "../../../util/Slot";
|
||||
import { FileUploadBaseProps } from "../FileUpload.types";
|
||||
import { useFileUpload } from "../useFileUpload";
|
||||
|
||||
export interface FileUploadTriggerProps
|
||||
extends Omit<FileUploadBaseProps, "fileLimit"> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Trigger = forwardRef<HTMLInputElement, FileUploadTriggerProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
multiple = true,
|
||||
accept,
|
||||
onSelect,
|
||||
validator,
|
||||
maxSizeInBytes,
|
||||
}: FileUploadTriggerProps,
|
||||
ref,
|
||||
) => {
|
||||
const { onChange, inputRef, mergedRef } = useFileUpload({
|
||||
ref,
|
||||
onSelect,
|
||||
validator,
|
||||
accept,
|
||||
maxSizeInBytes,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Slot onClick={() => inputRef.current?.click()}>{children}</Slot>
|
||||
<input
|
||||
ref={mergedRef}
|
||||
type="file"
|
||||
style={{ display: "none" }}
|
||||
multiple={multiple}
|
||||
accept={accept}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Trigger;
|
||||
@@ -0,0 +1,186 @@
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef } from "react";
|
||||
import { CircleSlashIcon, CloudUpIcon } from "@navikt/aksel-icons";
|
||||
import { Button } from "../../../../button";
|
||||
import { BodyShort, ErrorMessage, Label } from "../../../../typography";
|
||||
import { composeEventHandlers } from "../../../../util/composeEventHandlers";
|
||||
import { useId } from "../../../../util/hooks";
|
||||
import { omit } from "../../../../util/omit";
|
||||
import { useFormField } from "../../../useFormField";
|
||||
import { useFileUploadTranslation } from "../../FileUpload.context";
|
||||
import { useI18n } from "../../i18n/i18n.context";
|
||||
import { useFileUpload } from "../../useFileUpload";
|
||||
import { FileUploadDropzoneProps } from "./dropzone.types";
|
||||
import { useDropzone } from "./useDropzone";
|
||||
|
||||
const Dropzone = forwardRef<HTMLInputElement, FileUploadDropzoneProps>(
|
||||
(props: FileUploadDropzoneProps, ref) => {
|
||||
const {
|
||||
onSelect,
|
||||
error,
|
||||
label,
|
||||
description,
|
||||
className,
|
||||
multiple = true,
|
||||
accept,
|
||||
validator,
|
||||
maxSizeInBytes,
|
||||
fileLimit,
|
||||
icon: DropzoneIcon = CloudUpIcon,
|
||||
disabled,
|
||||
translations,
|
||||
onClick,
|
||||
id: buttonId,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const context = useFileUploadTranslation(false);
|
||||
const translate = useI18n(
|
||||
"FileUpload",
|
||||
{ dropzone: translations },
|
||||
context?.translations,
|
||||
);
|
||||
|
||||
const fileLimitReached =
|
||||
fileLimit && fileLimit?.current >= fileLimit?.max && fileLimit?.max > 0;
|
||||
|
||||
const _disabled = disabled ?? fileLimitReached;
|
||||
|
||||
const { inputProps, errorId, showErrorMsg, hasError, inputDescriptionId } =
|
||||
useFormField(
|
||||
{ ...omit(props, ["id"]), disabled: _disabled },
|
||||
"fileUpload",
|
||||
);
|
||||
const {
|
||||
id: inputId,
|
||||
"aria-describedby": ariaDescribedby,
|
||||
...inputPropsRest
|
||||
} = inputProps;
|
||||
const labelId = useId();
|
||||
|
||||
const { upload, onChange, inputRef, mergedRef } = useFileUpload({
|
||||
ref,
|
||||
onSelect,
|
||||
validator,
|
||||
accept,
|
||||
maxSizeInBytes,
|
||||
disabled: inputProps.disabled,
|
||||
});
|
||||
|
||||
const dropzoneCtx = useDropzone({
|
||||
upload,
|
||||
disabled: inputProps.disabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cl("navds-form-field", "navds-dropzone", className, {
|
||||
"navds-dropzone--error": hasError,
|
||||
"navds-dropzone--dragging": dropzoneCtx.isDraggingOver,
|
||||
"navds-dropzone--disabled": inputProps.disabled,
|
||||
})}
|
||||
>
|
||||
<Label
|
||||
htmlFor={inputId}
|
||||
id={labelId}
|
||||
className="navds-form-field__label"
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
{!!description && (
|
||||
<BodyShort
|
||||
id={inputDescriptionId}
|
||||
className="navds-form-field__description"
|
||||
as="div"
|
||||
>
|
||||
{description}
|
||||
</BodyShort>
|
||||
)}
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className="navds-dropzone__area"
|
||||
onDragEnter={dropzoneCtx.onDragEnter}
|
||||
onDragOver={dropzoneCtx.onDragOver}
|
||||
onDragLeave={dropzoneCtx.onDragLeave}
|
||||
onDrop={dropzoneCtx.onDrop}
|
||||
onClick={composeEventHandlers(
|
||||
onClick,
|
||||
() => inputRef.current?.click(),
|
||||
)}
|
||||
>
|
||||
{!inputProps.disabled && (
|
||||
<>
|
||||
<div className="navds-dropzone__area-icon">
|
||||
<DropzoneIcon fontSize="1.5rem" aria-hidden />
|
||||
</div>
|
||||
<div className="navds-dropzone__area-release">
|
||||
<div className="navds-dropzone__area-release__icon">
|
||||
<DropzoneIcon aria-hidden />
|
||||
</div>
|
||||
<span
|
||||
aria-hidden={!dropzoneCtx.isDraggingOver}
|
||||
className="navds-dropzone__area-release__text"
|
||||
>
|
||||
{translate("dropzone.drop")}
|
||||
</span>
|
||||
</div>
|
||||
<div aria-hidden>
|
||||
<BodyShort as="div" spacing>
|
||||
{multiple
|
||||
? translate("dropzone.dragAndDropMultiple")
|
||||
: translate("dropzone.dragAndDrop")}
|
||||
</BodyShort>
|
||||
<BodyShort as="div">{translate("dropzone.or")}</BodyShort>
|
||||
</div>
|
||||
<Button
|
||||
{...omit(rest, ["errorId"])}
|
||||
{...inputPropsRest}
|
||||
id={buttonId}
|
||||
aria-describedby={cl(labelId, ariaDescribedby)}
|
||||
className="navds-dropzone__area-button"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
{multiple
|
||||
? translate("dropzone.buttonMultiple")
|
||||
: translate("dropzone.button")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{inputProps.disabled && (
|
||||
<div className="navds-dropzone__area-disabled">
|
||||
<CircleSlashIcon aria-hidden fontSize="1.75rem" />
|
||||
<BodyShort as="div">
|
||||
{fileLimitReached
|
||||
? translate("dropzone.disabledFilelimit")
|
||||
: translate("dropzone.disabled")}
|
||||
</BodyShort>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
id={inputId}
|
||||
type="file"
|
||||
style={{ display: "none" }}
|
||||
multiple={multiple}
|
||||
accept={accept}
|
||||
onChange={onChange}
|
||||
ref={mergedRef}
|
||||
disabled={inputProps.disabled}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="navds-form-field__error"
|
||||
id={errorId}
|
||||
aria-relevant="additions removals"
|
||||
aria-live="polite"
|
||||
>
|
||||
{showErrorMsg && <ErrorMessage>{error}</ErrorMessage>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Dropzone;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { FormFieldProps } from "../../../useFormField";
|
||||
import { FileUploadBaseProps } from "../../FileUpload.types";
|
||||
import { ComponentTranslation } from "../../i18n/i18n.types";
|
||||
|
||||
export interface FileUploadDropzoneProps
|
||||
extends FileUploadBaseProps,
|
||||
Omit<FormFieldProps, "size" | "readOnly">,
|
||||
Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect" | "onClick">,
|
||||
Pick<React.HTMLAttributes<HTMLDivElement>, "onClick"> {
|
||||
/**
|
||||
* Text shown to the user.
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* @default CloudUpIcon
|
||||
*/
|
||||
icon?: React.ComponentType<any>;
|
||||
/**
|
||||
* i18n-API for customizing texts and labels
|
||||
*/
|
||||
translations?: ComponentTranslation<"FileUpload">["dropzone"];
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useState } from "react";
|
||||
import { UseFileUploadProps } from "../../useFileUpload";
|
||||
|
||||
interface Props {
|
||||
upload: (fileList: FileList) => void;
|
||||
disabled: UseFileUploadProps["disabled"];
|
||||
}
|
||||
|
||||
export const useDropzone = ({ upload, disabled }: Props) => {
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
|
||||
const onDragEnter = () => {
|
||||
setIsDraggingOver(true);
|
||||
};
|
||||
|
||||
const onDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault(); // Prevents the browser from opening the file in a new tab
|
||||
};
|
||||
|
||||
const onDragLeave = () => {
|
||||
setIsDraggingOver(false);
|
||||
};
|
||||
|
||||
const onDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault(); // Prevents the browser from opening the file in a new tab
|
||||
setIsDraggingOver(false);
|
||||
|
||||
const fileList = event.dataTransfer.files;
|
||||
if (!fileList) {
|
||||
return;
|
||||
}
|
||||
|
||||
upload(fileList);
|
||||
};
|
||||
|
||||
return {
|
||||
isDraggingOver,
|
||||
onDragEnter: disabled ? undefined : onDragEnter,
|
||||
onDragOver: disabled ? undefined : onDragOver,
|
||||
onDragLeave: disabled ? undefined : onDragLeave,
|
||||
onDrop: disabled ? undefined : onDrop,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
import cl from "clsx";
|
||||
import React, { MouseEvent, forwardRef } from "react";
|
||||
import { ExclamationmarkTriangleIcon } from "@navikt/aksel-icons";
|
||||
import { BodyShort } from "../../../../typography";
|
||||
import { OverridableComponent } from "../../../../util";
|
||||
import { useFileUploadTranslation } from "../../FileUpload.context";
|
||||
import { useI18n } from "../../i18n/i18n.context";
|
||||
import { ComponentTranslation } from "../../i18n/i18n.types";
|
||||
import { FileItem } from "./Item.types";
|
||||
import ItemButton from "./ItemButton";
|
||||
import ItemIcon from "./ItemIcon";
|
||||
import ItemName from "./ItemName";
|
||||
import { formatFileSize } from "./utils/format-file-size";
|
||||
|
||||
export interface FileItemBaseProps {
|
||||
/**
|
||||
* Overrides html-tag
|
||||
* @default "div"
|
||||
*/
|
||||
as?: "div" | "li";
|
||||
/**
|
||||
* 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";
|
||||
/**
|
||||
* i18n-API for customizing texts and labels
|
||||
*/
|
||||
translations?: ComponentTranslation<"FileUpload">["item"];
|
||||
onRetry?: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
onDelete?: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
||||
type FileItemActionDelete = {
|
||||
onDelete: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
itemAction: "delete";
|
||||
};
|
||||
|
||||
type FileItemActionRetry = {
|
||||
onRetry: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
itemAction: "retry";
|
||||
};
|
||||
|
||||
type FileItemActionNone = {
|
||||
itemAction?: "none";
|
||||
};
|
||||
|
||||
type FileItemConditionalProps =
|
||||
| FileItemActionDelete
|
||||
| FileItemActionRetry
|
||||
| FileItemActionNone;
|
||||
|
||||
export type FileUploadItemProps = FileItemBaseProps &
|
||||
FileItemConditionalProps &
|
||||
React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const Item: OverridableComponent<FileUploadItemProps, HTMLDivElement> =
|
||||
forwardRef(
|
||||
(
|
||||
{
|
||||
as: Component = "div",
|
||||
file,
|
||||
status = "idle",
|
||||
onDelete,
|
||||
onRetry,
|
||||
error,
|
||||
className,
|
||||
href,
|
||||
onFileClick,
|
||||
itemAction = "delete",
|
||||
translations,
|
||||
...rest
|
||||
},
|
||||
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" && (
|
||||
<ItemButton
|
||||
file={file}
|
||||
onRetry={onRetry}
|
||||
onDelete={onDelete}
|
||||
action={itemAction}
|
||||
retryTitle={translate("item.retryButtonTitle")}
|
||||
deleteTitle={translate("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,52 @@
|
||||
import React from "react";
|
||||
import { ArrowsCirclepathIcon, TrashIcon } from "@navikt/aksel-icons";
|
||||
import { Button } from "../../../../button";
|
||||
import { FileItem } from "./Item.types";
|
||||
|
||||
interface Props {
|
||||
file: FileItem;
|
||||
onRetry?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onDelete?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
action: "delete" | "retry" | "none";
|
||||
retryTitle: string;
|
||||
deleteTitle: string;
|
||||
}
|
||||
|
||||
const ItemButton = ({
|
||||
onRetry,
|
||||
onDelete,
|
||||
action,
|
||||
retryTitle,
|
||||
deleteTitle,
|
||||
}: Props) => {
|
||||
if (action === "none") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (onRetry && action === "retry") {
|
||||
return (
|
||||
<Button
|
||||
className="navds-file-item__button"
|
||||
type="button"
|
||||
variant="tertiary-neutral"
|
||||
onClick={onRetry}
|
||||
icon={<ArrowsCirclepathIcon title={retryTitle} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (onDelete && action === "delete") {
|
||||
return (
|
||||
<Button
|
||||
className="navds-file-item__button"
|
||||
type="button"
|
||||
variant="tertiary-neutral"
|
||||
onClick={onDelete}
|
||||
icon={<TrashIcon title={deleteTitle} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ItemButton;
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from "react";
|
||||
import {
|
||||
FileCsvIcon,
|
||||
FileExcelIcon,
|
||||
FileIcon,
|
||||
FileImageIcon,
|
||||
FilePdfIcon,
|
||||
FileTextIcon,
|
||||
FileWordIcon,
|
||||
FileXMarkIcon,
|
||||
} from "@navikt/aksel-icons";
|
||||
import { Loader } from "../../../../loader";
|
||||
import { FileItem } from "./Item.types";
|
||||
|
||||
interface ItemIconProps {
|
||||
isLoading?: boolean;
|
||||
file: FileItem;
|
||||
showError: boolean;
|
||||
}
|
||||
|
||||
const iconProps = {
|
||||
fontSize: "2rem",
|
||||
"aria-hidden": true,
|
||||
};
|
||||
|
||||
function ItemIcon({ isLoading, file, showError }: ItemIconProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="navds-file-item__icon navds-file-item__icon--loading">
|
||||
<Loader size="large" />
|
||||
</div>
|
||||
);
|
||||
} else if (showError) {
|
||||
return (
|
||||
<div className="navds-file-item__icon">
|
||||
<FileXMarkIcon {...iconProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="navds-file-item__icon">
|
||||
<Icon file={file} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Icon({ file }: { file: FileItem }) {
|
||||
const extension = file.name.substring(file.name.lastIndexOf(".") + 1);
|
||||
|
||||
switch (extension) {
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
case "png":
|
||||
case "gif":
|
||||
case "webp":
|
||||
return <FileImageIcon {...iconProps} />;
|
||||
case "pdf":
|
||||
return <FilePdfIcon {...iconProps} />;
|
||||
case "txt":
|
||||
return <FileTextIcon {...iconProps} />;
|
||||
case "csv":
|
||||
return <FileCsvIcon {...iconProps} />;
|
||||
case "xls":
|
||||
case "xlsx":
|
||||
return <FileExcelIcon {...iconProps} />;
|
||||
case "doc":
|
||||
case "docx":
|
||||
return <FileWordIcon {...iconProps} />;
|
||||
default:
|
||||
return <FileIcon {...iconProps} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default ItemIcon;
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from "react";
|
||||
import { Link } from "../../../../link";
|
||||
import { FileItem } from "./Item.types";
|
||||
import { downloadFile } from "./utils/download-file";
|
||||
import { isNativeFile } from "./utils/file-type-checker";
|
||||
|
||||
interface Props {
|
||||
file: FileItem;
|
||||
href?: string;
|
||||
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
}
|
||||
|
||||
const ItemName = ({ file, href, onClick }: Props) => {
|
||||
if (onClick && href) {
|
||||
return (
|
||||
<Link href={href} onClick={onClick}>
|
||||
{file.name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<Link
|
||||
href="#"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onClick(event);
|
||||
}}
|
||||
>
|
||||
{file.name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (href) {
|
||||
return <Link href={href}>{file.name}</Link>;
|
||||
}
|
||||
|
||||
if (isNativeFile(file)) {
|
||||
return (
|
||||
<Link
|
||||
href="#"
|
||||
download={file.name}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
downloadFile(file);
|
||||
}}
|
||||
>
|
||||
{file.name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>{file.name}</span>;
|
||||
};
|
||||
|
||||
export default ItemName;
|
||||
@@ -0,0 +1,9 @@
|
||||
export const downloadFile = (file: File): void => {
|
||||
const a = document.createElement("a");
|
||||
const url = URL.createObjectURL(file);
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
a.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { FileItem } from "../Item.types";
|
||||
|
||||
export const isNativeFile = (fileItem: FileItem): fileItem is File =>
|
||||
"lastModified" in fileItem;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { formatFileSize } from "./format-file-size";
|
||||
|
||||
describe("format-file-size", () => {
|
||||
describe("with native File", () => {
|
||||
it('returns "0,01 MB" when file size is less than 0,01 MB', () => {
|
||||
const file = new File(["abc"], "file.txt");
|
||||
|
||||
expect(formatFileSize(file)).toBe("0,01 MB");
|
||||
});
|
||||
|
||||
it('returns "0,29 MB" when file size is 0,29 MB', () => {
|
||||
const file = new File(["abc".repeat(99_999)], "file.txt");
|
||||
|
||||
expect(formatFileSize(file)).toBe("0,29 MB");
|
||||
});
|
||||
|
||||
it('returns "> 500 MB" when file size is more than 500 MB', () => {
|
||||
const file = createLargeMockFile(600_000_000);
|
||||
|
||||
expect(formatFileSize(file)).toBe("> 500 MB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("with MetadataFile", () => {
|
||||
it("returns null when file size is undefined", () => {
|
||||
const file = {
|
||||
name: "myfile.txt",
|
||||
};
|
||||
|
||||
expect(formatFileSize(file)).toBeNull();
|
||||
});
|
||||
it('returns "0,01 MB" when file size is less than 0,01 MB', () => {
|
||||
const file = {
|
||||
name: "myfile.txt",
|
||||
size: 1,
|
||||
};
|
||||
|
||||
expect(formatFileSize(file)).toBe("0,01 MB");
|
||||
});
|
||||
|
||||
it('returns "0,96 MB" when file size is 0,96 MB', () => {
|
||||
const file = {
|
||||
name: "myfile.txt",
|
||||
size: 999_999,
|
||||
};
|
||||
|
||||
expect(formatFileSize(file)).toBe("0,96 MB");
|
||||
});
|
||||
|
||||
it('returns "> 500 MB" when file size is more than 500 MB', () => {
|
||||
const file = {
|
||||
name: "myfile.txt",
|
||||
size: 600_000_000,
|
||||
};
|
||||
|
||||
expect(formatFileSize(file)).toBe("> 500 MB");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createLargeMockFile(sizeInBytes: number): File {
|
||||
const chunkSize = 1024 * 1024; // 1MB chunk size
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
||||
for (let i = 0; i < sizeInBytes; i += chunkSize) {
|
||||
const size = Math.min(chunkSize, sizeInBytes - i);
|
||||
const chunk = new Uint8Array(size);
|
||||
chunk.fill("a".charCodeAt(0));
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const blob = new Blob(chunks, { type: "text/plain" });
|
||||
return new File([blob], "largeMockFile.txt", {
|
||||
type: "text/plain",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { FileItem } from "../Item.types";
|
||||
|
||||
const MAX_MEGA_BYTES = 500;
|
||||
|
||||
export function formatFileSize(file: FileItem): string | null {
|
||||
if (!file.size) {
|
||||
return null;
|
||||
}
|
||||
const megaBytes = file.size / (1024 * 1024);
|
||||
|
||||
if (megaBytes <= MAX_MEGA_BYTES) {
|
||||
return formatter.format(megaBytes);
|
||||
}
|
||||
|
||||
return `> ${MAX_MEGA_BYTES} MB`;
|
||||
}
|
||||
|
||||
const formatter = new Intl.NumberFormat("nb-NO", {
|
||||
style: "unit",
|
||||
unit: "megabyte",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
// @ts-expect-error - Looks like roundingMode hasn't been added to TypeScript yet
|
||||
roundingMode: "ceil",
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useRef } from "react";
|
||||
import { useMergeRefs } from "../../util/hooks";
|
||||
import { FileUploadBaseProps } from "./FileUpload.types";
|
||||
import { validateFiles } from "./utils/validate-files";
|
||||
|
||||
export interface UseFileUploadProps
|
||||
extends Omit<FileUploadBaseProps, "fileLimit"> {
|
||||
ref: React.ForwardedRef<HTMLInputElement>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const useFileUpload = ({
|
||||
ref,
|
||||
accept,
|
||||
onSelect,
|
||||
validator,
|
||||
maxSizeInBytes,
|
||||
disabled,
|
||||
}: UseFileUploadProps) => {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const mergedRef = useMergeRefs(inputRef, ref);
|
||||
|
||||
const upload = (fileList: FileList) => {
|
||||
const { files, partitionedFiles } = validateFiles(
|
||||
Array.from(fileList),
|
||||
accept,
|
||||
validator,
|
||||
maxSizeInBytes,
|
||||
);
|
||||
|
||||
onSelect(files, partitionedFiles);
|
||||
};
|
||||
|
||||
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const fileList = event.target.files;
|
||||
if (!fileList) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!disabled) {
|
||||
upload(fileList);
|
||||
}
|
||||
|
||||
// Resets the value to make it is possible to upload the same file several consecutive times
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
return {
|
||||
upload,
|
||||
onChange,
|
||||
inputRef,
|
||||
mergedRef,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { isAcceptedFileType } from "./is-accepted-file-type";
|
||||
|
||||
const txtFile = () =>
|
||||
new File(["foo"], "foo.txt", {
|
||||
type: "text/plain",
|
||||
});
|
||||
|
||||
describe("isAcceptedFileType", () => {
|
||||
test("returns true when accept is undefined", () => {
|
||||
expect(isAcceptedFileType(txtFile(), undefined)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true when accept is empty string", () => {
|
||||
expect(isAcceptedFileType(txtFile(), "")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true when file matches accepted extensions", () => {
|
||||
expect(isAcceptedFileType(txtFile(), ".txt, .pdf")).toBe(true);
|
||||
});
|
||||
|
||||
test("works as expected when there is no space after comma in accept", () => {
|
||||
expect(isAcceptedFileType(txtFile(), ".txt,.pdf")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when file does not match the accepted extensions", () => {
|
||||
expect(isAcceptedFileType(txtFile(), ".xlsx, .pdf")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true when file matches the accepted exact mime types", () => {
|
||||
expect(isAcceptedFileType(txtFile(), "application/pdf, text/plain")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("returns false when file does not the accepted exact mime types", () => {
|
||||
expect(isAcceptedFileType(txtFile(), "application/pdf, text/csv")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("returns true when file matches the accepted wildcard mime types", () => {
|
||||
expect(isAcceptedFileType(txtFile(), "application/*, text/*")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when file does not the accepted wildcard mime types", () => {
|
||||
expect(isAcceptedFileType(txtFile(), "application/*, image/*")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true if matches extension, but not exact or wildcard mime type", () => {
|
||||
expect(
|
||||
isAcceptedFileType(txtFile(), "application/*, image/*, .txt, audio/mpeg"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true if matches exact mime type, but not extension or wildcard mime type", () => {
|
||||
expect(
|
||||
isAcceptedFileType(
|
||||
txtFile(),
|
||||
"application/*, text/plain, .jpg, audio/mpeg",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true if matches wildcard mime type, but not extension or exact mime type", () => {
|
||||
expect(
|
||||
isAcceptedFileType(txtFile(), "application/*, text/*, .jpg, audio/mpeg"),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
export function isAcceptedFileType(
|
||||
file: File,
|
||||
accept: string | undefined,
|
||||
): boolean {
|
||||
if (!accept) {
|
||||
return true;
|
||||
}
|
||||
const mimeType = file.type;
|
||||
const acceptedTypes = accept.split(",");
|
||||
|
||||
return acceptedTypes.some((type) => {
|
||||
const validType = type.trim();
|
||||
const isExtensionType = validType.startsWith(".");
|
||||
const isWildcardMimeType = validType.endsWith("/*");
|
||||
|
||||
if (isExtensionType) {
|
||||
return file.name.toLowerCase().endsWith(validType.toLowerCase());
|
||||
} else if (isWildcardMimeType) {
|
||||
const baseMimeType = mimeType.replace(/\/.*$/, "");
|
||||
const baseValidType = validType.replace(/\/.*$/, "");
|
||||
return baseMimeType === baseValidType;
|
||||
}
|
||||
return mimeType === validType;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { isAcceptedSize } from "./is-accepted-size";
|
||||
|
||||
describe("isAcceptedSize", () => {
|
||||
it("should return true if maxSize is less than or equal to 0", () => {
|
||||
const file = new File([new Array(5000).join("a")], "filename.txt", {
|
||||
type: "text/plain",
|
||||
});
|
||||
expect(isAcceptedSize(file, -1)).toBe(true);
|
||||
expect(isAcceptedSize(file, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true if file size is less than or equal to maxSize", () => {
|
||||
const file = new File([new Array(5000).join("a")], "filename.txt", {
|
||||
type: "text/plain",
|
||||
});
|
||||
expect(isAcceptedSize(file, 5000)).toBe(true);
|
||||
expect(isAcceptedSize(file, 6000)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if file size is greater than maxSize", () => {
|
||||
const file = new File([new Array(5000).join("a")], "filename.txt", {
|
||||
type: "text/plain",
|
||||
});
|
||||
expect(isAcceptedSize(file, 4000)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export function isAcceptedSize(file: File, maxSize: number = -1): boolean {
|
||||
if (maxSize <= 0 || file.size <= maxSize) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { validateFiles } from "./validate-files";
|
||||
|
||||
const createTxtFile = (name: string = "foo.txt") =>
|
||||
new File(["foo"], name, {
|
||||
type: "text/plain",
|
||||
});
|
||||
const createCsvFile = (name: string = "bar.csv") =>
|
||||
new File(["abc,123"], name, {
|
||||
type: "text/csv",
|
||||
});
|
||||
|
||||
describe("partitionFiles", () => {
|
||||
test("accepts all files when accept parameter is undefined", () => {
|
||||
const files = [createTxtFile(), createCsvFile()];
|
||||
const {
|
||||
partitionedFiles: { accepted, rejected },
|
||||
} = validateFiles(files);
|
||||
|
||||
expect(accepted.length).toBe(2);
|
||||
expect(rejected.length).toBe(0);
|
||||
});
|
||||
|
||||
test("accepts all files when accept parameter is empty", () => {
|
||||
const files = [createTxtFile(), createCsvFile()];
|
||||
const {
|
||||
partitionedFiles: { accepted, rejected },
|
||||
} = validateFiles(files, "");
|
||||
|
||||
expect(accepted.length).toBe(2);
|
||||
expect(rejected.length).toBe(0);
|
||||
});
|
||||
|
||||
test("rejects file that does not match accept parameter", () => {
|
||||
const txtFileName = "foo.txt";
|
||||
const csvFileName = "bar.csv";
|
||||
const files = [createTxtFile(txtFileName), createCsvFile(csvFileName)];
|
||||
const {
|
||||
partitionedFiles: { accepted, rejected },
|
||||
} = validateFiles(files, ".txt");
|
||||
|
||||
expect(accepted.length).toBe(1);
|
||||
expect(rejected.length).toBe(1);
|
||||
expect(accepted[0].name).toBe(txtFileName);
|
||||
expect(rejected[0].file.name).toBe(csvFileName);
|
||||
});
|
||||
|
||||
test("rejects file that does not pass validator", () => {
|
||||
const txtFileName = "foo.txt";
|
||||
const csvFileName = "bar.csv";
|
||||
const csvFile = createCsvFile(csvFileName);
|
||||
const txtFile = createTxtFile(txtFileName);
|
||||
const files = [txtFile, csvFile];
|
||||
const validator = (file: File) => {
|
||||
if (file.name === txtFile.name) {
|
||||
return true;
|
||||
}
|
||||
return "custom validation error";
|
||||
};
|
||||
const {
|
||||
partitionedFiles: { accepted, rejected },
|
||||
} = validateFiles(files, undefined, validator);
|
||||
|
||||
expect(accepted.length).toBe(1);
|
||||
expect(rejected.length).toBe(1);
|
||||
expect(accepted[0].name).toBe(txtFileName);
|
||||
expect(rejected[0].file.name).toBe(csvFileName);
|
||||
expect(rejected[0].reasons).toEqual(["custom validation error"]);
|
||||
});
|
||||
|
||||
test("rejects file that passes accept parameter but does not pass validator", () => {
|
||||
const txtFileName = "foo.txt";
|
||||
const csvFileName = "bar.csv";
|
||||
const csvFile = createCsvFile(csvFileName);
|
||||
const txtFile = createTxtFile(txtFileName);
|
||||
const files = [txtFile, csvFile];
|
||||
const validator = (file: File) => {
|
||||
if (file.name === txtFile.name) {
|
||||
return true;
|
||||
}
|
||||
return "custom validation error";
|
||||
};
|
||||
const {
|
||||
partitionedFiles: { accepted, rejected },
|
||||
} = validateFiles(files, ".txt, .csv", validator);
|
||||
|
||||
expect(accepted.length).toBe(1);
|
||||
expect(rejected.length).toBe(1);
|
||||
expect(accepted[0].name).toBe(txtFileName);
|
||||
expect(rejected[0].file.name).toBe(csvFileName);
|
||||
});
|
||||
|
||||
test("rejects file that passes validator but does not pass accept parameter", () => {
|
||||
const txtFileName = "foo.txt";
|
||||
const csvFileName = "bar.csv";
|
||||
const csvFile = createCsvFile(csvFileName);
|
||||
const txtFile = createTxtFile(txtFileName);
|
||||
const files = [txtFile, csvFile];
|
||||
const validator = (file: File) => {
|
||||
if (file.name === csvFile.name || file.name === txtFile.name) {
|
||||
return true;
|
||||
}
|
||||
return "custom validation error";
|
||||
};
|
||||
const {
|
||||
partitionedFiles: { accepted, rejected },
|
||||
} = validateFiles(files, ".csv", validator);
|
||||
|
||||
expect(accepted.length).toBe(1);
|
||||
expect(rejected.length).toBe(1);
|
||||
expect(accepted[0].name).toBe(csvFileName);
|
||||
expect(rejected[0].file.name).toBe(txtFileName);
|
||||
expect(rejected[0].reasons).toEqual(["fileType"]);
|
||||
});
|
||||
|
||||
test("rejects file that is too large", () => {
|
||||
const txtFileName = "foo.txt";
|
||||
const csvFileName = "bar.csv";
|
||||
const csvFile = createCsvFile(csvFileName);
|
||||
const txtFile = createTxtFile(txtFileName);
|
||||
const files = [txtFile, csvFile];
|
||||
const maxSizeInBytes = 5;
|
||||
const {
|
||||
partitionedFiles: { accepted, rejected },
|
||||
} = validateFiles(files, undefined, undefined, maxSizeInBytes);
|
||||
|
||||
expect(accepted.length).toBe(1);
|
||||
expect(rejected.length).toBe(1);
|
||||
expect(accepted[0].name).toBe(txtFileName);
|
||||
expect(rejected[0].file.name).toBe(csvFileName);
|
||||
expect(rejected[0].reasons).toEqual(["fileSize"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
FileObject,
|
||||
FileUploadBaseProps,
|
||||
FilesPartitioned,
|
||||
fileRejectionReason,
|
||||
} from "../FileUpload.types";
|
||||
import { isAcceptedFileType } from "./is-accepted-file-type";
|
||||
import { isAcceptedSize } from "./is-accepted-size";
|
||||
|
||||
export const validateFiles = (
|
||||
files: File[],
|
||||
accept?: string,
|
||||
validator?: FileUploadBaseProps["validator"],
|
||||
maxSizeInBytes: number = -1,
|
||||
) => {
|
||||
const allFiles: FileObject[] = [];
|
||||
const accepted: File[] = [];
|
||||
const rejected: FilesPartitioned["rejected"] = [];
|
||||
|
||||
files.forEach((file) => {
|
||||
const acceptedFileType = isAcceptedFileType(file, accept);
|
||||
const acceptedFileSize = isAcceptedSize(file, maxSizeInBytes);
|
||||
const customValidation = validator ? validator(file) : true;
|
||||
|
||||
const reasons: FilesPartitioned["rejected"][0]["reasons"] = [];
|
||||
if (customValidation !== true) {
|
||||
reasons.push(customValidation);
|
||||
}
|
||||
|
||||
if (!acceptedFileType) {
|
||||
reasons.push(fileRejectionReason.FileType);
|
||||
}
|
||||
|
||||
if (!acceptedFileSize) {
|
||||
reasons.push(fileRejectionReason.FileSize);
|
||||
}
|
||||
|
||||
allFiles.push({
|
||||
file,
|
||||
error: reasons.length > 0,
|
||||
reasons,
|
||||
});
|
||||
|
||||
if (reasons.length === 0) {
|
||||
accepted.push(file);
|
||||
return;
|
||||
}
|
||||
|
||||
rejected.push({
|
||||
file,
|
||||
reasons,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
files: allFiles,
|
||||
partitionedFiles: {
|
||||
accepted,
|
||||
rejected,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -49,6 +49,7 @@ export const Default = (props) => {
|
||||
</RadioGroup>
|
||||
);
|
||||
};
|
||||
|
||||
Default.args = {
|
||||
controlled: false,
|
||||
error: false,
|
||||
|
||||
@@ -5,15 +5,15 @@ export interface RadioProps
|
||||
extends Omit<FormFieldProps, "error" | "errorId" | "readOnly">,
|
||||
Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "value"> {
|
||||
/**
|
||||
* Radio label
|
||||
* Radio label.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* The value of the HTML element
|
||||
* The value of the HTML element.
|
||||
*/
|
||||
value: any;
|
||||
/**
|
||||
* Adds a description to extend labling of Radio
|
||||
* Adds a description to extend labeling of Radio.
|
||||
*/
|
||||
description?: string;
|
||||
}
|
||||
@@ -28,47 +28,51 @@ export interface SearchProps
|
||||
/**
|
||||
* Search label.
|
||||
*
|
||||
* Will be hidden by default, is required for accessibility reasons.
|
||||
* Will be hidden by default and is required for WCAG compliance.
|
||||
*/
|
||||
label: React.ReactNode;
|
||||
/**
|
||||
* Shows label and description for screenreaders-only
|
||||
* Shows label and description for screenreaders only.
|
||||
* @default true
|
||||
*/
|
||||
hideLabel?: boolean;
|
||||
/**
|
||||
* Callback for value-change in input
|
||||
* Callback for value-change in input.
|
||||
*/
|
||||
onChange?: (value: string) => void;
|
||||
/**
|
||||
* Callback for click on clear-button or Escape keydown
|
||||
* Callback for click on clear-button or Escape keydown.
|
||||
*/
|
||||
onClear?: (e: SearchClearEvent) => void;
|
||||
/**
|
||||
* Callback for Search-button submit
|
||||
* Callback for Search-button submit.
|
||||
*/
|
||||
onSearchClick?: (value: string) => void;
|
||||
/**
|
||||
* aria-label on clear button
|
||||
* Sets the `aria-label` for the clear button.
|
||||
* @default "Tøm"
|
||||
*/
|
||||
clearButtonLabel?: string;
|
||||
/**
|
||||
* If false, removes clear-button option from input.
|
||||
* Removes clear-button if `false`.
|
||||
* @default true
|
||||
*/
|
||||
clearButton?: boolean;
|
||||
/**
|
||||
* Changes button-variant, "simple" removes button
|
||||
* Changes button-variant.
|
||||
*
|
||||
* - "primary": When this is the main function of the page.
|
||||
* - "secondary": This is probably the one you want if in doubt.
|
||||
* - "simple": Removes the search button.
|
||||
* @default "primary"
|
||||
*/
|
||||
variant?: "primary" | "secondary" | "simple";
|
||||
/**
|
||||
* Exposes the HTML size attribute
|
||||
* Exposes the HTML size attribute. Specifies the width of the element, in characters.
|
||||
*/
|
||||
htmlSize?: number | string;
|
||||
/*
|
||||
* Exposes role attribute
|
||||
* Exposes role attribute.
|
||||
*/
|
||||
role?: string;
|
||||
}
|
||||
@@ -88,7 +92,7 @@ interface SearchComponent
|
||||
*
|
||||
* @example
|
||||
* ```jsx
|
||||
* <form>
|
||||
* <form role="search">
|
||||
* <Search label="Søk alle NAV sine sider" variant="primary" />
|
||||
* </form>
|
||||
* ```
|
||||
|
||||
@@ -53,7 +53,7 @@ export interface SwitchProps
|
||||
*/
|
||||
position?: "left" | "right";
|
||||
/**
|
||||
* Adds a description to extend labling of Switch.
|
||||
* Adds a description to extend labeling of Switch.
|
||||
*/
|
||||
description?: string;
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const [checked, setChecked] = useState(
|
||||
const [_checked, setChecked] = useState(
|
||||
defaultChecked ?? checkedProp ?? false,
|
||||
);
|
||||
|
||||
@@ -93,6 +93,8 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
|
||||
checkedProp !== undefined && setChecked(checkedProp);
|
||||
}, [checkedProp]);
|
||||
|
||||
const checked = checkedProp ?? _checked;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cl(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Meta } from "@storybook/react";
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { VStack } from "../../layout/stack";
|
||||
import Switch from "./Switch";
|
||||
|
||||
export default {
|
||||
@@ -31,70 +32,59 @@ export default {
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
export const Default = {
|
||||
render: (props) => {
|
||||
return <Switch {...props}>Label text</Switch>;
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof Switch>;
|
||||
|
||||
type Story = StoryObj<typeof Switch>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
position: "right",
|
||||
children: "Label text",
|
||||
},
|
||||
};
|
||||
|
||||
export const Small = () => {
|
||||
return (
|
||||
<div className="colgap">
|
||||
<Switch size="small">Label text</Switch>
|
||||
<Switch size="small" position="right">
|
||||
Label text
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
children: "Label text",
|
||||
size: "small",
|
||||
},
|
||||
};
|
||||
|
||||
export const Description = () => {
|
||||
return (
|
||||
<div className="colgap">
|
||||
<Switch size="small" description="Cillum sint exercitation ut cillum.">
|
||||
Label text
|
||||
</Switch>
|
||||
<Switch
|
||||
size="small"
|
||||
position="right"
|
||||
description="Cillum sint exercitation ut cillum."
|
||||
>
|
||||
Label text
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
export const Description: Story = {
|
||||
args: {
|
||||
children: "Label text",
|
||||
description: "Cillum sint exercitation ut cillum.",
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading = () => {
|
||||
return (
|
||||
<div className="colgap">
|
||||
export const Loading: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div className="colgap">
|
||||
<Switch loading>Label text</Switch>
|
||||
<div className="colgap">
|
||||
<Switch loading>Label text</Switch>
|
||||
|
||||
<Switch checked loading>
|
||||
Label text
|
||||
</Switch>
|
||||
<Switch checked loading>
|
||||
Label text
|
||||
</Switch>
|
||||
</div>
|
||||
<div className="colgap">
|
||||
<Switch loading size="small">
|
||||
Label text
|
||||
</Switch>
|
||||
<Switch checked loading size="small">
|
||||
Label text
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
<div className="colgap">
|
||||
<Switch loading size="small">
|
||||
Label text
|
||||
</Switch>
|
||||
<Switch checked loading size="small">
|
||||
Label text
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled = () => {
|
||||
return (
|
||||
export const Disabled: Story = {
|
||||
render: () => (
|
||||
<div className="colgap">
|
||||
<Switch disabled>Label text</Switch>
|
||||
|
||||
@@ -102,11 +92,11 @@ export const Disabled = () => {
|
||||
Label text
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
),
|
||||
};
|
||||
|
||||
export const HideLabel = () => {
|
||||
return (
|
||||
export const HideLabel: Story = {
|
||||
render: () => (
|
||||
<div className="colgap">
|
||||
<Switch hideLabel>Label text</Switch>
|
||||
|
||||
@@ -114,11 +104,11 @@ export const HideLabel = () => {
|
||||
Label text
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
),
|
||||
};
|
||||
|
||||
export const Readonly = () => {
|
||||
return (
|
||||
export const Readonly: Story = {
|
||||
render: () => (
|
||||
<div className="colgap">
|
||||
<Switch description="Slår av alle notifikasjoner" readOnly>
|
||||
Notifikasjoner
|
||||
@@ -128,5 +118,54 @@ export const Readonly = () => {
|
||||
Notifikasjoner
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
),
|
||||
};
|
||||
|
||||
export const Chromatic: Story = {
|
||||
render: () => (
|
||||
<VStack gap="4" align="start">
|
||||
<div>
|
||||
<h2>Default</h2>
|
||||
{/* @ts-expect-error Args are Partial here */}
|
||||
<Switch {...Default.args} />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Small</h2>
|
||||
{/* @ts-expect-error Args are Partial here */}
|
||||
<Switch {...Small.args} position="left" />
|
||||
{/* @ts-expect-error Args are Partial here */}
|
||||
<Switch {...Small.args} position="right" />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Description</h2>
|
||||
{/* @ts-expect-error Args are Partial here */}
|
||||
<Switch {...Description.args} position="left" />
|
||||
{/* @ts-expect-error Args are Partial here */}
|
||||
<Switch {...Description.args} position="right" />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Loading</h2>
|
||||
{/* @ts-expect-error Args are Partial here */}
|
||||
<Loading.render />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Disabled</h2>
|
||||
{/* @ts-expect-error Args are Partial here */}
|
||||
<Disabled.render />
|
||||
</div>
|
||||
<div>
|
||||
<h2>HideLabel</h2>
|
||||
{/* @ts-expect-error Args are Partial here */}
|
||||
<HideLabel.render />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Readonly</h2>
|
||||
{/* @ts-expect-error Args are Partial here */}
|
||||
<Readonly.render />
|
||||
</div>
|
||||
</VStack>
|
||||
),
|
||||
parameters: {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import { FieldsetContext } from "./fieldset/context";
|
||||
|
||||
export interface FormFieldProps {
|
||||
/**
|
||||
* Adds a description to extend labling of the field.
|
||||
* Adds a description to extend labeling of the field.
|
||||
* Error message for element.
|
||||
*/
|
||||
error?: React.ReactNode;
|
||||
@@ -24,7 +24,7 @@ export interface FormFieldProps {
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Adds a description to extend labling of a field.
|
||||
* Adds a description to extend labeling of a field.
|
||||
*/
|
||||
description?: React.ReactNode;
|
||||
/**
|
||||
|
||||
@@ -41,16 +41,14 @@ export const PosterVariants = {
|
||||
render: () => (
|
||||
<VStack gap="6" align="start">
|
||||
<GuidePanel>
|
||||
If you exclude the <code>poster</code> prop, you will get the poster
|
||||
variant on mobile (<480px) and the non-poster variant otherwise.
|
||||
If you exclude the `poster` prop, you will get the poster variant on
|
||||
mobile (<480px) and the non-poster variant otherwise.
|
||||
</GuidePanel>
|
||||
<GuidePanel poster>
|
||||
Use the <code>poster</code> prop to get the poster variant on all
|
||||
viewports.
|
||||
Use the `poster` prop to get the poster variant on all viewports.
|
||||
</GuidePanel>
|
||||
<GuidePanel poster={false}>
|
||||
Set <code>poster=false</code> to get the non-poster variant on all
|
||||
viewports.
|
||||
Set `poster=false` to get the non-poster variant on all viewports.
|
||||
</GuidePanel>
|
||||
</VStack>
|
||||
),
|
||||
|
||||
@@ -136,6 +136,20 @@ export { Select, type SelectProps } from "./form/select";
|
||||
export { Switch, type SwitchProps } from "./form/switch";
|
||||
export { Textarea, type TextareaProps } from "./form/textarea";
|
||||
export { TextField, type TextFieldProps } from "./form/textfield";
|
||||
export {
|
||||
UNSAFE_FileUpload,
|
||||
type FileUploadDropzoneProps,
|
||||
type FileUploadTriggerProps,
|
||||
type FileObject,
|
||||
type FileRejected,
|
||||
type FileAccepted,
|
||||
type FileRejectedPartitioned,
|
||||
type FilesPartitioned,
|
||||
type FileRejectionReason,
|
||||
type FileUploadItemProps,
|
||||
type FileItem,
|
||||
type FileMetadata,
|
||||
} from "./form/file-upload";
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
|
||||
@@ -2,10 +2,12 @@ import { Meta } from "@storybook/react";
|
||||
import React from "react";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
CogIcon,
|
||||
ExternalLinkIcon,
|
||||
LeaveIcon,
|
||||
MenuGridIcon,
|
||||
} from "@navikt/aksel-icons";
|
||||
import { BodyLong, BodyShort, Detail } from "@navikt/ds-react";
|
||||
import { BodyLong, BodyShort, Detail, Spacer } from "@navikt/ds-react";
|
||||
import { Dropdown } from "../dropdown";
|
||||
import InternalHeader from "./InternalHeader";
|
||||
|
||||
@@ -102,10 +104,11 @@ export const Default = {
|
||||
<Dropdown.Menu.Divider />
|
||||
<Dropdown.Menu.List>
|
||||
<Dropdown.Menu.List.Item as="a" href="/#settings">
|
||||
Innstillinger
|
||||
Innstillinger <Spacer />{" "}
|
||||
<CogIcon aria-hidden fontSize="1.5rem" />
|
||||
</Dropdown.Menu.List.Item>
|
||||
<Dropdown.Menu.List.Item onClick={() => console.log("logg ut")}>
|
||||
Logg ut
|
||||
Logg ut <Spacer /> <LeaveIcon aria-hidden fontSize="1.5rem" />
|
||||
</Dropdown.Menu.List.Item>
|
||||
</Dropdown.Menu.List>
|
||||
</Dropdown.Menu>
|
||||
@@ -180,10 +183,10 @@ export const UserWithMenu = () => (
|
||||
<Dropdown.Menu.Divider />
|
||||
<Dropdown.Menu.List>
|
||||
<Dropdown.Menu.List.Item as="a" href="/#settings">
|
||||
Innstillinger
|
||||
Innstillinger <Spacer /> <CogIcon aria-hidden fontSize="1.5rem" />
|
||||
</Dropdown.Menu.List.Item>
|
||||
<Dropdown.Menu.List.Item onClick={() => console.log("logg ut")}>
|
||||
Logg ut
|
||||
Logg ut <Spacer /> <LeaveIcon aria-hidden fontSize="1.5rem" />
|
||||
</Dropdown.Menu.List.Item>
|
||||
</Dropdown.Menu.List>
|
||||
</Dropdown.Menu>
|
||||
|
||||
@@ -32,13 +32,6 @@ export interface LoaderProps extends Omit<SVGProps<SVGSVGElement>, "ref"> {
|
||||
* @default "neutral"
|
||||
*/
|
||||
variant?: "neutral" | "interaction" | "inverted";
|
||||
/**
|
||||
* Allows getting a ref to the component instance.
|
||||
* Once the component unmounts, React will set `ref.current` to `null` (or call the ref with `null` if you passed a callback ref).
|
||||
*
|
||||
* @see https://react.dev/learn/referencing-values-with-refs#refs-and-the-dom
|
||||
*/
|
||||
ref?: React.Ref<SVGSVGElement>;
|
||||
}
|
||||
|
||||
/* Workaround for @types/react v17/v18 feil */
|
||||
|
||||
@@ -238,7 +238,11 @@ export const Modal = forwardRef<HTMLDialogElement, ModalProps>(
|
||||
level="1"
|
||||
id={ariaLabelId}
|
||||
>
|
||||
<span className="navds-modal__header-icon">{header.icon}</span>
|
||||
{header.icon && (
|
||||
<span className="navds-modal__header-icon">
|
||||
{header.icon}
|
||||
</span>
|
||||
)}
|
||||
{header.heading}
|
||||
</Heading>
|
||||
</ModalHeader>
|
||||
|
||||
@@ -1,65 +1,91 @@
|
||||
import { Meta, StoryFn } from "@storybook/react";
|
||||
import React, { useState } from "react";
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import { fn } from "@storybook/test";
|
||||
import React from "react";
|
||||
import { ReadMore } from ".";
|
||||
import { VStack } from "../layout/stack";
|
||||
|
||||
const meta: Meta<typeof ReadMore> = {
|
||||
export default {
|
||||
title: "ds-react/ReadMore",
|
||||
component: ReadMore,
|
||||
};
|
||||
export default meta;
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof ReadMore>;
|
||||
|
||||
export const Default: StoryFn<{
|
||||
controlled: boolean;
|
||||
size: "medium" | "small";
|
||||
defaultOpen: boolean;
|
||||
}> = (props) => {
|
||||
const [state, setState] = useState(false);
|
||||
type Story = StoryObj<typeof ReadMore>;
|
||||
|
||||
return (
|
||||
<ReadMore
|
||||
open={props.controlled ? state : undefined}
|
||||
onClick={() => setState((x) => !x)}
|
||||
header="Grunnen til at vi spør om dette og i tillegg ber om vedlegg"
|
||||
size={props.size}
|
||||
onOpenChange={console.log}
|
||||
defaultOpen={props.defaultOpen}
|
||||
>
|
||||
<div style={{ maxWidth: 300 }}>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Mollitia,
|
||||
tempore corporis exercitationem minus dignissimos eius aspernatur fugiat
|
||||
iusto.
|
||||
</div>
|
||||
</ReadMore>
|
||||
);
|
||||
};
|
||||
Default.args = {
|
||||
controlled: false,
|
||||
size: "medium",
|
||||
defaultOpen: false,
|
||||
};
|
||||
Default.argTypes = {
|
||||
size: {
|
||||
options: ["medium", "small"],
|
||||
control: { type: "radio" },
|
||||
const Content = (
|
||||
<div style={{ maxWidth: 300 }}>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Mollitia, tempore
|
||||
corporis exercitationem minus dignissimos eius aspernatur fugiat iusto.
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
size: "medium",
|
||||
defaultOpen: false,
|
||||
onOpenChange: fn(),
|
||||
children: Content,
|
||||
header: "Grunnen til at vi spør om dette og i tillegg ber om vedlegg",
|
||||
},
|
||||
argTypes: {
|
||||
size: {
|
||||
options: ["medium", "small"],
|
||||
control: { type: "radio" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Small = () => (
|
||||
<ReadMore
|
||||
header="Grunnen til at vi spør om dette og i tillegg ber om vedlegg"
|
||||
size="small"
|
||||
>
|
||||
Command station, this is ST 321. Code Clearance Blue. We're starting
|
||||
our approach. Deactivate the security shield.
|
||||
</ReadMore>
|
||||
);
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
size: "small",
|
||||
},
|
||||
};
|
||||
|
||||
export const Open = () => (
|
||||
<ReadMore
|
||||
header="Grunnen til at vi spør om dette og i tillegg ber om vedlegg"
|
||||
defaultOpen
|
||||
>
|
||||
Command station, this is ST 321. Code Clearance Blue. We're starting
|
||||
our approach. Deactivate the security shield.
|
||||
</ReadMore>
|
||||
);
|
||||
export const DefaultOpen: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
defaultOpen: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Open: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
open: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Chromatic: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<VStack gap="4">
|
||||
<div>
|
||||
<h2>Default</h2>
|
||||
{/* @ts-expect-error Args are partial, leading to required prop mismatch */}
|
||||
<ReadMore {...Default.args} />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Small</h2>
|
||||
{/* @ts-expect-error Args are partial, leading to required prop mismatch */}
|
||||
<ReadMore {...Small.args} />
|
||||
</div>
|
||||
<div>
|
||||
<h2>DefaultOpen</h2>
|
||||
{/* @ts-expect-error Args are partial, leading to required prop mismatch */}
|
||||
<ReadMore {...DefaultOpen.args} />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Controlled open</h2>
|
||||
{/* @ts-expect-error Args are partial, leading to required prop mismatch */}
|
||||
<ReadMore {...Open.args} />
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
};
|
||||
@@ -15,12 +15,7 @@ export interface DataCellProps
|
||||
textSize?: "medium" | "small";
|
||||
}
|
||||
|
||||
export interface DataCellType
|
||||
extends React.ForwardRefExoticComponent<
|
||||
DataCellProps & React.RefAttributes<HTMLTableCellElement>
|
||||
> {}
|
||||
|
||||
export const DataCell: DataCellType = forwardRef(
|
||||
export const DataCell = forwardRef<HTMLTableCellElement, DataCellProps>(
|
||||
({ className, children = "", align, textSize, ...rest }, ref) => {
|
||||
return (
|
||||
<BodyShort
|
||||
|
||||
@@ -4,18 +4,17 @@ import React from "react";
|
||||
import { VStack } from "../../layout/stack";
|
||||
import BodyLong from "../BodyLong";
|
||||
|
||||
const meta = {
|
||||
const meta: Meta<typeof BodyLong> = {
|
||||
title: "ds-react/Typography/BodyLong",
|
||||
component: BodyLong,
|
||||
decorators: [(story) => <div style={{ maxWidth: "700px" }}>{story()}</div>],
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof BodyLong>;
|
||||
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
type Story = StoryObj<typeof BodyLong>;
|
||||
|
||||
const lorem =
|
||||
"Hvis du ikke bor sammen med begge foreldrene dine, kan du ha rett til barnebidrag fra en eller begge foreldre mens du fullfører videregående skole eller tilsvarende.";
|
||||
|
||||
@@ -4,18 +4,17 @@ import React from "react";
|
||||
import { VStack } from "../../layout/stack";
|
||||
import { BodyShort } from "../BodyShort";
|
||||
|
||||
const meta = {
|
||||
const meta: Meta<typeof BodyShort> = {
|
||||
title: "ds-react/Typography/BodyShort",
|
||||
component: BodyShort,
|
||||
decorators: [(story) => <div style={{ maxWidth: "250px" }}>{story()}</div>],
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof BodyShort>;
|
||||
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
type Story = StoryObj<typeof BodyShort>;
|
||||
|
||||
const lorem = "Du må gjøre en filtrering for å se brukere i listen.";
|
||||
|
||||
|
||||
@@ -4,18 +4,17 @@ import React from "react";
|
||||
import { VStack } from "../../layout/stack";
|
||||
import Detail from "../Detail";
|
||||
|
||||
const meta = {
|
||||
const meta: Meta<typeof Detail> = {
|
||||
title: "ds-react/Typography/Detail",
|
||||
component: Detail,
|
||||
decorators: [(story) => <div style={{ maxWidth: "200px" }}>{story()}</div>],
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof Detail>;
|
||||
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
type Story = StoryObj<typeof Detail>;
|
||||
|
||||
const lorem = "Du må gjøre en filtrering for å se brukere i listen.";
|
||||
|
||||
|
||||
@@ -4,17 +4,16 @@ import React from "react";
|
||||
import { VStack } from "../../layout/stack";
|
||||
import ErrorMessage from "../ErrorMessage";
|
||||
|
||||
const meta = {
|
||||
const meta: Meta<typeof ErrorMessage> = {
|
||||
title: "ds-react/Typography/ErrorMessage",
|
||||
component: ErrorMessage,
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof ErrorMessage>;
|
||||
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
type Story = StoryObj<typeof ErrorMessage>;
|
||||
|
||||
const lorem =
|
||||
"Du må fylle ut: Oppgi årsaken til at du har ventet mer enn 6 måneder med å søke om refusjon";
|
||||
|
||||
@@ -4,18 +4,17 @@ import { default as React } from "react";
|
||||
import { VStack } from "../../layout/stack";
|
||||
import Heading from "../Heading";
|
||||
|
||||
const meta = {
|
||||
const meta: Meta<typeof Heading> = {
|
||||
title: "ds-react/Typography/Heading",
|
||||
component: Heading,
|
||||
decorators: [(story) => <div style={{ maxWidth: "300px" }}>{story()}</div>],
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof Heading>;
|
||||
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
type Story = StoryObj<typeof Heading>;
|
||||
|
||||
const lorem = "Hva kan vi hjelpe deg med?";
|
||||
|
||||
|
||||
@@ -4,18 +4,17 @@ import React from "react";
|
||||
import { VStack } from "../../layout/stack";
|
||||
import Label from "../Label";
|
||||
|
||||
const meta = {
|
||||
const meta: Meta<typeof Label> = {
|
||||
title: "ds-react/Typography/Label",
|
||||
component: Label,
|
||||
decorators: [(story) => <div style={{ maxWidth: "400px" }}>{story()}</div>],
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof Label>;
|
||||
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
type Story = StoryObj<typeof Label>;
|
||||
|
||||
const lorem =
|
||||
"Oppgi årsaken til at du har ventet mer enn 6 måneder med å søke om refusjon";
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface CreateContextOptions<T> {
|
||||
errorMessage?: string;
|
||||
name?: string;
|
||||
defaultValue?: T;
|
||||
strict?: boolean;
|
||||
}
|
||||
|
||||
type ProviderProps<T> = T & { children: React.ReactNode };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/composeRefs.tsx */
|
||||
import React from "react";
|
||||
|
||||
type PossibleRef<T> = React.Ref<T> | undefined;
|
||||
type PossibleRef<T> = React.LegacyRef<T> | undefined;
|
||||
|
||||
// https://github.com/gregberge/react-merge-refs
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @navikt/ds-tailwind
|
||||
|
||||
## 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/ds-tailwind",
|
||||
"version": "6.2.0",
|
||||
"version": "6.3.6",
|
||||
"description": "Tailwind config based on @navikt/ds-tokens",
|
||||
"author": "Aksel | NAV designsystem team",
|
||||
"keywords": [
|
||||
@@ -25,7 +25,7 @@
|
||||
"directory": "@navikt/core/tailwind"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@navikt/ds-tokens": "^6.2.0",
|
||||
"@navikt/ds-tokens": "^6.3.6",
|
||||
"color": "4.2.3",
|
||||
"lodash": "^4.17.21",
|
||||
"tailwindcss": "^3.3.3",
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @navikt/ds-tokens
|
||||
|
||||
## 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/ds-tokens",
|
||||
"version": "6.2.0",
|
||||
"version": "6.3.6",
|
||||
"description": "Design-tokens for NAV designsystem",
|
||||
"author": "Aksel | NAV designsystem team",
|
||||
"keywords": [
|
||||
|
||||
+209
-179
@@ -1,17 +1,47 @@
|
||||
# Changelog
|
||||
|
||||
## 6.3
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
- ReadMore: Padding-right på button er bumpet noen px ([#2806](https://github.com/navikt/aksel/pull/2806))
|
||||
|
||||
* FileUpload: Ny komponent `FileUpload` for å håndtere opplasting av filer, Dropzone og fil-visning ([#2504](https://github.com/navikt/aksel/pull/2504))
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- DatePicker: Fjernet role gridcell fra dato-knapper. ([#2826](https://github.com/navikt/aksel/pull/2826))
|
||||
|
||||
* Modal: Fjern tomt element ([#2819](https://github.com/navikt/aksel/pull/2819))
|
||||
|
||||
- Switch: Fikset edgecase der checked Switch var visuelt unchecked ([#2810](https://github.com/navikt/aksel/pull/2810))
|
||||
|
||||
- FileUpload.Dropzone: id prop settes på button ([#2817](https://github.com/navikt/aksel/pull/2817))
|
||||
|
||||
* 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))
|
||||
|
||||
- FileUpload: Fiks feil ved import av json ([#2803](https://github.com/navikt/aksel/pull/2803))
|
||||
|
||||
* FileUpload: Disabled dropzone stopper opplasting ([#2798](https://github.com/navikt/aksel/pull/2798))
|
||||
|
||||
- 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))
|
||||
|
||||
## 6.2
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- Select: Fjern 'multiple' fra SelectProps ([#2782](https://github.com/navikt/aksel/pull/2782))
|
||||
|
||||
### @navikt/aksel-icons
|
||||
|
||||
- Ikoner: Nytt ikon `archive` ([#2772](https://github.com/navikt/aksel/pull/2772))
|
||||
|
||||
- Ikoner: Nytt ikon `opensource` ([#2787](https://github.com/navikt/aksel/pull/2787))
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- Select: Fjern 'multiple' fra SelectProps ([#2782](https://github.com/navikt/aksel/pull/2782))
|
||||
|
||||
## 6.1
|
||||
|
||||
### @navikt/ds-css
|
||||
@@ -34,19 +64,9 @@
|
||||
|
||||
## 6.0
|
||||
|
||||
### @navikt/aksel
|
||||
### @navikt/ds-tailwind
|
||||
|
||||
- Chat: Fjernet deprecated props `backgroundColor` og `avatarBgColor`. Bruk prop `variant` som erstatning. ([#2623](https://github.com/navikt/aksel/pull/2623))
|
||||
|
||||
### @navikt/aksel-icons
|
||||
|
||||
- ESM: ds-react og aksel-icons støtter nå native ESM 🎉 ([#2623](https://github.com/navikt/aksel/pull/2623))
|
||||
|
||||
* SSR: ds-react og aksel-icons støtter nå `use client` og kan brukes i nextjs app-router uten lokal re-eksportering. ([#2623](https://github.com/navikt/aksel/pull/2623))
|
||||
|
||||
### @navikt/aksel-stylelint
|
||||
|
||||
- Textarea: Fjernet div med klassen navds-textarea\_\_wrapper ([#2623](https://github.com/navikt/aksel/pull/2623))
|
||||
- Tokens: La til nytt brekkpunkt `2xl` for `1440px`. Alle primitives er oppdatert for å reflektere oppdateringen. Brukere av Tailwind vil måtte sjekke at overskriving av `screen: "2xl"` ikke brekker app. ([#2623](https://github.com/navikt/aksel/pull/2623))
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
@@ -60,6 +80,12 @@
|
||||
|
||||
- Textarea: Fjernet div med klassen navds-textarea\_\_wrapper ([#2623](https://github.com/navikt/aksel/pull/2623))
|
||||
|
||||
### @navikt/ds-tokens
|
||||
|
||||
- Tokens: La til nytt brekkpunkt `2xl` for `1440px`. Alle primitives er oppdatert for å reflektere oppdateringen. Brukere av Tailwind vil måtte sjekke at overskriving av `screen: "2xl"` ikke brekker app. ([#2623](https://github.com/navikt/aksel/pull/2623))
|
||||
|
||||
- Tokens: Fjernet token `--a-z-index-modal` da den ikke lengre blir konsumert i systemet. ([#2623](https://github.com/navikt/aksel/pull/2623))
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- Chat: Fjernet deprecated props `backgroundColor` og `avatarBgColor`. Bruk prop `variant` som erstatning. ([#2623](https://github.com/navikt/aksel/pull/2623))
|
||||
@@ -82,15 +108,19 @@
|
||||
|
||||
* SSR: ds-react og aksel-icons støtter nå `use client` og kan brukes i nextjs app-router uten lokal re-eksportering. ([#2623](https://github.com/navikt/aksel/pull/2623))
|
||||
|
||||
### @navikt/ds-tailwind
|
||||
### @navikt/aksel-stylelint
|
||||
|
||||
- Tokens: La til nytt brekkpunkt `2xl` for `1440px`. Alle primitives er oppdatert for å reflektere oppdateringen. Brukere av Tailwind vil måtte sjekke at overskriving av `screen: "2xl"` ikke brekker app. ([#2623](https://github.com/navikt/aksel/pull/2623))
|
||||
- Textarea: Fjernet div med klassen navds-textarea\_\_wrapper ([#2623](https://github.com/navikt/aksel/pull/2623))
|
||||
|
||||
### @navikt/ds-tokens
|
||||
### @navikt/aksel
|
||||
|
||||
- Tokens: La til nytt brekkpunkt `2xl` for `1440px`. Alle primitives er oppdatert for å reflektere oppdateringen. Brukere av Tailwind vil måtte sjekke at overskriving av `screen: "2xl"` ikke brekker app. ([#2623](https://github.com/navikt/aksel/pull/2623))
|
||||
- Chat: Fjernet deprecated props `backgroundColor` og `avatarBgColor`. Bruk prop `variant` som erstatning. ([#2623](https://github.com/navikt/aksel/pull/2623))
|
||||
|
||||
- Tokens: Fjernet token `--a-z-index-modal` da den ikke lengre blir konsumert i systemet. ([#2623](https://github.com/navikt/aksel/pull/2623))
|
||||
### @navikt/aksel-icons
|
||||
|
||||
- ESM: ds-react og aksel-icons støtter nå native ESM 🎉 ([#2623](https://github.com/navikt/aksel/pull/2623))
|
||||
|
||||
* SSR: ds-react og aksel-icons støtter nå `use client` og kan brukes i nextjs app-router uten lokal re-eksportering. ([#2623](https://github.com/navikt/aksel/pull/2623))
|
||||
|
||||
## 5.18
|
||||
|
||||
@@ -132,9 +162,9 @@
|
||||
|
||||
## 5.16
|
||||
|
||||
### @navikt/aksel-icons
|
||||
### @navikt/ds-tailwind
|
||||
|
||||
- Ikoner: Nytt ikon `LocationPin` ✨ ([#2672](https://github.com/navikt/aksel/pull/2672))
|
||||
- Tailwind: La til ny `maxWidth` for text ([#2652](https://github.com/navikt/aksel/pull/2652))
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
@@ -144,20 +174,20 @@
|
||||
|
||||
- :sparkles: Combobox: Mulighet for å begrense hvor mange valg bruker kan ta ([#2260](https://github.com/navikt/aksel/pull/2260))
|
||||
|
||||
### @navikt/ds-tailwind
|
||||
|
||||
- Tailwind: La til ny `maxWidth` for text ([#2652](https://github.com/navikt/aksel/pull/2652))
|
||||
|
||||
## 5.15
|
||||
|
||||
### @navikt/aksel-icons
|
||||
|
||||
- Ikoner: Synket ikoner med Figma. Lagt til nytt ikon `FigureChild` ([#2629](https://github.com/navikt/aksel/pull/2629))
|
||||
- Ikoner: Nytt ikon `LocationPin` ✨ ([#2672](https://github.com/navikt/aksel/pull/2672))
|
||||
|
||||
## 5.15
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- HelpText: HelpText-state ble ikke riktig oppdatert ved klikk ([#2643](https://github.com/navikt/aksel/pull/2643))
|
||||
|
||||
### @navikt/aksel-icons
|
||||
|
||||
- Ikoner: Synket ikoner med Figma. Lagt til nytt ikon `FigureChild` ([#2629](https://github.com/navikt/aksel/pull/2629))
|
||||
|
||||
## 5.14
|
||||
|
||||
### @navikt/ds-css
|
||||
@@ -184,6 +214,10 @@
|
||||
|
||||
- Page: Lagt til ny bakgrunnsfarge `surface-subtle` og ny maksbredde på Page.Block `text` ([#2572](https://github.com/navikt/aksel/pull/2572))
|
||||
|
||||
### @navikt/ds-tokens
|
||||
|
||||
- Tokens: Ny token `--a-text-width-max: 576px` ([#2572](https://github.com/navikt/aksel/pull/2572))
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- Accordion.Item, Dropdown, ReadMore og Tooltip: Har en ny prop `onOpenChange?: (open: boolean) => void;` som forteller nå-state når `open`-state endrer seg. Dette vil være nyttig hvis man ikke bruker controlled-state, men fortsatt ønsker å vite om komponenten er `open` eller ikke (f.eks logging). ([#2585](https://github.com/navikt/aksel/pull/2585))
|
||||
@@ -192,20 +226,8 @@
|
||||
|
||||
* Page: Lagt til ny bakgrunnsfarge `surface-subtle` og ny maksbredde på Page.Block `text` ([#2572](https://github.com/navikt/aksel/pull/2572))
|
||||
|
||||
### @navikt/ds-tokens
|
||||
|
||||
- Tokens: Ny token `--a-text-width-max: 576px` ([#2572](https://github.com/navikt/aksel/pull/2572))
|
||||
|
||||
## 5.12
|
||||
|
||||
### @navikt/aksel-icons
|
||||
|
||||
- Ikoner: Oppdatert ikonpakke med diverse bugdfixes og oppdateringer av stroke ([#2538](https://github.com/navikt/aksel/pull/2538))
|
||||
|
||||
### @navikt/aksel-stylelint
|
||||
|
||||
- Stylelint: Støtter nå stylelint v16 ([#2591](https://github.com/navikt/aksel/pull/2591))
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
- ExpansionCard: Divider mellom header og innhold ([#2566](https://github.com/navikt/aksel/pull/2566))
|
||||
@@ -222,11 +244,15 @@
|
||||
|
||||
* Datepicker: Lukk-knapp i modal har nå type button for å unngå form-submition ([#2568](https://github.com/navikt/aksel/pull/2568))
|
||||
|
||||
## 5.11
|
||||
### @navikt/aksel-stylelint
|
||||
|
||||
- Stylelint: Støtter nå stylelint v16 ([#2591](https://github.com/navikt/aksel/pull/2591))
|
||||
|
||||
### @navikt/aksel-icons
|
||||
|
||||
- Ikoner: Nytt ikon `PushPin` ([#2503](https://github.com/navikt/aksel/pull/2503))
|
||||
- Ikoner: Oppdatert ikonpakke med diverse bugdfixes og oppdateringer av stroke ([#2538](https://github.com/navikt/aksel/pull/2538))
|
||||
|
||||
## 5.11
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
@@ -268,8 +294,16 @@
|
||||
|
||||
- :wheelchair: Textarea: Forsinkelse ved live-opplesning av gjenstående tegn ([#2502](https://github.com/navikt/aksel/pull/2502))
|
||||
|
||||
### @navikt/aksel-icons
|
||||
|
||||
- Ikoner: Nytt ikon `PushPin` ([#2503](https://github.com/navikt/aksel/pull/2503))
|
||||
|
||||
## 5.10
|
||||
|
||||
### @navikt/ds-tailwind
|
||||
|
||||
- typescript types for tailwind preset ([#2486](https://github.com/navikt/aksel/pull/2486))
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
- :bug: Textarea: Teller flyttet ut av tekstfeltet for å unngå overlapp og misforståelser ([#2483](https://github.com/navikt/aksel/pull/2483))
|
||||
@@ -294,10 +328,6 @@
|
||||
|
||||
- Primitives: Ny komponent `Page` og `Page.Block` ([#2456](https://github.com/navikt/aksel/pull/2456))
|
||||
|
||||
### @navikt/ds-tailwind
|
||||
|
||||
- typescript types for tailwind preset ([#2486](https://github.com/navikt/aksel/pull/2486))
|
||||
|
||||
## 5.9
|
||||
|
||||
### @navikt/ds-css
|
||||
@@ -344,10 +374,6 @@
|
||||
|
||||
## 5.7
|
||||
|
||||
### @navikt/aksel-icons
|
||||
|
||||
- Ikoner: Nye ikoner `ChevronLeftFirstCircle` og `ChevronRightLastCircle` ([#2365](https://github.com/navikt/aksel/pull/2365))
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
- :lipstick: Chips.Toggle: Ikon i uvalgt tilstand ([#2395](https://github.com/navikt/aksel/pull/2395))
|
||||
@@ -364,6 +390,10 @@
|
||||
|
||||
- :lipstick: CopyButton: Justert padding, gap og animasjon ([#2355](https://github.com/navikt/aksel/pull/2355))
|
||||
|
||||
### @navikt/ds-tokens
|
||||
|
||||
- Ny spacing token på 0.375rem ([#2370](https://github.com/navikt/aksel/pull/2370))
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- :lipstick: Chips.Toggle: Ikon i uvalgt tilstand ([#2395](https://github.com/navikt/aksel/pull/2395))
|
||||
@@ -384,19 +414,11 @@
|
||||
|
||||
- :lipstick: CopyButton: Justert padding, gap og animasjon ([#2355](https://github.com/navikt/aksel/pull/2355))
|
||||
|
||||
### @navikt/ds-tokens
|
||||
|
||||
- Ny spacing token på 0.375rem ([#2370](https://github.com/navikt/aksel/pull/2370))
|
||||
|
||||
## 5.6
|
||||
|
||||
### @navikt/aksel-icons
|
||||
|
||||
- Ikoner: SVG export setter nå `height="1em"`, `width="1em"` og `fill="currentColor"`. ([#2300](https://github.com/navikt/aksel/pull/2300))
|
||||
- Ikoner: Nye ikoner `ChevronLeftFirstCircle` og `ChevronRightLastCircle` ([#2365](https://github.com/navikt/aksel/pull/2365))
|
||||
|
||||
- Ikoner: Nye ikoner `book` og `books` ([#2313](https://github.com/navikt/aksel/pull/2313))
|
||||
|
||||
* Ikoner: Nye ikoner `ClipboardCheckmark` og `ClipboardLink` ([#2307](https://github.com/navikt/aksel/pull/2307))
|
||||
## 5.6
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
@@ -418,6 +440,10 @@
|
||||
|
||||
- MonthPicker: Tilpasset padding og sizing på mobil. ([#2311](https://github.com/navikt/aksel/pull/2311))
|
||||
|
||||
### @navikt/ds-tokens
|
||||
|
||||
- Tokens: Tilbyr nå token literals for typer, tidligere string literals ([#2342](https://github.com/navikt/aksel/pull/2342))
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- Datepicker: Popover legger seg nå ikke ovenfor input ([#2349](https://github.com/navikt/aksel/pull/2349))
|
||||
@@ -444,9 +470,13 @@
|
||||
|
||||
- Remove tabIndex="-1" on Popover to fix VoiceOver navigation issue inside Popover ([#2303](https://github.com/navikt/aksel/pull/2303))
|
||||
|
||||
### @navikt/ds-tokens
|
||||
### @navikt/aksel-icons
|
||||
|
||||
- Tokens: Tilbyr nå token literals for typer, tidligere string literals ([#2342](https://github.com/navikt/aksel/pull/2342))
|
||||
- Ikoner: SVG export setter nå `height="1em"`, `width="1em"` og `fill="currentColor"`. ([#2300](https://github.com/navikt/aksel/pull/2300))
|
||||
|
||||
- Ikoner: Nye ikoner `book` og `books` ([#2313](https://github.com/navikt/aksel/pull/2313))
|
||||
|
||||
* Ikoner: Nye ikoner `ClipboardCheckmark` og `ClipboardLink` ([#2307](https://github.com/navikt/aksel/pull/2307))
|
||||
|
||||
## 5.5
|
||||
|
||||
@@ -456,16 +486,16 @@
|
||||
|
||||
* Skeleton: Width fungerer nå med inline-variant av Skeleton ([#2273](https://github.com/navikt/aksel/pull/2273))
|
||||
|
||||
### @navikt/ds-tokens
|
||||
|
||||
- Box: Ny primitive, erstatter dagens `Panel` ([#2195](https://github.com/navikt/aksel/pull/2195))
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- Box: Ny primitive, erstatter dagens `Panel` ([#2195](https://github.com/navikt/aksel/pull/2195))
|
||||
|
||||
* Skeleton: Width fungerer nå med inline-variant av Skeleton ([#2273](https://github.com/navikt/aksel/pull/2273))
|
||||
|
||||
### @navikt/ds-tokens
|
||||
|
||||
- Box: Ny primitive, erstatter dagens `Panel` ([#2195](https://github.com/navikt/aksel/pull/2195))
|
||||
|
||||
## 5.4
|
||||
|
||||
### @navikt/ds-css
|
||||
@@ -474,6 +504,10 @@
|
||||
|
||||
* ErrorSummary: Har nå 8px border-radius ([#2256](https://github.com/navikt/aksel/pull/2256))
|
||||
|
||||
### @navikt/ds-tokens
|
||||
|
||||
- Tokens: `data-theme="light"` vil nå tilbakestille theming ([#2238](https://github.com/navikt/aksel/pull/2238))
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- MonthPicker: Fikset kalkulering av fromDate/toDate ([#2269](https://github.com/navikt/aksel/pull/2269))
|
||||
@@ -482,16 +516,8 @@
|
||||
|
||||
- Search: Kjører nå bare preventDefault ved Escape når `input` inneholder tekst ([#2245](https://github.com/navikt/aksel/pull/2245))
|
||||
|
||||
### @navikt/ds-tokens
|
||||
|
||||
- Tokens: `data-theme="light"` vil nå tilbakestille theming ([#2238](https://github.com/navikt/aksel/pull/2238))
|
||||
|
||||
## 5.3
|
||||
|
||||
### @navikt/aksel-icons
|
||||
|
||||
- :bug: SVG import map er nå riktig path ([#2250](https://github.com/navikt/aksel/pull/2250))
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
- Alert: 12px -> 8px gap mellom tekst og ikon ved `size="small"` ([#2240](https://github.com/navikt/aksel/pull/2240))
|
||||
@@ -528,11 +554,11 @@
|
||||
|
||||
* :wheelchair: Textarea: Skjermleser-spesifikk tekst leses opp sammenhengende ([#2216](https://github.com/navikt/aksel/pull/2216))
|
||||
|
||||
## 5.2
|
||||
|
||||
### @navikt/aksel-icons
|
||||
|
||||
- Ikoner: Oppdatert ikonpakke ([#2197](https://github.com/navikt/aksel/pull/2197))
|
||||
- :bug: SVG import map er nå riktig path ([#2250](https://github.com/navikt/aksel/pull/2250))
|
||||
|
||||
## 5.2
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
@@ -550,6 +576,10 @@
|
||||
|
||||
- Hovering over combobox dropdown will move selection/focus in the list, so we don't end up with a split focus, and reversely when moving focus while hovering ([#2193](https://github.com/navikt/aksel/pull/2193))
|
||||
|
||||
### @navikt/aksel-icons
|
||||
|
||||
- Ikoner: Oppdatert ikonpakke ([#2197](https://github.com/navikt/aksel/pull/2197))
|
||||
|
||||
## 5.1
|
||||
|
||||
### @navikt/ds-css
|
||||
@@ -585,6 +615,16 @@
|
||||
|
||||
* Table: Alle størrelser har justert padding. Small-size table bruker nå også standard typografi-størrelse (18px). ([#2178](https://github.com/navikt/aksel/pull/2178))
|
||||
|
||||
### @navikt/ds-tokens
|
||||
|
||||
- ConfirmationPanel: Fikset opplesing av feilmelding ([#2199](https://github.com/navikt/aksel/pull/2199))
|
||||
|
||||
- Tokens: `--a-icon-alt-3` er nå i synk med figma ([#2199](https://github.com/navikt/aksel/pull/2199))
|
||||
|
||||
* Gray: Gråfarger er nå mindre varme ([#2092](https://github.com/navikt/aksel/pull/2092))
|
||||
|
||||
* Action: Selected-varianter av action er nå 'blue' ('deepblue' før) ([#2092](https://github.com/navikt/aksel/pull/2092))
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- ConfirmationPanel: Fikset opplesing av feilmelding ([#2199](https://github.com/navikt/aksel/pull/2199))
|
||||
@@ -601,16 +641,6 @@
|
||||
|
||||
* Table: Har lagt til ny size: 'large'. ([#2178](https://github.com/navikt/aksel/pull/2178))
|
||||
|
||||
### @navikt/ds-tokens
|
||||
|
||||
- ConfirmationPanel: Fikset opplesing av feilmelding ([#2199](https://github.com/navikt/aksel/pull/2199))
|
||||
|
||||
- Tokens: `--a-icon-alt-3` er nå i synk med figma ([#2199](https://github.com/navikt/aksel/pull/2199))
|
||||
|
||||
* Gray: Gråfarger er nå mindre varme ([#2092](https://github.com/navikt/aksel/pull/2092))
|
||||
|
||||
* Action: Selected-varianter av action er nå 'blue' ('deepblue' før) ([#2092](https://github.com/navikt/aksel/pull/2092))
|
||||
|
||||
## 4.12
|
||||
|
||||
### @navikt/ds-css
|
||||
@@ -759,28 +789,28 @@
|
||||
|
||||
## 4.4
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
- :lipstick: Chat: fjernet border, satt avatar svg til 24x24px, byttet om "subtle" og "neutral" ([#2077](https://github.com/navikt/aksel/pull/2077))
|
||||
|
||||
* Shadow-tokens er oppdatert: Datepicker har nå ikke border, Modal bruker shadow-xlarge, LinkCard bruker shadow-xsmall ([PR](https://github.com/navikt/aksel/pull/2041))
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- Textarea: Fikset i18n for counter ([`718b3204d`](https://github.com/navikt/aksel/commit/718b3204d8714c4fc515dcad484424214bdc0c77))
|
||||
|
||||
* Fikset klassenavn brukt for popover i Datepicker og Monthpicker ([PR](https://github.com/navikt/aksel/pull/2041))
|
||||
|
||||
### @navikt/ds-tailwind
|
||||
|
||||
- Shadow-tokens er oppdatert ([PR](https://github.com/navikt/aksel/pull/2041))
|
||||
|
||||
- Oppdatert text-subtle og icon-subtle tokens til 700-skala (tidligere 600). ([PR](https://github.com/navikt/aksel/pull/2036))
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
- :lipstick: Chat: fjernet border, satt avatar svg til 24x24px, byttet om "subtle" og "neutral" ([#2077](https://github.com/navikt/aksel/pull/2077))
|
||||
|
||||
* Shadow-tokens er oppdatert: Datepicker har nå ikke border, Modal bruker shadow-xlarge, LinkCard bruker shadow-xsmall ([PR](https://github.com/navikt/aksel/pull/2041))
|
||||
|
||||
### @navikt/ds-tokens
|
||||
|
||||
- Shadow-tokens er oppdatert til mer tydeligere varianter ([PR](https://github.com/navikt/aksel/pull/2041))
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- Textarea: Fikset i18n for counter ([`718b3204d`](https://github.com/navikt/aksel/commit/718b3204d8714c4fc515dcad484424214bdc0c77))
|
||||
|
||||
* Fikset klassenavn brukt for popover i Datepicker og Monthpicker ([PR](https://github.com/navikt/aksel/pull/2041))
|
||||
|
||||
## 4.3
|
||||
|
||||
### @navikt/ds-css
|
||||
@@ -805,13 +835,9 @@
|
||||
|
||||
## 4.1
|
||||
|
||||
### @navikt/aksel-icons
|
||||
### @navikt/ds-tailwind
|
||||
|
||||
- Nye ikoner `ChevronRightLast` og `ChevronLeftLast` ([PR](https://github.com/navikt/aksel/pull/2054))
|
||||
|
||||
### @navikt/aksel-stylelint
|
||||
|
||||
- Deprecated klassenavn `navds-chips--icon-left` ([PR](https://github.com/navikt/aksel/pull/2035))
|
||||
- :tada: Fargetokens for datavisualisering. ([PR](https://github.com/navikt/aksel/pull/2032))
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
@@ -823,6 +849,12 @@
|
||||
|
||||
- Alle description-felter på fieldsets har nå `text-subtle` som farge. ([PR](https://github.com/navikt/aksel/pull/2036))
|
||||
|
||||
### @navikt/ds-tokens
|
||||
|
||||
- :tada: Fargetokens for datavisualisering. ([PR](https://github.com/navikt/aksel/pull/2032))
|
||||
|
||||
- Oppdatert text-subtle og icon-subtle tokens til 700-skala (tidligere 600). ([PR](https://github.com/navikt/aksel/pull/2036))
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- Fikset JSDom-problemer ved testing av Timeline
|
||||
@@ -847,24 +879,16 @@
|
||||
|
||||
- Accordion: La til `indent`-prop ([PR](https://github.com/navikt/aksel/pull/2027))
|
||||
|
||||
### @navikt/ds-tailwind
|
||||
### @navikt/aksel-stylelint
|
||||
|
||||
- :tada: Fargetokens for datavisualisering. ([PR](https://github.com/navikt/aksel/pull/2032))
|
||||
- Deprecated klassenavn `navds-chips--icon-left` ([PR](https://github.com/navikt/aksel/pull/2035))
|
||||
|
||||
### @navikt/ds-tokens
|
||||
### @navikt/aksel-icons
|
||||
|
||||
- :tada: Fargetokens for datavisualisering. ([PR](https://github.com/navikt/aksel/pull/2032))
|
||||
|
||||
- Oppdatert text-subtle og icon-subtle tokens til 700-skala (tidligere 600). ([PR](https://github.com/navikt/aksel/pull/2036))
|
||||
- Nye ikoner `ChevronRightLast` og `ChevronLeftLast` ([PR](https://github.com/navikt/aksel/pull/2054))
|
||||
|
||||
## 4.0
|
||||
|
||||
### @navikt/aksel
|
||||
|
||||
- Codemod for migrering av Datepicker/Monthpicker ut av Beta. `npx @navikt/aksel codemod v4-date` ([PR](https://github.com/navikt/aksel/pull/2026))
|
||||
|
||||
- Codemods for migrering fra `@navikt/ds-react-internal` til `@navikt/ds-react` [Guide](https://aksel.nav.no/grunnleggende/kode/migrering#h3b8538e1981d) ([PR](https://github.com/navikt/aksel/pull/2026))
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
- All styling fra `@navikt/ds-css-internal` er flyttet til `@navikt/ds-css`. ([PR](https://github.com/navikt/aksel/pull/2026))
|
||||
@@ -881,16 +905,14 @@
|
||||
|
||||
- Komponenter i `@navikt/ds-react-internal` er flyttet over til `@navikt/ds-react`. [Guide](https://aksel.nav.no/grunnleggende/kode/migrering#h3b8538e1981d) ([PR](https://github.com/navikt/aksel/pull/2026))
|
||||
|
||||
### @navikt/aksel
|
||||
|
||||
- Codemod for migrering av Datepicker/Monthpicker ut av Beta. `npx @navikt/aksel codemod v4-date` ([PR](https://github.com/navikt/aksel/pull/2026))
|
||||
|
||||
- Codemods for migrering fra `@navikt/ds-react-internal` til `@navikt/ds-react` [Guide](https://aksel.nav.no/grunnleggende/kode/migrering#h3b8538e1981d) ([PR](https://github.com/navikt/aksel/pull/2026))
|
||||
|
||||
## 3.4
|
||||
|
||||
### @navikt/aksel-stylelint
|
||||
|
||||
- La til riktige dependencies ([PR](https://github.com/navikt/aksel/pull/2017))
|
||||
|
||||
* Inkluderer nå dist-mappe i release
|
||||
|
||||
- Stylelint-pakke for Aksel ([PR](https://github.com/navikt/aksel/pull/1973))
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
- Tag: `moderate`-variant ([PR](https://github.com/navikt/aksel/pull/2010))
|
||||
@@ -899,6 +921,14 @@
|
||||
|
||||
- Tag: `moderate`-variant ([PR](https://github.com/navikt/aksel/pull/2010))
|
||||
|
||||
### @navikt/aksel-stylelint
|
||||
|
||||
- La til riktige dependencies ([PR](https://github.com/navikt/aksel/pull/2017))
|
||||
|
||||
* Inkluderer nå dist-mappe i release
|
||||
|
||||
- Stylelint-pakke for Aksel ([PR](https://github.com/navikt/aksel/pull/1973))
|
||||
|
||||
## 3.3
|
||||
|
||||
### @navikt/ds-react
|
||||
@@ -909,10 +939,6 @@
|
||||
|
||||
## 3.2
|
||||
|
||||
### @navikt/aksel-icons
|
||||
|
||||
- Nye ikoner `FileParagraph` og `FilePlus` ([PR](https://github.com/navikt/aksel/pull/1998))
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
- Datepicker: Fikset small-variant av datepicker-input. ([PR](https://github.com/navikt/aksel/pull/1993))
|
||||
@@ -931,6 +957,10 @@
|
||||
|
||||
* Ny komponent CopyButton! Erstatter `CopyToClipboard` som nå er tagget som deprecated ([PR](https://github.com/navikt/aksel/pull/1982))
|
||||
|
||||
### @navikt/aksel-icons
|
||||
|
||||
- Nye ikoner `FileParagraph` og `FilePlus` ([PR](https://github.com/navikt/aksel/pull/1998))
|
||||
|
||||
## 3.1
|
||||
|
||||
### @navikt/ds-css
|
||||
@@ -963,6 +993,14 @@
|
||||
|
||||
## 2.9
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
- CSS nå tilgjengelig som separate filer: Kan lastest fra CDN, minified-versjoner tilgjengelig ([PR](https://github.com/navikt/aksel/pull/1941))
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- ExpansionCard: Støtter nå `aria-labelledby` i tillegg til `aria-label` ([PR](https://github.com/navikt/aksel/pull/1944))
|
||||
|
||||
### @navikt/aksel
|
||||
|
||||
- :truck: Flyttet Codemods fra `@navikt/ds-codemods` -> `@navikt/aksel`. `@navikt/ds-codemods` regnes nå som deprecated ([PR](https://github.com/navikt/aksel/pull/1952))
|
||||
@@ -977,28 +1015,8 @@
|
||||
|
||||
* Nye ikoner `BulletList` og `NumberList` ([PR](https://github.com/navikt/aksel/pull/1950))
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
- CSS nå tilgjengelig som separate filer: Kan lastest fra CDN, minified-versjoner tilgjengelig ([PR](https://github.com/navikt/aksel/pull/1941))
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- ExpansionCard: Støtter nå `aria-labelledby` i tillegg til `aria-label` ([PR](https://github.com/navikt/aksel/pull/1944))
|
||||
|
||||
## 2.8
|
||||
|
||||
### @navikt/aksel-icons
|
||||
|
||||
- :tada: Nye ikoner `HandKnot` og `HandKnotFilled` ([PR](https://github.com/navikt/aksel/pull/1928))
|
||||
|
||||
* Oppdatert `InformationSquare`-ikon ([PR](https://github.com/navikt/aksel/pull/1921))
|
||||
|
||||
- Oppdatert `VideoSlack`, `Questionmark` og `Information`-ikoner ([PR](https://github.com/navikt/aksel/pull/1909))
|
||||
|
||||
* Nye ikoner `VideoSlash` og `VideoSlashFill`
|
||||
|
||||
- Fikset Sourcemap-bug
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
- Button: Padding/border-radius tokens ([PR](https://github.com/navikt/aksel/pull/1905))
|
||||
@@ -1033,8 +1051,26 @@
|
||||
|
||||
* Ny komponent ExpansionCard! ([PR](https://github.com/navikt/aksel/pull/1820))
|
||||
|
||||
### @navikt/aksel-icons
|
||||
|
||||
- :tada: Nye ikoner `HandKnot` og `HandKnotFilled` ([PR](https://github.com/navikt/aksel/pull/1928))
|
||||
|
||||
* Oppdatert `InformationSquare`-ikon ([PR](https://github.com/navikt/aksel/pull/1921))
|
||||
|
||||
- Oppdatert `VideoSlack`, `Questionmark` og `Information`-ikoner ([PR](https://github.com/navikt/aksel/pull/1909))
|
||||
|
||||
* Nye ikoner `VideoSlash` og `VideoSlashFill`
|
||||
|
||||
- Fikset Sourcemap-bug
|
||||
|
||||
## 2.7
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
- Tabs: La til focus-markering for Tabs.Panel ([PR](https://github.com/navikt/aksel/pull/1863))
|
||||
|
||||
* ToggleGroup: Fikset token-bug
|
||||
|
||||
### @navikt/aksel-icons
|
||||
|
||||
- Nye ikoner `CaretUpDownFilledDown`, `CaretUpDownFilledUp`, `CloudDown`, `CloudDownFill`, `CloudUp`, `CloudUpFill`, `HddDown`, `HddDownFill`, `HddUp`, `HddUpFill`, `Inbox`, `InboxDown`, `InboxDownFill`, `InboxFill`, `InboxUp`, `InboxUpFill`, `ShoppingBasket`, `ShoppingBasketFill`
|
||||
@@ -1043,12 +1079,6 @@
|
||||
|
||||
- Ny ikonpakke med for core icons 3! `@navikt/aksel-icons` ([PR](https://github.com/navikt/aksel/pull/1847))
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
- Tabs: La til focus-markering for Tabs.Panel ([PR](https://github.com/navikt/aksel/pull/1863))
|
||||
|
||||
* ToggleGroup: Fikset token-bug
|
||||
|
||||
## 2.6
|
||||
|
||||
### @navikt/ds-react
|
||||
@@ -1059,10 +1089,6 @@
|
||||
|
||||
## 2.5
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- List: Refaktorert nestede lister
|
||||
|
||||
### @navikt/ds-tailwind
|
||||
|
||||
- Breakpoint-tokens er lagt til. Overskriver native tailwind-breakpoints ([PR](https://github.com/navikt/aksel/pull/1832))
|
||||
@@ -1071,6 +1097,10 @@
|
||||
|
||||
- Breakpoint-tokens lagt til ([PR](https://github.com/navikt/aksel/pull/1832))
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- List: Refaktorert nestede lister
|
||||
|
||||
## 2.4
|
||||
|
||||
### @navikt/ds-css
|
||||
@@ -1105,24 +1135,28 @@
|
||||
|
||||
- Button: `Neutral`-variant.
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- ToggleGroup: `neutral`-variant ([PR](https://github.com/navikt/aksel/pull/1789))
|
||||
|
||||
- Button: `neutral`-variant ([PR](https://github.com/navikt/aksel/pull/1789))
|
||||
|
||||
### @navikt/ds-tokens
|
||||
|
||||
- Justeringer av semantiske fargetokens, statusfarger nå mer tydelig ([PR](https://github.com/navikt/aksel/pull/1787))
|
||||
|
||||
- Oppdatert neutral-tokens ([PR](https://github.com/navikt/aksel/pull/1789))
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- ToggleGroup: `neutral`-variant ([PR](https://github.com/navikt/aksel/pull/1789))
|
||||
|
||||
- Button: `neutral`-variant ([PR](https://github.com/navikt/aksel/pull/1789))
|
||||
|
||||
## 2.1
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
- TextField: `small`-variant har nå 8px horisontal padding (før 4px)
|
||||
|
||||
### @navikt/ds-tokens
|
||||
|
||||
- Fikset feil danger-hover token ([PR](https://github.com/navikt/aksel/pull/1665))
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- Datepicker: Fikset rekkefølge på `onValidate` og `onRangeChange` i useRangepicker-hook
|
||||
@@ -1139,12 +1173,12 @@
|
||||
|
||||
- Datepicker/Monthpicker: år med 2 siffer i input fungerer nå
|
||||
|
||||
### @navikt/ds-tokens
|
||||
|
||||
- Fikset feil danger-hover token ([PR](https://github.com/navikt/aksel/pull/1665))
|
||||
|
||||
## 2.0
|
||||
|
||||
### @navikt/ds-tailwind
|
||||
|
||||
- Alle token er oppdatert til nytt format. ([Guide](https://aksel.nav.no/grunnleggende/kode/migrering#h76f47744d112))
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
- Radio: Fikset default visuell error-state ([PR](https://github.com/navikt/aksel/pull/1737))
|
||||
@@ -1157,6 +1191,10 @@
|
||||
|
||||
* Tokens: Alle komponenter bruker nå semantiske tokens for som standard, med innebygd støtte for komponent-spesifikke tokens.
|
||||
|
||||
### @navikt/ds-tokens
|
||||
|
||||
- Alle token er oppdatert til nytt format. ([Guide](https://aksel.nav.no/grunnleggende/kode/migrering#h76f47744d112))
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- Datepicker: Bedre typer for `ref`
|
||||
@@ -1175,14 +1213,6 @@
|
||||
|
||||
* Fonter blir bruk i all typografi blir nå lastet fra CDN
|
||||
|
||||
### @navikt/ds-tailwind
|
||||
|
||||
- Alle token er oppdatert til nytt format. ([Guide](https://aksel.nav.no/grunnleggende/kode/migrering#h76f47744d112))
|
||||
|
||||
### @navikt/ds-tokens
|
||||
|
||||
- Alle token er oppdatert til nytt format. ([Guide](https://aksel.nav.no/grunnleggende/kode/migrering#h76f47744d112))
|
||||
|
||||
## 1.5
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
@@ -4,8 +4,9 @@ ENV NODE_ENV production
|
||||
|
||||
WORKDIR /app
|
||||
COPY aksel.nav.no/website/.next/standalone ./
|
||||
COPY aksel.nav.no/website/config/next-logger.config.js ./
|
||||
COPY aksel.nav.no/website/next-logger.config.js ./
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_OPTIONS '-r next-logger'
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user