Compare commits

...
Author SHA1 Message Date
github-actions[bot]andgithub-actions[bot] 09ba52ea76 Version Packages (#2132)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-08-03 13:30:24 +02:00
a82bccf707 Removable chips type now defaults to button (#2124)
* Removable chips type now defaults to button

* 📝 Changeset

* 📝 Changeset update

* Remove OverridableComponent from Chips.Removable. This component should always be a button, and it is not currently used in any other way.

The reason for making this change now, is that we put type="button" on the Chip.Removable to prevent a submit event when the chip is clicked inside a form.

---------

Co-authored-by: Ken <ken.aleksander@gmail.com>
Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>
Co-authored-by: Vegard Haugstvedt <it.vegard@gmail.com>
2023-08-03 11:41:31 +02:00
Ken 89ae778c24 🐛 Fikset lasting av aksel-eksempler i storybook 2023-08-03 10:53:25 +02:00
Ken 5b2669d35d ⬆️ Oppdatert storybook til 7.2 (#2128) 2023-08-02 15:35:28 +02:00
Ken 8a82bb4a96 🐛 Fikset layout-bug på komponentsider for enkelte nettlesere 2023-08-02 12:43:19 +02:00
Ken 7871e0f95c 📝 Oppdatert datepicker-eksempler med formatering (#2125) 2023-08-02 12:37:14 +02:00
dependabot[bot] 3182888a07 ⬆️ Bump word-wrap from 1.2.3 to 1.2.4 (#2115)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-02 10:39:55 +02:00
Mathias S. Müller f2742da085 Legg til manglende størrelsesbeskrivelse (#2121) 2023-07-27 14:59:08 +02:00
github-actions[bot]andgithub-actions[bot] 92721aefa0 Version Packages (#2119)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-07-21 11:47:05 +02:00
Ken e6fdfdc9b4 🐛 Fikset stepper-bug (#2116) 2023-07-20 19:50:56 +02:00
github-actions[bot]andgithub-actions[bot] 93975bc935 Version Packages (#2118)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-07-19 20:14:53 +02:00
Ken 3a0dac3997 🐛 Fikset datepicker-bug for dynamsik oppdatering av 'month' (#2117) 2023-07-19 20:12:14 +02:00
Andreas Nordahl 1d43710af0 Remove preconnect link to fonts.googleapis.com(#2114) 2023-07-19 17:45:55 +02:00
github-actions[bot]andgithub-actions[bot] 6df7c78e3c Version Packages (#2111)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-07-17 22:35:24 +02:00
Andreas NordahlandKen 476d79e487 Expose timeline axis label templates as a prop (#2109)
Co-authored-by: Ken <ken.aleksander@gmail.com>
2023-07-17 22:32:16 +02:00
dependabot[bot]andLars Eirik Korsgaard Hansen 238a715c92 ⬆️ Bump semver from 5.7.1 to 5.7.2 (#2107)
Bumps [semver](https://github.com/npm/node-semver) from 5.7.1 to 5.7.2.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/v5.7.2/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v5.7.1...v5.7.2)

---
updated-dependencies:
- dependency-name: semver
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Lars Eirik Korsgaard Hansen <71271458+larseirikhansen@users.noreply.github.com>
2023-07-14 15:06:55 +02:00
Lars Eirik Korsgaard Hansen 0fadd33e3e Combobox fixes (#2112)
Combobox post-release tweaks

* Add dynamic height to examples because of the dropdown

* Examples were not working: remove controlled prop for uncontrolled Comboboxes

* Add an example with controlled Combobox
2023-07-14 11:08:47 +02:00
Ken eadc0bce13 📝 Oppdatert changeset for bedre endringslogg 2023-07-13 15:50:29 +02:00
Lars Eirik Korsgaard Hansen 678db9b11b Update spotty-onions-cough.md 2023-07-13 15:39:40 +02:00
Lars Eirik Korsgaard Hansen e3027cc196 ✨ Ny komponent: Combobox (#1868)
✨Ny komponent: Combobox 🎉 

Combobox er en ny komponent som kombinerer tekstfelt og nedtrekksliste. 
- Velg mellom predefinerte alternativer i nedtrekkslisten
- Bruk input-feltet som filter for alternativene i nedtrekkslisten.
- Legg inn nye svar, hvis svaret du ikke finnes i listen
- Velg mellom SingleSelect eller MultiSelect-versjon for henholdsvis ett og flere valg. 
- Autocomplete svar i inputfeltet når man velger SingleSelect

Dokumentasjonen finner du på https://aksel.nav.no/komponenter/core/combobox
2023-07-13 15:17:05 +02:00
Ken ac0fd790e3 ⚰️ Fjernet ubrukt metrics-løsning (#2098) 2023-07-06 17:42:38 +02:00
SjurandKen c344c9beb4 Update removable.tsx (#2101)
Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>
2023-07-06 17:22:24 +02:00
SjurandKen 5cb5dea4ff Update toggle-neutral.tsx (#2100)
Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>
2023-07-06 14:42:51 +02:00
Sjur 054c63b161 Update toggle.tsx (#2099) 2023-07-06 14:42:12 +02:00
Julian Nymark f60ba32f97 split default example into separate examples (#2097)
* split default example into separate examples

* also keep the default

* remove unnecessary div wrapper for single elements
2023-07-06 13:28:26 +02:00
Ken 54811b394d ✨ Eksterne sider åpnes nå i ny fane 2023-07-06 13:14:33 +02:00
Ken c6b0570c4c 🔇 Fjernet API-access for tilbakemeldinger 2023-07-06 11:21:05 +02:00
Ken 8baa1ab0b4 🔒 Fjernet kommentarfelt på aksel 2023-07-05 09:24:10 +02:00
Ken 06ceeb3e6f 🐛 Fikset rouge ';' i preview 2023-07-04 21:55:21 +02:00
Ken 02f19858c7 🐛 Fikset sanity-auth for preview og edit-knapp 2023-07-04 21:51:21 +02:00
Ken 799e26a628 🐛 Fikset lenker på komponentside 2023-07-04 17:11:53 +02:00
Ken f5e5843916 Oppdatert sanity, Lister opp alle utdaterte artikler for forfatter (#2091) 2023-07-04 13:59:33 +02:00
github-actions[bot]andgithub-actions[bot] 4b8ed30554 Version Packages (#2090)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-07-03 14:38:52 +02:00
Ken 28430f1345 🐛 Fikset layout-bug i skjemakomponenter (#2089) 2023-07-03 14:34:50 +02:00
github-actions[bot]andgithub-actions[bot] d242325863 Version Packages (#2087)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-07-03 13:22:31 +02:00
Ken ae70bfa719 ReadOnly for skjemakomponenter (#2080) 2023-07-03 12:37:48 +02:00
Ken 0aef861622 📝 Readme-oppdatering 2023-06-30 14:33:58 +02:00
Ken 525b021e0f 📝 Oppdatert Switch-eksempler 2023-06-30 11:23:22 +02:00
Ken 761344c34e 🎨 Oppdatert sidebar-config for sanity 2023-06-30 10:34:39 +02:00
github-actions[bot]andgithub-actions[bot] a67f61c33f Version Packages (#2084)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-06-29 15:23:12 +02:00
Julian NymarkandKen b05958863b add close button to alert (#2079)
Co-authored-by: Ken <ken.aleksander@gmail.com>
2023-06-29 13:43:10 +02:00
Ken 883ae00dfa 💄 Fikset sidebar :before-element 2023-06-29 13:25:17 +02:00
176 changed files with 6574 additions and 2846 deletions

No files matched your search

+5
View File
@@ -10,6 +10,11 @@ module.exports = (indexers) => {
const exampleIndexer = async (fileName: string, opts) => {
let code = readFileSync(fileName, "utf-8").toString();
code = code
.split("\n")
.filter((line) => !line.includes("withDsExample"))
.join("\n");
code = code.replace("export const args =", "const args =");
code += `\nexport default { title: "Eksempler/${fileName
+3 -8
View File
@@ -1,5 +1,4 @@
const storyIndexers = require("./indexer");
const path = require("path");
const { mergeConfig } = require("vite");
@@ -59,15 +58,11 @@ module.exports = {
},
async viteFinal(config) {
return mergeConfig(config, {
define: { "process.env": {} },
define: {
"process.env": {},
},
resolve: {
alias: [
/*
// Example
{
find: "@components",
replacement: path.resolve(__dirname, "./src/components"),
}, */
{
find: "components",
replacement: path.resolve(
+1 -1
View File
@@ -4,7 +4,7 @@ import "./layout.css";
export const parameters = {
options: {
storySort: {
method: "",
method: "alphabetical",
order: ["Intro", "ds-react", ["form"], ["Default"], "ds-icons"],
locales: "",
},
+14
View File
@@ -1,5 +1,19 @@
# @navikt/aksel-icons
## 4.7.3
## 4.7.2
## 4.7.1
## 4.7.0
## 4.6.1
## 4.6.0
## 4.5.0
## 4.4.2
## 4.4.1
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-icons",
"version": "4.4.2",
"version": "4.7.3",
"author": "Aksel | NAV",
"homepage": "https://aksel.nav.no/ikoner",
"repository": {
+14
View File
@@ -1,5 +1,19 @@
# @navikt/aksel-stylelint
## 4.7.3
## 4.7.2
## 4.7.1
## 4.7.0
## 4.6.1
## 4.6.0
## 4.5.0
## 4.4.2
## 4.4.1
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-stylelint",
"version": "4.4.2",
"version": "4.7.3",
"author": "Aksel | NAV",
"homepage": "https://aksel.nav.no/grunnleggende/kode/stylelint",
"repository": {
@@ -35,8 +35,8 @@
"dev": "yarn watch:lint"
},
"devDependencies": {
"@navikt/ds-css": "^4.4.2",
"@navikt/ds-tokens": "^4.4.2",
"@navikt/ds-css": "^4.7.3",
"@navikt/ds-tokens": "^4.7.3",
"@types/jest": "^29.0.0",
"concurrently": "7.2.1",
"copyfiles": "2.4.1",
+49
View File
@@ -1,5 +1,54 @@
# @navikt/aksel
## 4.7.3
### Patch Changes
- Updated dependencies []:
- @navikt/ds-css@4.7.3
## 4.7.2
### Patch Changes
- Updated dependencies [[`e6fdfdc9b`](https://github.com/navikt/aksel/commit/e6fdfdc9b44156b6eb8ebc0eda3a9259dce800ff)]:
- @navikt/ds-css@4.7.2
## 4.7.1
### Patch Changes
- Updated dependencies []:
- @navikt/ds-css@4.7.1
## 4.7.0
### Patch Changes
- Updated dependencies [[`e3027cc19`](https://github.com/navikt/aksel/commit/e3027cc1966623433cfc2d28915273486c7bf611)]:
- @navikt/ds-css@4.7.0
## 4.6.1
### Patch Changes
- Updated dependencies [[`28430f134`](https://github.com/navikt/aksel/commit/28430f13455a8ecc10447e5e1ad75625d29c4bcb)]:
- @navikt/ds-css@4.6.1
## 4.6.0
### Patch Changes
- Updated dependencies [[`ae70bfa71`](https://github.com/navikt/aksel/commit/ae70bfa7192db46d8dea0dfb0222a857efc46be5)]:
- @navikt/ds-css@4.6.0
## 4.5.0
### Patch Changes
- Updated dependencies [[`b05958863`](https://github.com/navikt/aksel/commit/b05958863b1adadb1b6a3fcceb4477e7411b66b5), [`b05958863`](https://github.com/navikt/aksel/commit/b05958863b1adadb1b6a3fcceb4477e7411b66b5)]:
- @navikt/ds-css@4.5.0
## 4.4.2
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel",
"version": "4.4.2",
"version": "4.7.3",
"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": "4.4.2",
"@navikt/ds-css": "4.7.3",
"axios": "1.3.6",
"chalk": "4.1.0",
"clipboardy": "^2.3.0",
+38
View File
@@ -1,5 +1,43 @@
# @navikt/ds-css
## 4.7.3
## 4.7.2
### Patch Changes
- Stepper: Fikset `hotizontal`-bug når step var `completed` ([#2116](https://github.com/navikt/aksel/pull/2116))
## 4.7.1
## 4.7.0
### Minor Changes
- Ny komponent Combobox! ([#1868](https://github.com/navikt/aksel/pull/1868))
## 4.6.1
### Patch Changes
- Skjema: Labels og Legends bruker nå inline-flex når readOnly er satt ([#2089](https://github.com/navikt/aksel/pull/2089))
## 4.6.0
### Minor Changes
- Skjema: De fleste skjemakomponenter har nå styling for `readOnly`-state ([#2080](https://github.com/navikt/aksel/pull/2080))
## 4.5.0
### Minor Changes
- - Alert: La til `closeButton`-prop ([#2079](https://github.com/navikt/aksel/pull/2079))
### Patch Changes
- - Button: Fikset outline-bug i tertiary-variant ved `:active`-state ([#2079](https://github.com/navikt/aksel/pull/2079))
## 4.4.2
## 4.4.1
+9
View File
@@ -24,6 +24,7 @@
font-size: 1.5rem;
align-self: flex-start;
height: var(--a-font-line-height-xlarge);
margin: var(--a-spacing-1);
background: radial-gradient(circle, var(--a-surface-default) 50%, 0, transparent);
}
@@ -73,3 +74,11 @@
border: none;
padding: 0;
}
.navds-alert__button-wrapper {
align-self: flex-start;
flex: 1;
display: flex;
flex-flow: row nowrap;
justify-content: flex-end;
}
-2
View File
@@ -294,7 +294,6 @@
.navds-button--tertiary:active {
color: var(--ac-button-tertiary-active-text, var(--a-text-on-action));
background-color: var(--ac-button-tertiary-active-bg, var(--a-surface-action-active));
box-shadow: inset 0 0 0 1px var(--a-surface-default);
}
.navds-button--tertiary:active:hover {
@@ -346,7 +345,6 @@
.navds-button--tertiary-neutral:active {
color: var(--ac-button-tertiary-neutral-active-text, var(--a-text-on-neutral));
background-color: var(--ac-button-tertiary-neutral-active-bg, var(--a-surface-neutral-active));
box-shadow: inset 0 0 0 1px var(--a-surface-default);
}
.navds-button--tertiary-neutral:active:hover {
+5
View File
@@ -54,6 +54,11 @@ const StyleMappings = {
{ component: "Checkbox", main: formCss, dependencies: [typoCss] },
{ component: "CheckboxGroup", main: formCss, dependencies: [typoCss] },
{ component: "Chips", main: "chips.css", dependencies: [typoCss] },
{
component: "UNSAFE_Combobox",
main: "combobox.css",
dependencies: [typoCss],
},
{
component: "ConfirmationPanel",
main: formCss,
+6
View File
@@ -282,3 +282,9 @@
.navds-date__popover:where(.navds-popover) {
border: none;
}
/* Readonly */
.navds-date__field--readonly .navds-date__field-button {
cursor: default;
}
+287
View File
@@ -0,0 +1,287 @@
.navds-combobox__wrapper {
display: flex;
flex-direction: column;
width: 100%;
position: relative;
}
.navds-combobox__wrapper-inner {
position: relative;
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
border: 1px solid black;
}
.navds-combobox__wrapper-inner > :first-child {
flex: 2;
}
.navds-combobox__wrapper-inner > :last-child {
display: flex;
flex-flow: row nowrap;
}
.navds-form-field--small .navds-combobox__wrapper-inner {
padding: var(--a-spacing-1) calc(var(--a-spacing-3) / 2);
}
.navds-combobox__wrapper-inner:hover {
cursor: text;
}
.navds-combobox__selected-options {
gap: var(--a-spacing-2);
}
.navds-combobox__selected-options > li {
margin: auto 0;
border-radius: var(--a-border-radius-full);
}
.navds-combobox__selected-options > li:last-of-type {
display: flex;
flex: 1;
}
.navds-combobox__selected-options--no-bg {
font-family: inherit;
font-size: var(--a-font-size-large);
font-weight: var(--a-font-weight-regular);
letter-spacing: 0;
line-height: var(--a-font-line-height-large);
margin: 0;
padding-left: calc(0.5rem - 4px);
}
.navds-combobox__input-wrapper {
width: 100%;
}
.navds-combobox__input {
z-index: 1;
flex: 1;
border: none;
padding: 0;
margin: 0;
min-width: 10ch;
width: 100%;
}
.navds-combobox__input:focus-visible {
outline: none;
border: none;
box-shadow: none;
}
.navds-combobox__wrapper-inner:has(.navds-combobox__input:focus-visible) {
box-shadow: 0 0 0 1px var(--a-surface-default) inset, var(--a-shadow-focus);
box-shadow: var(--a-shadow-focus);
}
.navds-combobox__wrapper-inner:has(.navds-combobox__input:focus-visible).navds-combobox__wrapper-inner--virtually-unfocused {
box-shadow: none;
}
@supports not selector(:focus-visible) {
.navds-combobox__input:focus-visible {
outline: none;
border: none;
box-shadow: none;
}
.navds-combobox__wrapper-inner:has(.navds-combobox__input:focus) {
box-shadow: 0 0 0 1px var(--a-surface-default) inset, var(--a-shadow-focus);
box-shadow: var(--a-shadow-focus);
}
.navds-combobox__wrapper-inner:has(.navds-combobox__input:focus).navds-combobox__wrapper-inner--virtually-unfocused {
box-shadow: none;
}
}
.navds-combobox__button-clear {
border-radius: var(--a-border-radius-medium);
color: var(--ac-combobox-clear-icon, var(--a-text-subtle));
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
background: none;
border: none;
font-size: 1rem;
padding: 0;
}
.navds-combobox__button-clear svg {
width: 1.5rem;
height: 1.5rem;
}
.navds-combobox__input::-webkit-search-cancel-button {
display: none;
}
.navds-combobox__button-toggle-list {
border-radius: var(--a-border-radius-medium);
color: var(--a-text-default);
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
background: none;
border: none;
font-size: 1rem;
padding: 0;
}
.navds-combobox__button-clear:active:hover,
.navds-combobox__button-toggle-list:active:hover {
color: var(--ac-combobox-clear-icon-active, var(--a-text-action));
}
.navds-combobox__button-clear:hover,
.navds-combobox__button-toggle-list:hover {
color: var(--ac-combobox-clear-icon-hover, var(--a-text-action-selected));
}
.navds-combobox__button-toggle-list:focus-visible {
box-shadow: 0 0 0 1px var(--a-surface-default) inset, var(--a-shadow-focus);
box-shadow: var(--a-shadow-focus);
outline: none;
}
@supports not selector(:focus-visible) {
.navds-combobox__button-toggle-list:focus {
box-shadow: 0 0 0 1px var(--a-surface-default) inset, var(--a-shadow-focus);
box-shadow: var(--a-shadow-focus);
outline: none;
}
}
.navds-combobox__button-toggle-list svg {
width: 1.5rem;
height: 1.5rem;
pointer-events: none;
}
.navds-form-field--small .navds-combobox__button-toggle-list svg,
.navds-form-field--small .navds-combobox__button-clear svg {
width: 1.25rem;
height: 1.25rem;
}
/* dropdown list */
.navds-combobox__list {
max-height: 290px;
overflow-y: auto;
position: absolute;
left: 0;
right: 0;
z-index: 9999;
top: calc(100% + var(--a-spacing-2));
list-style: none;
margin: 0;
border: 1px solid var(--ac-combobox-list-border-color, var(--a-border-divider));
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 0;
box-shadow: var(--a-shadow-small);
border-radius: 4px;
gap: 4px 0;
background-color: var(--ac-combobox-list-bg, var(--a-surface-default));
color: var(--ac-combobox-list-text, var(--a-text-default));
}
.navds-combobox__list--closed {
display: none;
}
.navds-combobox__list svg {
height: 1.5rem;
width: 1.5rem;
}
.navds-combobox__list-item,
.navds-combobox__list-item__no-options,
.navds-combobox__list-item__new-option {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: var(--a-spacing-3);
width: 100%;
background-color: var(--ac-combobox-list-item-bg, var(--a-surface-default));
}
.navds-form-field--small .navds-combobox__list-item,
.navds-form-field--small .navds-combobox__list-item__no-options,
.navds-form-field--small .navds-combobox__list-item__new-option {
padding: calc(var(--a-spacing-3) / 2) var(--a-spacing-2);
}
.navds-combobox__list-item--loading {
display: flex;
justify-content: center;
padding: var(--a-spacing-3);
background-color: var(--ac-combobox-list-item-loading-bg, var(--a-surface-default));
width: 100%;
}
.navds-combobox__list-item--focus,
.navds-combobox__list-item:hover {
background-color: var(--ac-combobox-list-item-hover-bg, var(--a-surface-hover));
cursor: pointer;
border-left: 4px solid var(--ac-combobox-list-item-hover-border-left, var(--a-border-strong));
padding-left: calc(var(--a-spacing-3) - 4px);
}
.navds-combobox__list-item--selected {
background-color: var(--ac-combobox-list-item-selected-bg, var(--a-surface-selected));
}
.navds-combobox__list-item--selected.navds-combobox__list-item--focus,
.navds-combobox__list-item--selected:hover {
background-color: var(--ac-combobox-list-item-selected-hover-bg, var(--a-surface-action-subtle-hover));
border-left: 4px solid var(--ac-combobox-list-item-selected-hover-border-left, var(--a-border-focus));
padding-left: calc(var(--a-spacing-3) - 4px);
}
.navds-combobox__list-item__new-option {
border-bottom: 1px solid var(--a-border-divider);
background: var(--a-surface-neutral-subtle);
cursor: pointer;
justify-content: flex-start;
gap: 0.25rem;
}
.navds-combobox__list-item__new-option:hover {
border-bottom: 1px solid var(--a-border-divider);
background: var(--a-surface-neutral-subtle-hover);
}
.navds-combobox__list-item__new-option--focus {
box-shadow: var(--a-shadow-focus) inset, var(--a-border-action) 0 0 0 5px inset;
border-radius: 3px;
}
/* Mobile */
@media (max-width: 479px) {
.navds-combobox__button-toggle-list {
right: 0.5rem;
}
/* add bigger click area for input */
.navds-combobox__input {
min-width: min-content;
padding: 0.75rem 0;
}
.navds-combobox__selected-options {
gap: var(--a-spacing-1);
}
}
+4
View File
@@ -30,6 +30,10 @@
padding: 0;
}
.navds-fieldset--readonly > :where(.navds-fieldset__legend) {
display: inline-flex;
}
.navds-fieldset:disabled > .navds-fieldset__legend,
.navds-fieldset:disabled > .navds-fieldset__description {
opacity: 0.3;
+11
View File
@@ -36,3 +36,14 @@
.navds-form-field__subdescription {
color: var(--ac-form-subdescription, var(--a-text-subtle));
}
.navds-form-field--readonly > :where(.navds-form-field__label) {
display: inline-flex;
}
.navds-form-field__readonly-icon {
margin-top: var(--a-spacing-05);
margin-right: var(--a-spacing-2);
flex-shrink: 0;
align-self: flex-start;
}
+1
View File
@@ -11,3 +11,4 @@
@import "text-field.css";
@import "textarea.css";
@import "search.css";
@import "combobox.css";
+58 -1
View File
@@ -22,6 +22,9 @@
cursor: pointer;
display: flex;
gap: var(--a-spacing-2);
--__ac-radio-checkbox-readonly-bg: var(--a-surface-subtle);
--__ac-radio-checkbox-readonly-border: var(--a-border-subtle);
}
.navds-checkbox__label::before,
@@ -102,7 +105,7 @@
background-color: var(--ac-radio-checkbox-bg, var(--a-surface-default));
width: 0.75rem;
height: 0.25rem;
border-radius: 1px; /* Custom value OK */
border-radius: 1px;
flex-shrink: 0;
}
@@ -244,3 +247,57 @@
.navds-radio--disabled > .navds-radio__label {
cursor: not-allowed;
}
/* Readonly */
.navds-checkbox--readonly > :where(.navds-checkbox__input, .navds-checkbox__label),
.navds-radio--readonly > :where(.navds-radio__input, .navds-radio__label) {
cursor: default;
}
.navds-checkbox--readonly .navds-checkbox__label-text {
display: inline-flex;
}
.navds-checkbox--readonly > .navds-checkbox__input:hover + .navds-checkbox__label,
.navds-radio--readonly > .navds-radio__input:hover + .navds-radio__label {
color: var(--a-text-default);
}
.navds-checkbox--readonly
> .navds-checkbox__input:not(:disabled):not(:checked):not(:indeterminate)
+ .navds-checkbox__label::before,
.navds-checkbox--readonly
> .navds-checkbox__input:hover:not(:checked):not(:indeterminate):not(:focus-visible)
+ .navds-checkbox__label::before,
.navds-radio--readonly > .navds-radio__input:not(:disabled):not(:checked):not(:indeterminate) + .navds-radio__label::before,
.navds-radio--readonly
> .navds-radio__input:hover:not(:checked):not(:indeterminate):not(:focus-visible)
+ .navds-radio__label::before {
background-color: var(--__ac-radio-checkbox-readonly-bg);
box-shadow: inset 0 0 0 2px var(--__ac-radio-checkbox-readonly-border);
}
.navds-checkbox--readonly > .navds-checkbox__input:focus-visible + .navds-checkbox__label::before,
.navds-radio--readonly > .navds-radio__input:focus-visible + .navds-radio__label::before {
--__ac-radio-checkbox-readonly-border: var(--a-border-subtle), var(--a-shadow-focus);
}
.navds-checkbox--readonly > .navds-checkbox__input:checked + .navds-checkbox__label::before {
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMyAxMCI+ICAgIDxnPiAgICA8cGF0aCBmaWxsPSJyZ2JhKDAsIDAsIDAsIDAuNjUpIiBkPSJNNCwxMGMtMC40LDAtMC44LTAuMS0xLjEtMC40TDAuNCw3LjFDMC4xLDYuOCwwLDYuNCwwLDZzMC4yLTAuOCwwLjUtMS4xQzEsNC40LDIsNC40LDIuNSw0LjlMNCw2LjRsNi40LTYgICAgQzEwLjgsMC4xLDExLjEsMCwxMS41LDBjMC40LDAsMC44LDAuMiwxLDAuNWMwLjYsMC42LDAuNSwxLjYtMC4xLDIuMXYwTDUsOS42QzQuNyw5LjksNC40LDEwLDQsMTB6IE0xMS44LDEuOUwxMS44LDEuOSAgICBDMTEuOCwxLjksMTEuOCwxLjksMTEuOCwxLjl6IE0xMS4yLDEuMUMxMS4yLDEuMSwxMS4yLDEuMSwxMS4yLDEuMUwxMS4yLDEuMXoiLz4gICAgPC9nPjwvc3ZnPg==);
box-shadow: inset 0 0 0 2px var(--__ac-radio-checkbox-readonly-border);
background-color: var(--__ac-radio-checkbox-readonly-bg);
}
.navds-radio--readonly > .navds-radio__input:checked + .navds-radio__label::before {
box-shadow: inset 0 0 0 2px var(--__ac-radio-checkbox-readonly-border), inset 0 0 0 4px var(--__ac-radio-checkbox-readonly-bg);
background-color: var(--a-icon-subtle);
}
.navds-checkbox--readonly > .navds-checkbox__input:indeterminate + .navds-checkbox__label::before {
box-shadow: inset 0 0 0 2px var(--__ac-radio-checkbox-readonly-border);
background-color: var(--__ac-radio-checkbox-readonly-bg);
}
.navds-checkbox--readonly > .navds-checkbox__input:indeterminate + .navds-checkbox__label::after {
background-color: var(--a-icon-subtle);
}
+6
View File
@@ -79,3 +79,9 @@
.navds-select__input:disabled > option {
color: var(--a-text-default);
}
.navds-select--readonly .navds-select__input {
background-color: var(--a-surface-subtle);
border-color: var(--a-border-subtle);
cursor: default;
}
+41
View File
@@ -178,3 +178,44 @@
.navds-switch__input:disabled ~ .navds-switch__label-wrapper {
cursor: not-allowed;
}
/* Readonly */
.navds-switch--readonly > .navds-switch__track,
.navds-switch--readonly > .navds-switch__input:hover ~ .navds-switch__track,
.navds-switch--readonly > .navds-switch__input:checked ~ .navds-switch__track,
.navds-switch--readonly > .navds-switch__input:checked:hover ~ .navds-switch__track {
background-color: var(--a-surface-neutral-moderate);
}
.navds-switch--readonly > .navds-switch__label-wrapper,
.navds-switch--readonly > .navds-switch__input {
cursor: default;
}
.navds-switch--readonly > .navds-switch__input:hover ~ .navds-switch__label-wrapper,
.navds-switch--readonly .navds-switch__label-wrapper:hover {
color: var(--a-text-default);
}
.navds-switch--readonly .navds-switch__label {
display: inline-flex;
}
.navds-switch--readonly .navds-switch__thumb {
background-color: var(--a-surface-subtle);
box-shadow: 0 0 0 2px var(--a-border-default);
}
.navds-switch--readonly > .navds-switch__input:checked ~ .navds-switch__track > .navds-switch__thumb {
color: var(--a-icon-subtle);
}
@media (hover: hover) and (pointer: fine) {
.navds-switch--readonly > .navds-switch__input:hover ~ .navds-switch__track > .navds-switch__thumb {
transform: translateX(0);
}
.navds-switch--readonly > .navds-switch__input:checked:hover ~ .navds-switch__track > .navds-switch__thumb {
transform: translateX(20px);
}
}
+4 -5
View File
@@ -73,11 +73,10 @@
cursor: not-allowed;
}
.navds-text-field__input[readonly] {
background-color: var(--ac-textfield-bg, var(--a-surface-default));
border-color: var(--ac-textfield-border, var(--a-border-default));
box-shadow: none;
cursor: not-allowed;
.navds-text-field--readonly .navds-text-field__input {
background-color: var(--a-surface-subtle);
border-color: var(--a-border-subtle);
cursor: default;
}
/**
+4 -5
View File
@@ -99,9 +99,8 @@
cursor: not-allowed;
}
.navds-textarea__input[readonly] {
background-color: var(--ac-textarea-bg, var(--a-surface-default));
border-color: var(--ac-textarea-border, var(--a-border-default));
box-shadow: none;
cursor: not-allowed;
.navds-textarea--readonly .navds-textarea__input {
background-color: var(--a-surface-subtle);
border-color: var(--a-border-subtle);
cursor: default;
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-css",
"version": "4.4.2",
"version": "4.7.3",
"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": "^4.4.2",
"@navikt/ds-tokens": "^4.7.3",
"cssnano": "6.0.0",
"fast-glob": "3.2.11",
"lodash": "4.17.21",
+10
View File
@@ -40,6 +40,10 @@
background-color: var(--ac-stepper-line-completed, var(--a-border-selected));
}
.navds-stepper__item--behind.navds-stepper__item--completed + .navds-stepper__item > .navds-stepper__line--1 {
background-color: var(--ac-stepper-line-completed, var(--a-border-selected));
}
.navds-stepper__line--1 {
grid-row: line-1;
display: none;
@@ -213,6 +217,12 @@ button.navds-stepper__step {
background-color: var(--ac-stepper-non-interactive-line-completed, var(--a-border-strong));
}
.navds-stepper__item--non-interactive.navds-stepper__item--behind.navds-stepper__item--completed
+ .navds-stepper__item
> .navds-stepper__line--1 {
background-color: var(--ac-stepper-non-interactive-line-completed, var(--a-border-strong));
}
:where(.navds-stepper__step--non-interactive.navds-stepper__step--active) .navds-stepper__circle {
background-color: var(--ac-stepper-non-interactive-active-bg, var(--a-surface-inverted));
border-color: var(--ac-stepper-non-interactive-active-border, var(--a-surface-inverted));
+15
View File
@@ -372,6 +372,21 @@
"--ac-search-button-focus-active-border": "--a-surface-default",
"--ac-search-error-border": "--a-border-danger"
},
"combobox": {
"--ac-combobox-clear-icon": "--a-text-subtle",
"--ac-combobox-clear-icon-hover": "--a-text-action-selected",
"--ac-combobox-clear-icon-active": "--a-text-action",
"--ac-combobox-list-bg": "--a-surface-default",
"--ac-combobox-list-text": "--a-text-default",
"--ac-combobox-list-border-color": "--a-border-divider",
"--ac-combobox-list-item-bg": "--a-surface-default",
"--ac-combobox-list-item-hover-bg": "--a-surface-hover",
"--ac-combobox-list-item-selected-bg": "--a-surface-default",
"--ac-combobox-list-item-selected-hover-bg": "--a-surface-action-subtle-hover",
"--ac-combobox-list-item-loading-bg": "--a-surface-default",
"--ac-combobox-list-item-hover-border-left": "--a-border-strong",
"--ac-combobox-list-item-selected-hover-border-left": "--a-border-focus"
},
"select": {
"--ac-select-bg": "--a-surface-default",
"--ac-select-text": "--a-text-default",
+73
View File
@@ -1,5 +1,78 @@
# @navikt/ds-react
## 4.7.3
### Patch Changes
- Chips: Removable Chips submitter ikke forms ved klikk lengre ([#2124](https://github.com/navikt/aksel/pull/2124))
- Updated dependencies []:
- @navikt/aksel-icons@4.7.3
## 4.7.2
### Patch Changes
- Stepper: Fikset `hotizontal`-bug når step var `completed` ([#2116](https://github.com/navikt/aksel/pull/2116))
- Updated dependencies []:
- @navikt/aksel-icons@4.7.2
## 4.7.1
### Patch Changes
- Datepicker: Fikset bug ved bruk dynamisk oppdatering av minDate. Vist `month` vil nå alltid være oppdatert når datepicker åpnes ([#2117](https://github.com/navikt/aksel/pull/2117))
- Updated dependencies []:
- @navikt/aksel-icons@4.7.1
## 4.7.0
### Minor Changes
- Ny komponent Combobox! ([#1868](https://github.com/navikt/aksel/pull/1868))
### Patch Changes
- Timeline: Har nå egen `axisLabelTemplates`-prop for axixlabel formatering ([#2109](https://github.com/navikt/aksel/pull/2109))
- Combobox post-release tweaks ([#2112](https://github.com/navikt/aksel/pull/2112))
- Updated dependencies []:
- @navikt/aksel-icons@4.7.0
## 4.6.1
### Patch Changes
- Skjema: Labels og Legends bruker nå inline-flex når readOnly er satt ([#2089](https://github.com/navikt/aksel/pull/2089))
- Updated dependencies []:
- @navikt/aksel-icons@4.6.1
## 4.6.0
### Minor Changes
- Skjema: De fleste skjemakomponenter støtter nå `readOnly`-state ([#2080](https://github.com/navikt/aksel/pull/2080))
### Patch Changes
- Updated dependencies []:
- @navikt/aksel-icons@4.6.0
## 4.5.0
### Minor Changes
- - Alert: La til `closeButton`-prop ([#2079](https://github.com/navikt/aksel/pull/2079))
### Patch Changes
- Updated dependencies []:
- @navikt/aksel-icons@4.5.0
## 4.4.2
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-react",
"version": "4.4.2",
"version": "4.7.3",
"description": "Aksel react-components for NAV designsystem",
"author": "Aksel | NAV designsystem team",
"license": "MIT",
@@ -38,7 +38,7 @@
},
"dependencies": {
"@floating-ui/react": "0.24.1",
"@navikt/aksel-icons": "^4.4.2",
"@navikt/aksel-icons": "^4.7.3",
"@radix-ui/react-tabs": "1.0.0",
"@radix-ui/react-toggle-group": "1.0.0",
"clsx": "^1.2.1",
+49 -18
View File
@@ -3,10 +3,12 @@ import {
CheckmarkCircleFillIcon,
ExclamationmarkTriangleFillIcon,
XMarkOctagonFillIcon,
XMarkIcon,
} from "@navikt/aksel-icons";
import cl from "clsx";
import React, { forwardRef } from "react";
import { BodyLong } from "../typography/BodyLong";
import { Button } from "../button";
export interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
/**
@@ -32,6 +34,17 @@ export interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
* @default false
*/
inline?: boolean;
/**
* Removes close-button(X) when false
* Requires onClose to be set
* @default true
*/
closeButton?: boolean;
/**
* Callback for alert wanting to close
* requires closeButton to be true
*/
onClose?: () => void;
}
const Icon = ({ variant, ...props }) => {
@@ -71,27 +84,45 @@ export const Alert = forwardRef<HTMLDivElement, AlertProps>(
size = "medium",
fullWidth = false,
inline = false,
closeButton = false,
onClose,
...rest
},
ref
) => (
<div
{...rest}
ref={ref}
className={cl(
className,
"navds-alert",
`navds-alert--${variant}`,
`navds-alert--${size}`,
{ "navds-alert--full-width": fullWidth, "navds-alert--inline": inline }
)}
>
<Icon variant={variant} className="navds-alert__icon" />
<BodyLong as="div" size={size} className="navds-alert__wrapper">
{children}
</BodyLong>
</div>
)
) => {
return (
<div
{...rest}
ref={ref}
className={cl(
className,
"navds-alert",
`navds-alert--${variant}`,
`navds-alert--${size}`,
{
"navds-alert--full-width": fullWidth,
"navds-alert--inline": inline,
}
)}
>
<Icon variant={variant} className="navds-alert__icon" />
<BodyLong as="div" size={size} className="navds-alert__wrapper">
{children}
</BodyLong>
{closeButton && !inline && (
<div className="navds-alert__button-wrapper">
<Button
className="navds-alert__button"
size="small"
variant="tertiary-neutral"
onClick={onClose}
icon={<XMarkIcon title="Lukk Alert" />}
/>
</div>
)}
</div>
);
}
);
export default Alert;
+75 -22
View File
@@ -2,6 +2,8 @@ import type { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { Alert } from ".";
import { BodyLong, Heading as DsHeading, Link } from "..";
import { within, userEvent } from "@storybook/testing-library";
import { expect } from "@storybook/jest";
const meta: Meta<typeof Alert> = {
title: "ds-react/Alert",
@@ -20,22 +22,14 @@ const variants: Array<"error" | "warning" | "info" | "success"> = [
];
export const Default: Story = {
render: (props) => (
<Alert
variant={props.variant}
size={props.size}
fullWidth={props.fullWidth}
inline={props.inline}
>
{props.children}
</Alert>
),
render: (props) => <Alert {...props} />,
args: {
children: "Id elit esse enim reprehenderit enim nisi veniam nostrud.",
fullWidth: false,
variant: "info",
size: "medium",
closeButton: false,
},
argTypes: {
variant: {
@@ -50,21 +44,39 @@ export const Default: Story = {
},
options: ["medium", "small"],
},
closeButton: {
type: "boolean",
},
},
};
export const Small = () => {
return (
<div className="colgap">
{variants.map((variant, i) => (
<Alert key={variant} variant={variant} size="small">
{new Array(i + 1).fill(
"Id elit esse enim reprehenderit enim nisi veniam nostrud."
)}
</Alert>
))}
</div>
);
export const Small = {
render: (props) => {
return (
<div className="colgap">
{variants.map((variant, i) => (
<Alert
key={variant}
variant={variant}
size="small"
closeButton={props.closeButton}
>
{new Array(i + 1).fill(
"Id elit esse enim reprehenderit enim nisi veniam nostrud."
)}
</Alert>
))}
</div>
);
},
args: {
closeButton: false,
},
argtypes: {
closeButton: {
type: "boolean",
},
},
};
export const FullWidth = () => {
@@ -147,3 +159,44 @@ export const Links = () => {
</div>
);
};
const AlertWithCloseButton = ({ children }: { children?: React.ReactNode }) => {
let [show, setShow] = React.useState(true);
return (
show && (
<Alert variant="success" closeButton onClose={() => setShow(false)}>
{children || "Content"}
</Alert>
)
);
};
export const WithCloseButton: Story = {
render: () => {
return (
<div className="colgap">
<AlertWithCloseButton />
<AlertWithCloseButton>
<BodyLong>
Ullamco ullamco laborum et commodo sint culpa cupidatat culpa qui
laboris ex. Labore ex occaecat proident qui qui fugiat magna. Fugiat
sint commodo consequat eu aute.
</BodyLong>
<Link href="#">Id elit esse enim reprehenderit</Link>
</AlertWithCloseButton>
</div>
);
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const buttons = canvas.getAllByTitle("Lukk Alert");
await step("click button", async () => {
await userEvent.click(buttons[0]);
});
const buttonsAfter = canvas.getAllByTitle("Lukk Alert");
expect(buttonsAfter.length).toBe(1);
},
};
+1 -1
View File
@@ -70,7 +70,7 @@ export const Chips: ChipsComponent = forwardRef<HTMLUListElement, ChipsProps>(
})}
>
{React.Children.map(children, (chip, index) => {
return <li key={index + (chip?.toString() ?? "")}>{chip}</li>;
return <li key={chip?.toString() || index}>{chip}</li>;
})}
</ul>
);
+8 -8
View File
@@ -1,7 +1,6 @@
import { XMarkIcon } from "@navikt/aksel-icons";
import cl from "clsx";
import React, { forwardRef } from "react";
import { OverridableComponent } from "../util/OverridableComponent";
export interface RemovableChipsProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
@@ -22,26 +21,27 @@ export interface RemovableChipsProps
onDelete?: () => void;
}
export const RemovableChips: OverridableComponent<
RemovableChipsProps,
HTMLButtonElement
> = forwardRef(
export const RemovableChips = forwardRef<
HTMLButtonElement,
RemovableChipsProps
>(
(
{
className,
children,
variant = "action",
as: Component = "button",
removeLabel = "slett",
onDelete,
type = "button",
...rest
},
ref
) => {
return (
<Component
<button
{...rest}
ref={ref}
type={type}
className={cl(
"navds-chips__chip navds-chips__removable navds-chips--icon-right",
className,
@@ -57,7 +57,7 @@ export const RemovableChips: OverridableComponent<
<span className="navds-chips__removable-icon">
<XMarkIcon aria-hidden />
</span>
</Component>
</button>
);
}
);
+9 -2
View File
@@ -4,6 +4,7 @@ import React, { forwardRef, InputHTMLAttributes } from "react";
import { BodyShort, ErrorMessage, Label, omit } from "..";
import { FormFieldProps, useFormField } from "../form/useFormField";
import { useDateInputContext } from "./context";
import { ReadOnlyIcon } from "../form/ReadOnlyIcon";
export interface DateInputProps
extends FormFieldProps,
@@ -57,6 +58,7 @@ const DateInput = forwardRef<HTMLInputElement, DateInputProps>((props, ref) => {
errorId,
showErrorMsg,
hasError,
readOnly,
} = useFormField(props, conditionalVariables.prefix);
return (
@@ -71,6 +73,9 @@ const DateInput = forwardRef<HTMLInputElement, DateInputProps>((props, ref) => {
"navds-date__field--error": hasError,
"navds-form-field--disabled": !!inputProps.disabled,
"navds-text-field--disabled": !!inputProps.disabled,
"navds-form-field--readonly": readOnly,
"navds-text-field--readonly": readOnly,
"navds-date__field--readonly": readOnly,
}
)}
>
@@ -81,6 +86,7 @@ const DateInput = forwardRef<HTMLInputElement, DateInputProps>((props, ref) => {
"navds-sr-only": hideLabel,
})}
>
<ReadOnlyIcon readOnly={readOnly} />
{label}
</Label>
{!!description && (
@@ -102,6 +108,7 @@ const DateInput = forwardRef<HTMLInputElement, DateInputProps>((props, ref) => {
{...inputProps}
autoComplete="off"
aria-controls={ariaId}
readOnly={readOnly}
className={cl(
"navds-date__field-input",
"navds-text-field__input",
@@ -111,8 +118,8 @@ const DateInput = forwardRef<HTMLInputElement, DateInputProps>((props, ref) => {
size={14}
/>
<button
disabled={inputProps.disabled}
tabIndex={open ? -1 : 0}
disabled={inputProps.disabled || readOnly}
tabIndex={readOnly ? -1 : open ? -1 : 0}
onClick={() => onOpen()}
type="button"
className="navds-date__field-button"
@@ -341,3 +341,25 @@ export const Size = () => {
</div>
);
};
export const Readonly = () => {
const { datepickerProps, inputProps } = useDatepicker({
fromDate: new Date("Aug 23 2019"),
toDate: new Date("Feb 23 2024"),
onDateChange: console.log,
});
return (
<div style={{ display: "flex", gap: "1rem" }}>
<DatePicker {...datepickerProps} dropdownCaption>
<DatePicker.Input
size="medium"
{...inputProps}
value="01.02.2021"
label="Velg dato"
readOnly
/>
</DatePicker>
</div>
);
};
@@ -166,8 +166,7 @@ export const useDatepicker = (
const handleOpen = useCallback(
(open: boolean) => {
setOpen(open);
!open &&
setMonth(selectedDay ?? defaultSelected ?? defaultMonth ?? today);
open && setMonth(selectedDay ?? defaultSelected ?? defaultMonth ?? today);
},
[defaultMonth, defaultSelected, selectedDay, today]
);
@@ -204,6 +203,9 @@ export const useDatepicker = (
};
const handleFocus: React.FocusEventHandler<HTMLInputElement> = (e) => {
if (e.target.readOnly) {
return;
}
!open && openOnFocus && handleOpen(true);
let day = parseDate(
e.target.value,
@@ -163,8 +163,7 @@ export const useMonthpicker = (
const handleOpen = useCallback(
(open: boolean) => {
setOpen(open);
!open &&
setYear(selectedMonth ?? defaultSelected ?? defaultYear ?? today);
open && setYear(selectedMonth ?? defaultSelected ?? defaultYear ?? today);
},
[defaultSelected, defaultYear, selectedMonth, today]
);
@@ -201,6 +200,9 @@ export const useMonthpicker = (
};
const handleFocus: React.FocusEventHandler<HTMLInputElement> = (e) => {
if (e.target.readOnly) {
return;
}
!open && openOnFocus && handleOpen(true);
let day = parseDate(
e.target.value,
@@ -321,6 +321,9 @@ export const useRangeDatepicker = (
};
const handleFocus = (e, src: RangeT) => {
if (e.target.readOnly) {
return;
}
!open && openOnFocus && setOpen(true);
let day = parseDate(
e.target.value,
@@ -6,7 +6,7 @@ import { useFormField } from "./useFormField";
export interface ConfirmationPanelProps
extends Omit<
CheckboxProps,
"children" | "indeterminate" | "hideLabel" | "error"
"children" | "indeterminate" | "hideLabel" | "error" | "readOnly"
> {
/**
* Additional information on panel
@@ -3,6 +3,7 @@ import React, { FieldsetHTMLAttributes, forwardRef, useContext } from "react";
import { BodyShort, ErrorMessage, Label, omit } from "../..";
import { FormFieldProps } from "../useFormField";
import { useFieldset } from "./useFieldset";
import { ReadOnlyIcon } from "../ReadOnlyIcon";
export type FieldsetContextProps = {
/**
@@ -21,6 +22,10 @@ export type FieldsetContextProps = {
* Sets fieldset and all form-children to disabled
*/
disabled: boolean;
/**
* Read only-state
*/
readOnly?: boolean;
};
export const FieldsetContext = React.createContext<FieldsetContextProps | null>(
@@ -47,6 +52,7 @@ export interface FieldsetProps
* @default true
*/
errorPropagation?: boolean;
nativeReadOnly?: boolean;
}
export const Fieldset = forwardRef<HTMLFieldSetElement, FieldsetProps>(
@@ -57,6 +63,7 @@ export const Fieldset = forwardRef<HTMLFieldSetElement, FieldsetProps>(
showErrorMsg,
hasError,
size,
readOnly,
inputDescriptionId,
} = useFieldset(props);
@@ -69,6 +76,7 @@ export const Fieldset = forwardRef<HTMLFieldSetElement, FieldsetProps>(
legend,
description,
hideLegend,
nativeReadOnly = true,
...rest
} = props;
@@ -82,17 +90,21 @@ export const Fieldset = forwardRef<HTMLFieldSetElement, FieldsetProps>(
}),
size,
disabled: props.disabled ?? false,
readOnly,
}}
>
<fieldset
{...omit(rest, ["errorId", "error", "size"])}
{...omit(rest, ["errorId", "error", "size", "readOnly"])}
{...inputProps}
ref={ref}
className={cl(
className,
"navds-fieldset",
`navds-fieldset--${size}`,
{ "navds-fieldset--error": hasError }
{
"navds-fieldset--error": hasError,
"navds-fieldset--readonly": readOnly,
}
)}
>
<Label
@@ -102,6 +114,7 @@ export const Fieldset = forwardRef<HTMLFieldSetElement, FieldsetProps>(
"navds-sr-only": !!hideLegend,
})}
>
<ReadOnlyIcon readOnly={readOnly} nativeReadOnly={nativeReadOnly} />
{legend}
</Label>
{!!description && (
@@ -0,0 +1,20 @@
import { PadlockLockedFillIcon } from "@navikt/aksel-icons";
import React from "react";
export const ReadOnlyIcon = ({
readOnly,
nativeReadOnly = true,
}: {
readOnly?: boolean;
nativeReadOnly?: boolean;
}) => {
if (readOnly) {
return (
<PadlockLockedFillIcon
{...(nativeReadOnly ? { "aria-hidden": true } : { title: "readonly" })}
className="navds-form-field__readonly-icon"
/>
);
}
return null;
};
+29 -2
View File
@@ -4,6 +4,7 @@ import React, { forwardRef, SelectHTMLAttributes } from "react";
import { ChevronDownIcon } from "@navikt/aksel-icons";
import { BodyShort, ErrorMessage, Label, omit } from "..";
import { FormFieldProps, useFormField } from "./useFormField";
import { ReadOnlyIcon } from "./ReadOnlyIcon";
export interface SelectProps
extends FormFieldProps,
@@ -55,7 +56,8 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(
hasError,
size,
inputDescriptionId,
} = useFormField(props, "textField");
readOnly,
} = useFormField(props, "select");
const {
children,
@@ -68,6 +70,27 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(
...rest
} = props;
const readOnlyEventHandlers = {
onMouseDown: (evt) => {
// NOTE: does not prevent click
if (readOnly) {
evt.preventDefault();
// focus on the element as per readonly input behavior
evt.target.focus();
}
},
onKeyDown: (evt) => {
if (
readOnly &&
["ArrowDown", "ArrowUp", "ArrowRight", "ArrowLeft", " "].includes(
evt.key
)
) {
evt.preventDefault();
}
},
};
return (
<div
className={cl(
@@ -76,7 +99,9 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(
`navds-form-field--${size}`,
{
"navds-form-field--disabled": !!inputProps.disabled,
"navds-form-field--readonly": readOnly,
"navds-select--error": hasError,
"navds-select--readonly": readOnly,
}
)}
>
@@ -87,6 +112,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(
"navds-sr-only": hideLabel,
})}
>
<ReadOnlyIcon readOnly={readOnly} nativeReadOnly={false} />
{label}
</Label>
{!!description && (
@@ -103,8 +129,9 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(
)}
<div className="navds-select__container" style={style}>
<select
{...omit(rest, ["error", "errorId", "size"])}
{...omit(rest, ["error", "errorId", "size", "readOnly"])}
{...inputProps}
{...readOnlyEventHandlers}
ref={ref}
className={cl(
"navds-select__input",
+20 -9
View File
@@ -7,6 +7,7 @@ import React, {
} from "react";
import { BodyShort, Loader, omit } from "..";
import { FormFieldProps, useFormField } from "./useFormField";
import { ReadOnlyIcon } from "./ReadOnlyIcon";
const SelectedIcon = () => (
<svg
@@ -63,12 +64,12 @@ export interface SwitchProps
*
* @example
* ```jsx
* <Switch>Slå på notifikasjoner</Switch>
* <Switch>Varsle med SMS</Switch>
* ```
*/
export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
(props, ref) => {
const { inputProps, size } = useFormField(props, "switch");
const { inputProps, size, readOnly } = useFormField(props, "switch");
const {
children,
@@ -90,11 +91,6 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
checkedProp !== undefined && setChecked(checkedProp);
}, [checkedProp]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setChecked(e.target.checked);
props.onChange && props.onChange(e);
};
return (
<div
className={cl(
@@ -105,18 +101,32 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
{
"navds-switch--loading": loading,
"navds-switch--disabled": inputProps.disabled ?? loading,
"navds-switch--readonly": readOnly,
}
)}
>
<input
{...omit(rest, ["size"])}
{...omit(rest, ["size", "readOnly"])}
{...omit(inputProps, ["aria-invalid", "aria-describedby"])}
disabled={inputProps.disabled ?? loading}
checked={checkedProp}
defaultChecked={defaultChecked}
ref={ref}
type="checkbox"
onChange={(e) => handleChange(e)}
onChange={(e) => {
if (readOnly) {
return;
}
setChecked(e.target.checked);
props.onChange && props.onChange(e);
}}
onClick={(e) => {
if (readOnly) {
e.preventDefault();
return;
}
props?.onClick?.(e);
}}
className={cl(className, "navds-switch__input")}
/>
<span className="navds-switch__track">
@@ -136,6 +146,7 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
})}
>
<BodyShort as="div" size={size} className="navds-switch__label">
<ReadOnlyIcon readOnly={readOnly} nativeReadOnly={false} />
{children}
</BodyShort>
{description && (
@@ -1,6 +1,7 @@
import cl from "clsx";
import React, { forwardRef, InputHTMLAttributes } from "react";
import { BodyShort, ErrorMessage, Label, omit } from "..";
import { ReadOnlyIcon } from "./ReadOnlyIcon";
import { FormFieldProps, useFormField } from "./useFormField";
export interface TextFieldProps
@@ -62,6 +63,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
htmlSize,
hideLabel = false,
type = "text",
readOnly,
...rest
} = props;
@@ -71,10 +73,13 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
className,
"navds-form-field",
`navds-form-field--${size}`,
{
"navds-text-field--error": hasError,
"navds-text-field--disabled": !!inputProps.disabled,
"navds-form-field--disabled": !!inputProps.disabled,
"navds-form-field--readonly": readOnly,
"navds-text-field--readonly": readOnly,
}
)}
>
@@ -85,6 +90,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
"navds-sr-only": hideLabel,
})}
>
<ReadOnlyIcon readOnly={readOnly} />
{label}
</Label>
@@ -105,6 +111,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
{...inputProps}
ref={ref}
type={type}
readOnly={readOnly}
className={cl(
"navds-text-field__input",
"navds-body-short",
+6
View File
@@ -3,6 +3,7 @@ import React, { forwardRef, useState } from "react";
import { BodyShort, ErrorMessage, Label, omit, useId } from "..";
import TextareaAutosize from "../util/TextareaAutoSize";
import { FormFieldProps, useFormField } from "./useFormField";
import { ReadOnlyIcon } from "./ReadOnlyIcon";
/**
* TODO: Mulighet for lokalisering av sr-only/counter text
@@ -85,6 +86,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
hideLabel = false,
resize,
i18n,
readOnly,
...rest
} = props;
@@ -115,6 +117,8 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
`navds-form-field--${size}`,
{
"navds-form-field--disabled": !!inputProps.disabled,
"navds-form-field--readonly": readOnly,
"navds-textarea--readonly": readOnly,
"navds-textarea--error": hasError,
"navds-textarea--resize": resize,
}
@@ -127,6 +131,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
"navds-sr-only": hideLabel,
})}
>
<ReadOnlyIcon readOnly={readOnly} />
{label}
</Label>
{!!description && (
@@ -152,6 +157,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
}
minRows={getMinRows()}
ref={ref}
readOnly={readOnly}
className={cl(
"navds-textarea__input",
"navds-body-short",
@@ -4,6 +4,7 @@ import { BodyShort } from "../../typography";
import { omit } from "../../util";
import { FormFieldProps } from "../useFormField";
import useCheckbox from "./useCheckbox";
import { ReadOnlyIcon } from "../ReadOnlyIcon";
export interface CheckboxProps
extends FormFieldProps,
@@ -42,7 +43,7 @@ export interface CheckboxProps
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
(props, ref) => {
const { inputProps, hasError, size } = useCheckbox(props);
const { inputProps, hasError, size, readOnly, nested } = useCheckbox(props);
return (
<div
@@ -53,6 +54,7 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
{
"navds-checkbox--error": hasError,
"navds-checkbox--disabled": inputProps.disabled,
"navds-checkbox--readonly": readOnly,
}
)}
>
@@ -65,6 +67,7 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
"hideLabel",
"indeterminate",
"errorId",
"readOnly",
])}
{...inputProps}
type="checkbox"
@@ -88,7 +91,14 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
"navds-sr-only": props.hideLabel,
})}
>
<BodyShort as="span" size={size}>
<BodyShort
as="span"
size={size}
className="navds-checkbox__label-text"
>
{!nested && (
<ReadOnlyIcon readOnly={readOnly} nativeReadOnly={false} />
)}
{props.children}
</BodyShort>
{props.description && (
@@ -81,6 +81,7 @@ export const CheckboxGroup = forwardRef<
"navds-checkbox-group",
`navds-checkbox-group--${rest.size ?? fieldset?.size ?? "medium"}`
)}
nativeReadOnly={false}
>
<CheckboxGroupContext.Provider
value={{
@@ -1,7 +1,7 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { Meta } from "@storybook/react";
import React, { useState } from "react";
import { Checkbox, CheckboxGroup } from "../../index";
import { Meta } from "@storybook/react";
export default {
title: "ds-react/Checkbox",
@@ -190,3 +190,37 @@ export const Indeterminate = () => {
</>
);
};
export const Readonly = () => (
<div className="colgap">
<CheckboxGroup
legend="Hvilken frukt liker du?"
defaultValue={["banan"]}
readOnly
>
<Checkbox value="banan">Banan</Checkbox>
<Checkbox value="eple">Eple</Checkbox>
<Checkbox value="druer" indeterminate>
Druer
</Checkbox>
</CheckboxGroup>
<CheckboxGroup
legend="Hvilken frukt liker du?"
error="feilmelding"
defaultValue={["Eple"]}
readOnly
>
<Checkbox value="eple" description="Epler kommer i 4 varianter">
Eple
</Checkbox>
<Checkbox value="banan">Banan</Checkbox>
</CheckboxGroup>
<hr />
<Checkbox value="tekst1" readOnly>
Eple single
</Checkbox>
<Checkbox value="tekst1" checked readOnly>
Banan single
</Checkbox>
</div>
);
@@ -10,7 +10,7 @@ import { omit } from "../..";
const useCheckbox = ({ children, ...props }: CheckboxProps) => {
const checkboxGroup = useContext(CheckboxGroupContext);
const { inputProps, ...rest } = useFormField(
const { inputProps, readOnly, ...rest } = useFormField(
omit(props, ["description"]),
"checkbox"
);
@@ -30,6 +30,8 @@ const useCheckbox = ({ children, ...props }: CheckboxProps) => {
return {
...rest,
readOnly,
nested: !!checkboxGroup,
inputProps: {
...inputProps,
checked: checkboxGroup?.value
@@ -39,9 +41,19 @@ const useCheckbox = ({ children, ...props }: CheckboxProps) => {
? checkboxGroup.defaultValue.includes(props.value)
: props.defaultChecked,
onChange: (e) => {
if (readOnly) {
return;
}
props.onChange && props.onChange(e);
checkboxGroup && checkboxGroup.toggleValue(props.value);
},
onClick: (e) => {
if (readOnly) {
e.preventDefault();
return;
}
props?.onClick?.(e);
},
},
};
};
@@ -0,0 +1,29 @@
import React from "react";
import { XMarkIcon } from "@navikt/aksel-icons";
interface ClearButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
handleClear: (event: any) => void;
clearButtonLabel?: string;
}
export const ClearButton: React.FC<ClearButtonProps> = ({
handleClear,
clearButtonLabel,
...rest
}) => {
return (
<button
type="button"
onClick={handleClear}
className="navds-combobox__button-clear"
{...rest}
>
<span className="navds-sr-only">
{clearButtonLabel ? clearButtonLabel : "Tøm"}
</span>
<XMarkIcon aria-hidden />
</button>
);
};
export default ClearButton;
@@ -0,0 +1,136 @@
import cl from "clsx";
import React, { forwardRef, useMemo, useRef } from "react";
import { BodyShort, Label, mergeRefs } from "../..";
import ClearButton from "./ClearButton";
import FilteredOptions from "./FilteredOptions/FilteredOptions";
import { useFilteredOptionsContext } from "./FilteredOptions/filteredOptionsContext";
import SelectedOptions from "./SelectedOptions/SelectedOptions";
import ToggleListButton from "./ToggleListButton";
import { ComboboxProps } from "./types";
import { useSelectedOptionsContext } from "./SelectedOptions/selectedOptionsContext";
import ComboboxWrapper from "./ComboboxWrapper";
import { useInputContext } from "./Input/inputContext";
import Input from "./Input/Input";
export const Combobox = forwardRef<
HTMLInputElement,
Omit<ComboboxProps, "onChange" | "options" | "size">
>((props, ref) => {
const {
value: externalValue,
onClear,
className,
hideLabel = false,
description,
label,
clearButton = true,
clearButtonLabel,
toggleListButton = true,
toggleListButtonLabel,
inputClassName,
shouldShowSelectedOptions = true,
...rest
} = props;
const toggleListButtonRef = useRef<HTMLButtonElement>(null);
const { currentOption, toggleIsListOpen } = useFilteredOptionsContext();
const { selectedOptions } = useSelectedOptionsContext();
const {
clearInput,
focusInput,
hasError,
inputDescriptionId,
inputProps,
inputRef,
value,
size = "medium",
} = useInputContext();
const mergedInputRef = useMemo(
() => mergeRefs([inputRef, ref]),
[inputRef, ref]
);
return (
<ComboboxWrapper
className={className}
hasError={hasError}
inputProps={inputProps}
inputSize={size}
toggleIsListOpen={toggleIsListOpen}
toggleListButtonRef={toggleListButtonRef}
>
<Label
htmlFor={inputProps.id}
size={size}
className={cl("navds-form-field__label", {
"navds-sr-only": hideLabel,
})}
>
{label}
</Label>
{!!description && (
<BodyShort
as="div"
className={cl("navds-form-field__description", {
"navds-sr-only": hideLabel,
})}
id={inputDescriptionId}
size={size}
>
{description}
</BodyShort>
)}
<div className="navds-combobox__wrapper">
<div
className={cl(
"navds-combobox__wrapper-inner navds-text-field__input",
{
"navds-combobox__wrapper-inner--virtually-unfocused":
currentOption !== null,
}
)}
onClick={focusInput}
>
{!shouldShowSelectedOptions ? (
<Input
id={inputProps.id}
ref={mergedInputRef}
inputClassName={inputClassName}
{...rest}
/>
) : (
<SelectedOptions selectedOptions={selectedOptions} size={size}>
<Input
id={inputProps.id}
ref={mergedInputRef}
inputClassName={inputClassName}
{...rest}
/>
</SelectedOptions>
)}
<div>
{value && clearButton && (
<ClearButton
handleClear={clearInput}
clearButtonLabel={clearButtonLabel}
tabIndex={-1}
/>
)}
{toggleListButton && (
<ToggleListButton
toggleListButtonLabel={toggleListButtonLabel}
ref={toggleListButtonRef}
/>
)}
</div>
</div>
<FilteredOptions />
</div>
</ComboboxWrapper>
);
});
export default Combobox;
@@ -0,0 +1,99 @@
import React, { forwardRef } from "react";
import Combobox from "./Combobox";
import { FilteredOptionsProvider } from "./FilteredOptions/filteredOptionsContext";
import { CustomOptionsProvider } from "./customOptionsContext";
import { SelectedOptionsProvider } from "./SelectedOptions/selectedOptionsContext";
import { InputContextProvider } from "./Input/inputContext";
import { ComboboxProps } from "./types";
/**
* A component that allows the user to search in a list of options
*
* Has options for allowing only one or multiple options to be selected,
* or adding new, user-submitted values.
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/combobox)
*
* @example
* ```jsx
* const options = ["apple", "banana", "orange"];
*
* return (
* <Combobox
* label="Velg en verdi"
* options={options}
* id="my-combobox"
* shouldAutoComplete
* />
* )
* ```
*/
const ComboboxProvider = forwardRef<HTMLInputElement, ComboboxProps>(
(props, ref) => {
const {
allowNewValues = false,
children,
defaultValue,
error,
errorId,
filteredOptions,
id,
isListOpen,
isLoading = false,
isMultiSelect,
onToggleSelected,
selectedOptions,
options,
value,
onChange,
onClear,
shouldAutocomplete,
size,
...rest
} = props;
return (
<InputContextProvider
value={{
defaultValue,
error,
errorId,
id,
value,
onChange,
onClear,
shouldAutocomplete,
size,
}}
>
<CustomOptionsProvider>
<SelectedOptionsProvider
value={{
allowNewValues,
isMultiSelect,
selectedOptions,
onToggleSelected,
options,
}}
>
<FilteredOptionsProvider
value={{
allowNewValues,
filteredOptions,
isListOpen,
isLoading,
isMultiSelect,
options,
}}
>
<Combobox ref={ref} {...rest}>
{children}
</Combobox>
</FilteredOptionsProvider>
</SelectedOptionsProvider>
</CustomOptionsProvider>
</InputContextProvider>
);
}
);
export default ComboboxProvider;
@@ -0,0 +1,63 @@
import cl from "clsx";
import React, { useRef } from "react";
type ComboboxWrapperProps = {
children: any;
className?: string;
hasError: boolean;
inputProps: {
disabled?: boolean;
};
inputSize: string;
toggleIsListOpen: (isListOpen: boolean) => void;
toggleListButtonRef: React.RefObject<HTMLButtonElement>;
};
const ComboboxWrapper = ({
children,
className,
hasError,
inputProps,
inputSize,
toggleIsListOpen,
toggleListButtonRef,
}: ComboboxWrapperProps) => {
const wrapperRef = useRef<HTMLDivElement | null>(null);
function onFocusInsideWrapper(e) {
if (
!wrapperRef.current?.contains(e.relatedTarget) &&
toggleListButtonRef?.current !== e.target
) {
toggleIsListOpen(true);
}
}
function onBlurWrapper(e) {
if (!wrapperRef.current?.contains(e.relatedTarget)) {
toggleIsListOpen(false);
}
}
return (
<div
ref={wrapperRef}
className={cl(
className,
"navds-form-field",
`navds-form-field--${inputSize}`,
"navds-search",
{
"navds-search--error": hasError,
"navds-search--disabled": !!inputProps.disabled,
}
)}
onFocus={onFocusInsideWrapper}
onBlur={onBlurWrapper}
>
{children}
</div>
);
};
export default ComboboxWrapper;
@@ -0,0 +1,23 @@
import React from "react";
const CheckIcon = () => {
return (
<svg
width="16"
height="13"
viewBox="0 0 16 13"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
fillRule="evenodd"
clipRule="evenodd"
fill="#005B82"
d="M14.2014 0L16 1.89047L4.77943 13L0 8.39552L1.79361 6.5L4.77418 9.3019L14.2014 0Z"
/>
</svg>
);
};
export default CheckIcon;
@@ -0,0 +1,106 @@
import React from "react";
import cl from "clsx";
import { BodyShort, Label, Loader } from "../../..";
import { CheckmarkIcon, PlusIcon } from "@navikt/aksel-icons";
import { useFilteredOptionsContext } from "./filteredOptionsContext";
import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
import { useInputContext } from "../Input/inputContext";
const FilteredOptions = () => {
const {
inputProps: { id },
size,
value,
} = useInputContext();
const {
allowNewValues,
isLoading,
isListOpen,
filteredOptions,
filteredOptionsIndex,
filteredOptionsRef,
isValueNew,
toggleIsListOpen,
} = useFilteredOptionsContext();
const { isMultiSelect, selectedOptions, toggleOption } =
useSelectedOptionsContext();
return (
<ul
ref={filteredOptionsRef}
className={cl("navds-combobox__list", {
"navds-combobox__list--closed": !isListOpen,
})}
id={`${id}-filtered-options`}
role="listbox"
tabIndex={-1}
>
{isLoading && (
<li
className="navds-combobox__list-item--loading"
role="option"
aria-selected={false}
id={`${id}-is-loading`}
>
<Loader aria-label="Søker..." />
</li>
)}
{isValueNew && allowNewValues && (
<li
tabIndex={-1}
onPointerUp={(event) => toggleOption(value, event)}
id={`${id}-combobox-new-option`}
className={cl("navds-combobox__list-item__new-option", {
"navds-combobox__list-item__new-option--focus":
filteredOptionsIndex === -1,
})}
role="option"
aria-selected={false}
>
<PlusIcon aria-hidden />
<BodyShort size={size}>
Legg til{" "}
<Label as="span" size={size}>
&#8220;{value}&#8221;
</Label>
</BodyShort>
</li>
)}
{!isLoading && filteredOptions.length === 0 && (
<li
className="navds-combobox__list-item__no-options"
role="option"
aria-selected={false}
id={`${id}-no-hits`}
>
Ingen søketreff
</li>
)}
{filteredOptions.map((option, index) => (
<li
className={cl("navds-combobox__list-item", {
"navds-combobox__list-item--focus": index === filteredOptionsIndex,
"navds-combobox__list-item--selected":
selectedOptions.includes(option),
})}
id={`${id}-option-${option.replace(" ", "-")}`}
key={option}
tabIndex={-1}
onPointerUp={(event) => {
toggleOption(option, event);
if (!isMultiSelect) {
toggleIsListOpen(false);
}
}}
role="option"
aria-selected={selectedOptions.includes(option)}
>
<BodyShort size={size}>{option}</BodyShort>
{selectedOptions.includes(option) && <CheckmarkIcon />}
</li>
))}
</ul>
);
};
export default FilteredOptions;
@@ -0,0 +1,266 @@
import React, {
useState,
useEffect,
useMemo,
createContext,
useContext,
useCallback,
useRef,
useLayoutEffect,
} from "react";
import { useCustomOptionsContext } from "../customOptionsContext";
import { useInputContext } from "../Input/inputContext";
import usePrevious from "../../../util/usePrevious";
const normalizeText = (text: string): string =>
typeof text === "string" ? `${text}`.toLowerCase().trim() : "";
const isPartOfText = (value, text) =>
normalizeText(text).startsWith(normalizeText(value ?? ""));
const isValueInList = (value, list) =>
list?.find((listItem) => normalizeText(value) === normalizeText(listItem));
const getMatchingValuesFromList = (value, list) =>
list?.filter((listItem) => isPartOfText(value, listItem));
type FilteredOptionsContextType = {
activeDecendantId?: string;
allowNewValues?: boolean;
ariaDescribedBy?: string;
filteredOptionsRef: React.RefObject<HTMLUListElement>;
filteredOptionsIndex: number | null;
setFilteredOptionsIndex: (index: number) => void;
isListOpen: boolean;
isLoading?: boolean;
filteredOptions: string[];
isValueNew: boolean;
toggleIsListOpen: (newState?: boolean) => void;
currentOption: string | null;
resetFilteredOptionsIndex: () => void;
moveFocusUp: () => void;
moveFocusDown: () => void;
moveFocusToInput: () => void;
moveFocusToEnd: () => void;
shouldAutocomplete?: boolean;
};
const FilteredOptionsContext = createContext<FilteredOptionsContextType>(
{} as FilteredOptionsContextType
);
export const FilteredOptionsProvider = ({ children, value: props }) => {
const {
allowNewValues,
filteredOptions: externalFilteredOptions,
isListOpen: isExternalListOpen,
isLoading,
options,
} = props;
const filteredOptionsRef = useRef<HTMLUListElement | null>(null);
const {
inputProps: { id },
value,
searchTerm,
setValue,
setSearchTerm,
shouldAutocomplete,
} = useInputContext();
const [filteredOptionsIndex, setFilteredOptionsIndex] = useState<
number | null
>(null);
const [isInternalListOpen, setInternalListOpen] = useState(false);
const { customOptions } = useCustomOptionsContext();
const filteredOptions = useMemo(() => {
if (externalFilteredOptions) {
return externalFilteredOptions;
}
const opts = [...customOptions, ...options];
setFilteredOptionsIndex(null);
return getMatchingValuesFromList(searchTerm, opts);
}, [customOptions, externalFilteredOptions, options, searchTerm]);
const previousSearchTerm = usePrevious(searchTerm);
useLayoutEffect(() => {
if (
shouldAutocomplete &&
normalizeText(searchTerm) !== "" &&
(previousSearchTerm?.length || 0) < searchTerm.length &&
filteredOptions.length > 0 &&
!isValueInList(searchTerm, filteredOptions)
) {
setValue(
`${searchTerm}${filteredOptions[0].substring(searchTerm.length)}`
);
setSearchTerm(searchTerm);
}
}, [
filteredOptions,
previousSearchTerm,
searchTerm,
setSearchTerm,
setValue,
shouldAutocomplete,
]);
const isListOpen = useMemo(() => {
return isExternalListOpen ?? isInternalListOpen;
}, [isExternalListOpen, isInternalListOpen]);
const toggleIsListOpen = useCallback((newState?: boolean) => {
setFilteredOptionsIndex(null);
setInternalListOpen((oldState) => newState ?? !oldState);
}, []);
const isValueNew = useMemo(
() => Boolean(value) && !isValueInList(value, filteredOptions),
[value, filteredOptions]
);
const getMinimumIndex = useCallback(() => {
return isValueNew && allowNewValues ? -1 : 0;
}, [allowNewValues, isValueNew]);
const ariaDescribedBy = useMemo(() => {
if (!isLoading && filteredOptions.length === 0) {
return `${id}-no-hits`;
} else if ((value && value !== "") || isLoading) {
if (shouldAutocomplete && filteredOptions[0]) {
return `${id}-option-${filteredOptions[0].replace(" ", "-")}`;
} else if (isLoading) {
return `${id}-is-loading`;
}
} else {
return undefined;
}
}, [isLoading, value, shouldAutocomplete, filteredOptions, id]);
const currentOption = useMemo(() => {
if (filteredOptionsIndex == null) {
return null;
}
if (filteredOptionsIndex === -1) {
return value;
}
return filteredOptions[filteredOptionsIndex];
}, [filteredOptionsIndex, filteredOptions, value]);
const resetFilteredOptionsIndex = () => {
setFilteredOptionsIndex(getMinimumIndex());
};
const scrollToOption = useCallback((newIndex: number) => {
if (
filteredOptionsRef.current &&
filteredOptionsRef.current.children[newIndex]
) {
const child = filteredOptionsRef.current.children[newIndex];
const { top, bottom } = child.getBoundingClientRect();
const parentRect = filteredOptionsRef.current.getBoundingClientRect();
if (top < parentRect.top || bottom > parentRect.bottom) {
child.scrollIntoView({ block: "nearest" });
}
}
}, []);
useEffect(() => {
if (filteredOptionsIndex !== null && isListOpen) {
scrollToOption(filteredOptionsIndex);
}
}, [filteredOptionsIndex, isListOpen, scrollToOption]);
const moveFocusToInput = useCallback(() => {
setFilteredOptionsIndex(null);
toggleIsListOpen(false);
}, [toggleIsListOpen]);
const moveFocusToEnd = useCallback(() => {
const lastIndex = filteredOptions.length - 1;
toggleIsListOpen(true);
setFilteredOptionsIndex(lastIndex);
}, [filteredOptions.length, toggleIsListOpen]);
const moveFocusUp = useCallback(() => {
if (filteredOptionsIndex === null) {
return;
}
if (filteredOptionsIndex === getMinimumIndex()) {
toggleIsListOpen(false);
setFilteredOptionsIndex(null);
} else {
const newIndex = Math.max(getMinimumIndex(), filteredOptionsIndex - 1);
setFilteredOptionsIndex(newIndex);
}
}, [filteredOptionsIndex, getMinimumIndex, toggleIsListOpen]);
const moveFocusDown = useCallback(() => {
if (filteredOptionsIndex === null || !isListOpen) {
toggleIsListOpen(true);
if (allowNewValues || filteredOptions.length >= 1) {
setFilteredOptionsIndex(getMinimumIndex());
}
return;
}
const newIndex = Math.min(
filteredOptionsIndex + 1,
Math.max(getMinimumIndex(), filteredOptions.length - 1)
);
setFilteredOptionsIndex(newIndex);
}, [
allowNewValues,
filteredOptions.length,
filteredOptionsIndex,
getMinimumIndex,
isListOpen,
toggleIsListOpen,
]);
const activeDecendantId = useMemo(() => {
if (filteredOptionsIndex === null) {
return undefined;
} else if (filteredOptionsIndex === -1) {
return `${id}-combobox-new-option`;
} else {
return `${id}-option-${currentOption?.replace(" ", "-")}`;
}
}, [filteredOptionsIndex, currentOption, id]);
const filteredOptionsState = {
activeDecendantId,
allowNewValues,
filteredOptionsRef,
filteredOptionsIndex,
setFilteredOptionsIndex,
shouldAutocomplete,
isListOpen,
isLoading,
filteredOptions,
isValueNew,
toggleIsListOpen,
currentOption,
resetFilteredOptionsIndex,
moveFocusUp,
moveFocusDown,
moveFocusToInput,
moveFocusToEnd,
ariaDescribedBy,
};
return (
<FilteredOptionsContext.Provider value={filteredOptionsState}>
{children}
</FilteredOptionsContext.Provider>
);
};
export const useFilteredOptionsContext = () => {
const context = useContext(FilteredOptionsContext);
if (!context) {
throw new Error(
"useFilteredOptionsContext must be used within a FilteredOptionsProvider"
);
}
return context;
};
@@ -0,0 +1,170 @@
import { omit } from "../../..";
import React, {
useCallback,
forwardRef,
InputHTMLAttributes,
ChangeEvent,
} from "react";
import cl from "clsx";
import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
import { useFilteredOptionsContext } from "../FilteredOptions/filteredOptionsContext";
import { useInputContext } from "./inputContext";
interface InputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "value"> {
ref: React.Ref<HTMLInputElement>;
inputClassName?: string;
errorId?: string;
value?: string;
error?: React.ReactNode;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ inputClassName, error, errorId, ...rest }, ref) => {
const { clearInput, inputProps, onChange, size, value } = useInputContext();
const { selectedOptions, removeSelectedOption, toggleOption } =
useSelectedOptionsContext();
const {
activeDecendantId,
allowNewValues,
currentOption,
filteredOptions,
toggleIsListOpen,
isListOpen,
filteredOptionsIndex,
moveFocusUp,
moveFocusDown,
ariaDescribedBy,
moveFocusToInput,
moveFocusToEnd,
shouldAutocomplete,
} = useFilteredOptionsContext();
const onEnter = useCallback(
(event: React.KeyboardEvent) => {
if (currentOption) {
event.preventDefault();
// Selecting a value from the dropdown / FilteredOptions
toggleOption(currentOption, event);
clearInput(event);
} else if (shouldAutocomplete && selectedOptions.includes(value)) {
event.preventDefault();
// Trying to set the same value that is already set, so just clearing the input
clearInput(event);
} else if ((allowNewValues || shouldAutocomplete) && value !== "") {
event.preventDefault();
// Autocompleting or adding a new value
toggleOption(value, event);
clearInput(event);
}
},
[
allowNewValues,
clearInput,
currentOption,
selectedOptions,
shouldAutocomplete,
toggleOption,
value,
]
);
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
e.preventDefault();
switch (e.key) {
case "Escape":
clearInput(e);
toggleIsListOpen(false);
break;
case "Enter":
case "Accept":
onEnter(e);
break;
case "Home":
moveFocusToInput();
break;
case "End":
moveFocusToEnd();
break;
default:
break;
}
};
const handleKeyDown = useCallback(
(e) => {
if (e.key === "Backspace") {
if (value === "") {
const lastSelectedOption =
selectedOptions[selectedOptions.length - 1];
removeSelectedOption(lastSelectedOption);
}
} else if (e.key === "ArrowDown") {
// Check that cursor position is at the end of the input field,
// so we don't interfere with text editing
if (e.target.selectionStart === value?.length) {
e.preventDefault();
moveFocusDown();
}
} else if (e.key === "ArrowUp") {
// Check that the FilteredOptions list is open and has virtual focus.
// Otherwise ignore keystrokes, so it doesn't interfere with text editing
if (isListOpen && filteredOptionsIndex !== null) {
e.preventDefault();
moveFocusUp();
}
}
},
[
value,
selectedOptions,
removeSelectedOption,
moveFocusDown,
isListOpen,
filteredOptionsIndex,
moveFocusUp,
]
);
const onChangeHandler = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value;
if (newValue && newValue !== "") {
toggleIsListOpen(true);
} else if (filteredOptions.length === 0) {
toggleIsListOpen(false);
}
onChange(event);
},
[filteredOptions.length, onChange, toggleIsListOpen]
);
return (
<input
{...rest}
{...omit(inputProps, ["aria-invalid"])}
ref={ref}
value={value}
onChange={onChangeHandler}
type="text"
role="combobox"
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
aria-controls={`${inputProps.id}-filtered-options`}
aria-expanded={!!isListOpen}
autoComplete="off"
aria-autocomplete={shouldAutocomplete ? "both" : "list"}
aria-activedescendant={activeDecendantId}
aria-describedby={ariaDescribedBy}
className={cl(
inputClassName,
"navds-combobox__input",
"navds-body-short",
`navds-body-${size}`
)}
/>
);
}
);
export default Input;
@@ -0,0 +1,127 @@
import React, {
ChangeEvent,
ChangeEventHandler,
createContext,
useCallback,
useContext,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useFormField, FormFieldType } from "../../useFormField";
interface InputContextType extends FormFieldType {
clearInput: (event: React.PointerEvent | React.KeyboardEvent) => void;
focusInput: () => void;
inputRef: React.RefObject<HTMLInputElement>;
value: string;
setValue: (text: string) => void;
onChange: ChangeEventHandler<HTMLInputElement>;
searchTerm: string;
setSearchTerm: React.Dispatch<React.SetStateAction<string>>;
shouldAutocomplete?: boolean;
}
const InputContext = createContext<InputContextType>({} as InputContextType);
export const InputContextProvider = ({ children, value: props }) => {
const {
defaultValue = "",
description,
disabled,
error,
errorId,
id: externalId,
value: externalValue,
onChange: externalOnChange,
onClear,
shouldAutocomplete,
size,
} = props;
const formFieldProps = useFormField(
{
description,
disabled,
error,
errorId,
id: externalId,
size,
},
"comboboxfield"
);
const inputRef = useRef<HTMLInputElement | null>(null);
const [internalValue, setInternalValue] = useState<string>(defaultValue);
const value = useMemo(
() => String(externalValue ?? internalValue),
[externalValue, internalValue]
);
const [searchTerm, setSearchTerm] = useState(value);
const onChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.value;
externalValue ?? setInternalValue(value);
externalOnChange?.(event);
setSearchTerm(value);
},
[externalValue, externalOnChange]
);
const setValue = useCallback(
(text) => {
setInternalValue(text);
},
[setInternalValue]
);
const clearInput = useCallback(
(event: React.PointerEvent | React.KeyboardEvent) => {
onClear?.(event);
setValue("");
setSearchTerm("");
},
[onClear, setSearchTerm, setValue]
);
const focusInput = useCallback(() => {
inputRef.current?.focus?.();
}, []);
useLayoutEffect(() => {
if (shouldAutocomplete && inputRef && value !== searchTerm) {
inputRef.current?.setSelectionRange?.(searchTerm.length, value.length);
}
}, [value, searchTerm, shouldAutocomplete]);
return (
<InputContext.Provider
value={{
...formFieldProps,
clearInput,
focusInput,
inputRef,
value,
setValue,
onChange,
searchTerm,
setSearchTerm,
shouldAutocomplete,
}}
>
{children}
</InputContext.Provider>
);
};
export const useInputContext = () => {
const context = useContext(InputContext);
if (!context) {
throw new Error(
"useInputContext must be used within an InputContextProvider"
);
}
return context;
};
@@ -0,0 +1,45 @@
import React from "react";
import { Chips } from "../../..";
import { useSelectedOptionsContext } from "./selectedOptionsContext";
interface SelectedOptionsProps {
selectedOptions?: string[];
size?: "medium" | "small";
children: React.ReactNode;
}
const Option = ({ option }: { option: string }) => {
const { isMultiSelect, removeSelectedOption } = useSelectedOptionsContext();
const onClick = (e) => {
e.stopPropagation();
removeSelectedOption(option);
};
if (!isMultiSelect) {
return (
<div className="navds-combobox__selected-options--no-bg">{option}</div>
);
}
return <Chips.Removable onClick={onClick}>{option}</Chips.Removable>;
};
const SelectedOptions: React.FC<SelectedOptionsProps> = ({
selectedOptions = [],
size,
children,
}) => {
return (
<Chips className="navds-combobox__selected-options" size={size}>
{selectedOptions.length
? selectedOptions.map((option, i) => (
<Option key={option + i} option={option} />
))
: []}
{children}
</Chips>
);
};
export default SelectedOptions;
@@ -0,0 +1,147 @@
import React, {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from "react";
import usePrevious from "../../../util/usePrevious";
import { useInputContext } from "../Input/inputContext";
import { ComboboxProps } from "../types";
import { useCustomOptionsContext } from "../customOptionsContext";
type SelectedOptionsContextType = {
addSelectedOption: (option: string) => void;
isMultiSelect?: boolean;
removeSelectedOption: (option: string) => void;
prevSelectedOptions?: string[];
selectedOptions: string[];
setSelectedOptions: (any) => void;
toggleOption: (
option: string,
event: React.KeyboardEvent | React.PointerEvent
) => void;
};
const SelectedOptionsContext = createContext<SelectedOptionsContextType>(
{} as SelectedOptionsContextType
);
export const SelectedOptionsProvider = ({
children,
value,
}: {
children: any;
value: Pick<
ComboboxProps,
| "allowNewValues"
| "isMultiSelect"
| "options"
| "selectedOptions"
| "onToggleSelected"
>;
}) => {
const { clearInput, focusInput } = useInputContext();
const { customOptions, removeCustomOption, addCustomOption } =
useCustomOptionsContext();
const {
allowNewValues,
isMultiSelect,
selectedOptions: externalSelectedOptions,
onToggleSelected,
options,
} = value;
const [internalSelectedOptions, setSelectedOptions] = useState<string[]>([]);
const selectedOptions = useMemo(
() =>
externalSelectedOptions ?? [...customOptions, ...internalSelectedOptions],
[customOptions, externalSelectedOptions, internalSelectedOptions]
);
const addSelectedOption = useCallback(
(option: string) => {
if (
!options
.map((opt) => opt.toLowerCase())
.includes(option?.toLowerCase?.())
) {
allowNewValues && addCustomOption(option);
} else if (isMultiSelect) {
setSelectedOptions((prevSelectedOptions) => [
...prevSelectedOptions,
option,
]);
} else {
setSelectedOptions([option]);
}
onToggleSelected?.(option, true);
},
[addCustomOption, allowNewValues, isMultiSelect, onToggleSelected, options]
);
const removeSelectedOption = useCallback(
(option: string) => {
if (customOptions.includes(option)) {
removeCustomOption(option);
} else {
setSelectedOptions((prevSelectedOptions) =>
prevSelectedOptions.filter(
(selectedOption) => selectedOption !== option
)
);
}
onToggleSelected?.(option, false);
},
[customOptions, onToggleSelected, removeCustomOption]
);
const toggleOption = useCallback(
(option: string, event: React.KeyboardEvent | React.PointerEvent) => {
if (selectedOptions.includes(option)) {
removeSelectedOption(option);
} else {
addSelectedOption(option);
}
if (!isMultiSelect) {
clearInput(event);
}
focusInput();
},
[
addSelectedOption,
clearInput,
focusInput,
isMultiSelect,
removeSelectedOption,
selectedOptions,
]
);
const prevSelectedOptions = usePrevious<string[]>(selectedOptions);
const selectedOptionsState = {
addSelectedOption,
isMultiSelect,
removeSelectedOption,
prevSelectedOptions,
selectedOptions,
setSelectedOptions,
toggleOption,
};
return (
<SelectedOptionsContext.Provider value={selectedOptionsState}>
{children}
</SelectedOptionsContext.Provider>
);
};
export const useSelectedOptionsContext = () => {
const context = useContext(SelectedOptionsContext);
if (!context) {
throw new Error(
"useSelectedOptionsContext must be used within a SelectedOptionsProvider"
);
}
return context;
};
@@ -0,0 +1,37 @@
import React, { forwardRef } from "react";
import { ChevronUpIcon, ChevronDownIcon } from "@navikt/aksel-icons";
import { useFilteredOptionsContext } from "./FilteredOptions/filteredOptionsContext";
interface ToggleListButtonProps {
toggleListButtonLabel?: string;
}
export const ToggleListButton = forwardRef<
HTMLButtonElement,
ToggleListButtonProps
>(({ toggleListButtonLabel }, ref) => {
const { isListOpen, toggleIsListOpen } = useFilteredOptionsContext();
return (
<button
type="button"
onPointerUp={() => toggleIsListOpen()}
onKeyDown={({ key }) => key === "Enter" && toggleIsListOpen()}
className="navds-combobox__button-toggle-list"
aria-expanded={isListOpen}
tabIndex={-1}
ref={ref}
>
<span className="navds-sr-only">
{toggleListButtonLabel ?? "Alternativer"}
</span>
{isListOpen ? (
<ChevronUpIcon aria-hidden />
) : (
<ChevronDownIcon aria-hidden />
)}
</button>
);
});
export default ToggleListButton;
@@ -0,0 +1,413 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { Meta } from "@storybook/react";
import React, { useState, useId, useMemo } from "react";
import { userEvent, within } from "@storybook/testing-library";
import { Chips, UNSAFE_Combobox, TextField } from "../../index";
import { expect } from "@storybook/jest";
export default {
title: "ds-react/Combobox",
component: UNSAFE_Combobox,
argTypes: {
isListOpen: {
control: {
type: "boolean",
},
},
isLoading: {
control: {
type: "boolean",
},
},
},
} as Meta;
const options = [
"banana",
"apple",
"apple pie",
"tangerine",
"pear",
"grape",
"kiwi",
"mango",
"passion fruit",
"pineapple",
"strawberry",
"watermelon",
"grape fruit",
];
const initialSelectedOptions = ["passion fruit", "grape fruit"];
const DemoContainer = ({
dataTheme,
children,
}: {
children: any;
dataTheme: "dark" | "light";
}) => (
<div data-theme={dataTheme} style={{ width: "300px" }}>
{children}
</div>
);
export const Default = (props) => {
const id = useId();
return (
<DemoContainer dataTheme={props.darkMode}>
<UNSAFE_Combobox
options={props.options}
label="Hva er dine favorittfrukter?"
/* everything under here is optional? */
shouldAutocomplete={props.shouldAutoComplete}
size="medium"
id={id}
/>
</DemoContainer>
);
};
Default.args = {
options,
shouldAutoComplete: true,
};
export function MultiSelect(props) {
const id = useId();
return (
<DemoContainer dataTheme={props.darkMode}>
<UNSAFE_Combobox
id={id}
label="Komboboks - velg flere"
options={props.options}
isMultiSelect={props.isMultiSelect}
/>
</DemoContainer>
);
}
MultiSelect.args = {
options,
isMultiSelect: true,
};
export function MultiSelectWithAddNewOptions(props) {
const id = useId();
return (
<DemoContainer dataTheme={props.darkMode}>
<UNSAFE_Combobox
id={id}
isMultiSelect={props.isMultiSelect}
label="Komboboks (med mulighet for å legge til nye verdier)"
options={props.options}
allowNewValues={props.allowNewValues}
/>
</DemoContainer>
);
}
MultiSelectWithAddNewOptions.args = {
allowNewValues: true,
isMultiSelect: true,
options,
shouldAutocomplete: false,
};
export const MultiSelectWithExternalChips = (props) => {
const [selectedOptions, setSelectedOptions] = useState<string[]>(
props.selectedOptions
);
const [value, setValue] = useState("");
const id = useId();
const toggleSelected = (option) =>
selectedOptions.includes(option)
? setSelectedOptions(selectedOptions.filter((opt) => opt !== option))
: setSelectedOptions([...selectedOptions, option]);
return (
<DemoContainer dataTheme={props.darkMode}>
{selectedOptions && (
<Chips>
{selectedOptions.map((option) => (
<Chips.Removable
key={option}
onPointerUp={() => toggleSelected(option)}
onKeyUp={(e) => e.key === "Enter" && toggleSelected(option)}
>
{option}
</Chips.Removable>
))}
</Chips>
)}
<UNSAFE_Combobox
options={options}
selectedOptions={selectedOptions}
onToggleSelected={(option: string) => toggleSelected(option)}
isListOpen={props.isListOpen}
isMultiSelect
value={props.controlled ? value : undefined}
onChange={(event) =>
props.controlled ? setValue(event.currentTarget.value) : undefined
}
label="Komboboks"
size="medium"
error={props.error && "error here"}
id={id}
shouldShowSelectedOptions={false}
/>
</DemoContainer>
);
};
MultiSelectWithExternalChips.args = {
controlled: false,
options,
selectedOptions: [],
};
export function Loading(props) {
const id = useId();
return (
<DemoContainer dataTheme={props.darkMode}>
<UNSAFE_Combobox
id={id}
label="Komboboks (laster)"
options={[]}
selectedOptions={[]}
isListOpen={props.isListOpen}
isLoading={props.isLoading}
/>
</DemoContainer>
);
}
Loading.args = {
isLoading: true,
isListOpen: true,
};
export function ComboboxWithNoHits(props) {
const id = useId();
const [value, setValue] = useState(props.value);
return (
<DemoContainer dataTheme={props.darkMode}>
<UNSAFE_Combobox
id={id}
label="Komboboks (uten søketreff)"
options={props.options}
value={value}
onChange={(event) => setValue(event.currentTarget.value)}
isListOpen={true}
/>
</DemoContainer>
);
}
ComboboxWithNoHits.args = {
options,
value: "Orange",
};
export const Controlled = (props) => {
const id = useId();
const [value, setValue] = useState(props.value);
const [selectedOptions, setSelectedOptions] = useState(props.selectedOptions);
const filteredOptions = useMemo(
() => props.options.filter((option) => option.includes(value)),
[props.options, value]
);
const onToggleSelected = (option, isSelected) => {
if (isSelected) {
setSelectedOptions([...selectedOptions, option]);
} else {
setSelectedOptions(selectedOptions.filter((o) => o !== option));
}
};
return (
<DemoContainer dataTheme={props.darkMode}>
<TextField
label="Overstyr value"
onChange={(event) => setValue(event.target.value)}
value={value}
/>
<br />
<UNSAFE_Combobox
label="Hva er dine favorittfrukter?"
id={id}
filteredOptions={filteredOptions}
isMultiSelect
options={props.options}
onChange={(event) => setValue(event.target.value)}
onToggleSelected={onToggleSelected}
selectedOptions={selectedOptions}
value={value}
/>
</DemoContainer>
);
};
Controlled.args = {
value: "apple",
options,
selectedOptions: initialSelectedOptions,
};
export const ComboboxSizes = (props) => (
<DemoContainer dataTheme={props.darkMode}>
<UNSAFE_Combobox
label="Hva er dine favorittfrukter?"
description="Medium single-select"
options={options}
/>
<br />
<UNSAFE_Combobox
label="Hva er dine favorittfrukter?"
description="Small single-select"
options={options}
size="small"
/>
<br />
<UNSAFE_Combobox
label="Hva er dine favorittfrukter?"
description="Medium multiselect"
options={options}
isMultiSelect
allowNewValues
/>
<br />
<UNSAFE_Combobox
label="Hva er dine favorittfrukter?"
description="Small multiselect"
options={options}
isMultiSelect
size="small"
allowNewValues
/>
</DemoContainer>
);
ComboboxSizes.args = {
options,
};
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const CancelInputTest = {
render: (props) => {
return (
<DemoContainer dataTheme={props.darkMode}>
<UNSAFE_Combobox
options={options}
label="Hva er dine favorittfrukter?"
/>
</DemoContainer>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
userEvent.click(input);
await userEvent.type(input, "apple", { delay: 200 });
await sleep(1000);
userEvent.keyboard("{ArrowDown}");
await sleep(1000);
userEvent.keyboard("{Escape}");
await sleep(1000);
userEvent.keyboard("{ArrowDown}");
const banana = canvas.getByText("banana");
userEvent.click(banana);
},
};
export const RemoveSelectedMultiSelectTest = {
render: (props) => {
return (
<DemoContainer dataTheme={props.darkMode}>
<UNSAFE_Combobox
options={options}
label="Hva er dine favorittfrukter?"
isMultiSelect
/>
</DemoContainer>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
userEvent.click(input);
await userEvent.type(input, "apple", { delay: 200 });
await sleep(250);
userEvent.keyboard("{ArrowDown}");
await sleep(250);
userEvent.keyboard("{Enter}");
await sleep(250);
userEvent.keyboard("{Escape}");
await sleep(250);
userEvent.click(input);
await userEvent.type(input, "banana", { delay: 200 });
await sleep(250);
userEvent.keyboard("{ArrowDown}");
await sleep(250);
userEvent.keyboard("{Enter}");
await sleep(250);
userEvent.keyboard("{Escape}");
await sleep(250);
const appleSlett = canvas.getByLabelText("apple slett");
userEvent.click(appleSlett);
await sleep(250);
const bananaSlett = canvas.getByLabelText("banana slett");
expect(bananaSlett).toBeInTheDocument();
const appleSlettAgain = canvas.queryByLabelText("apple slett");
expect(appleSlettAgain).not.toBeInTheDocument();
},
};
export const AddWhenAddNewDisabledTest = {
render: (props) => {
return (
<DemoContainer dataTheme={props.darkMode}>
<UNSAFE_Combobox
options={options}
label="Hva er dine favorittfrukter?"
isMultiSelect
/>
</DemoContainer>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
userEvent.click(input);
await userEvent.type(input, "aaa", { delay: 200 });
await sleep(250);
userEvent.keyboard("{ArrowDown}");
await sleep(250);
userEvent.keyboard("{ArrowDown}");
await sleep(250);
userEvent.keyboard("{Enter}");
await sleep(250);
userEvent.keyboard("{Escape}");
await sleep(250);
const invalidSelect = canvas.queryByLabelText("aaa slett");
expect(invalidSelect).not.toBeInTheDocument();
},
};
@@ -0,0 +1,123 @@
/* eslint-disable react/jsx-pascal-case */
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React, { useId } from "react";
import { UNSAFE_Combobox } from "..";
import { act } from "react-dom/test-utils";
const options = [
"banana",
"apple",
"tangerine",
"pear",
"grape",
"kiwi",
"mango",
"passion fruit",
"pineapple",
"strawberry",
"watermelon",
"grape fruit",
];
const App = (props) => {
const id = useId();
return (
<div data-theme="light">
<UNSAFE_Combobox
label="Hva er dine favorittfrukter?"
size="medium"
variant="simple"
id={id}
{...props}
/>
</div>
);
};
describe("Render combobox", () => {
describe("with multi select", () => {
it("Should be able to search, select and remove selections", async () => {
const utils = render(<App isMultiSelect options={options} />);
await act(async () => {
await userEvent.click(
utils.getByRole("combobox", { name: "Hva er dine favorittfrukter?" })
);
});
await act(async () => {
await userEvent.type(
utils.getByRole("combobox", { name: "Hva er dine favorittfrukter?" }),
"apple"
);
});
await act(async () => {
await userEvent.click(
await utils.findByRole("option", { name: "apple" })
);
});
expect(
await utils.findByRole("option", { name: "apple", selected: true })
).toBeInTheDocument();
await act(async () => {
await userEvent.click(
await utils.findByRole("button", { name: "apple slett" })
);
});
});
});
it("Should show loading icon when loading (used for async search)", async () => {
const utils = render(<App options={[]} isListOpen isLoading />);
expect(await utils.findByRole("option", { name: "venter..." }));
});
});
describe("Combobox state-handling", () => {
it("Should not select previous focused element when closes", async () => {
const utils = render(<App options={options} />);
await act(async () => {
await userEvent.click(
utils.getByRole("combobox", { name: "Hva er dine favorittfrukter?" })
);
});
await act(async () => {
await userEvent.type(
utils.getByRole("combobox", { name: "Hva er dine favorittfrukter?" }),
"ban"
);
await userEvent.keyboard("{ArrowDown}");
await userEvent.keyboard("{ArrowUp}");
await userEvent.keyboard("{Enter}");
});
expect(
await utils.queryByRole("button", { name: "banana slett" })
).toBeNull();
});
it("Should reset list when resetting input (ESC)", async () => {
const utils = render(<App options={options} />);
await act(async () => {
await userEvent.click(
utils.getByRole("combobox", { name: "Hva er dine favorittfrukter?" })
);
});
await act(async () => {
await userEvent.type(
utils.getByRole("combobox", { name: "Hva er dine favorittfrukter?" }),
"apple"
);
await userEvent.keyboard("{ArrowDown}");
await userEvent.keyboard("{Escape}");
await userEvent.keyboard("{ArrowDown}");
});
expect(
await utils.findByRole("option", { name: "banana" })
).toBeInTheDocument();
});
});
@@ -0,0 +1,57 @@
import React, { useState, useCallback, createContext, useContext } from "react";
import { useInputContext } from "./Input/inputContext";
type CustomOptionsContextType = {
customOptions: string[];
removeCustomOption: (option: string) => void;
addCustomOption: (option: string) => void;
};
const CustomOptionsContext = createContext<CustomOptionsContextType>(
{} as CustomOptionsContextType
);
export const CustomOptionsProvider = ({ children }) => {
const [customOptions, setCustomOptions] = useState<string[]>([]);
const { focusInput } = useInputContext();
const removeCustomOption = useCallback(
(option) => {
setCustomOptions((prevCustomOptions) =>
prevCustomOptions.filter((o) => o !== option)
);
focusInput();
},
[focusInput, setCustomOptions]
);
const addCustomOption = useCallback(
(option) => {
setCustomOptions((prevOptions) => [...prevOptions, option]);
focusInput();
},
[focusInput, setCustomOptions]
);
const customOptionsState = {
customOptions,
removeCustomOption,
addCustomOption,
};
return (
<CustomOptionsContext.Provider value={customOptionsState}>
{children}
</CustomOptionsContext.Provider>
);
};
export const useCustomOptionsContext = () => {
const context = useContext(CustomOptionsContext);
if (!context) {
throw new Error(
"useCustomOptionsContext must be used within a CustomOptionsProvider"
);
}
return context;
};
@@ -0,0 +1,2 @@
export { default as Combobox } from "./ComboboxProvider";
export { type ComboboxProps } from "./types";
@@ -0,0 +1,122 @@
import React, { ChangeEvent, InputHTMLAttributes } from "react";
import { FormFieldProps } from "../useFormField";
export interface ComboboxProps
extends FormFieldProps,
Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "onChange" | "value"> {
/**
* Combobox label
*/
label: React.ReactNode;
/**
* List of options to use for autocompletion
*/
options: string[];
/**
* If enabled, adds an option to add the value of the input as an option whenever there are no options matching the value.
*/
allowNewValues?: boolean;
/**
* If "true" adds a button to clear the value in the input field
*/
clearButton?: boolean;
/**
* Custom name for the clear button. Requires "clearButton" to be "true".
*
* @default "Tøm"
*/
clearButtonLabel?: string;
/**
* A list of options to display in the dropdown list.
* 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[];
/**
* Optionally hide the label visually.
* Not recommended, but can be considered for e.g. search fields in the top menu.
*/
hideLabel?: boolean;
/**
* Custom class name for the input field.
*
* If used for styling, please consider using tokens instead.
*/
inputClassName?: string | undefined;
/**
* Controlled open/closed state for the dropdown list
*/
isListOpen?: boolean;
/**
* Set to "true" when doing an async search and waiting for new filteredOptions.
*
* Will show a spinner in the dropdown and announce to screen readers that it is loading.
*/
isLoading?: boolean;
/**
* Set to "true" to allow multiple selections
*
* This will display selected values as a list of Chips in front of the input field, instead of a selection replacing the value of the input.
*
*/
isMultiSelect?: boolean;
/**
* Callback function triggered whenever the value of the input field is triggered.
*
* @param event
* @returns
*/
onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
/**
* 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
* @param isSelected
* @returns
*/
onToggleSelected?: (option: string, isSelected: boolean) => void;
/**
* List of selected options.
*
* 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[];
/**
* Set to "true" to enable inline autocomplete.
*
* @default false
*/
shouldAutocomplete?: boolean;
/**
* When set to "true" displays selected options as Chips before the input field
*
* @default true
*/
shouldShowSelectedOptions?: boolean;
/**
* When set to "true" displays the toggle button for opening/closing the dropdown list
*
* @default true
*/
toggleListButton?: boolean;
/**
* Custom name for the toggle list-button. Requires "toggleListButton" to be "true".
*
* @default "Alternativer"
*/
toggleListButtonLabel?: string;
/**
* Set this to override the value of the input field.
*
* This converts the input to a controlled input, so you have to use onChange to update the value.
*/
value?: string;
}
+1
View File
@@ -17,6 +17,7 @@ export {
type RadioProps,
} from "./radio";
export { Search, type SearchClearEvent, type SearchProps } from "./search";
export { Combobox as UNSAFE_Combobox, type ComboboxProps } from "./combobox";
export { default as Select, type SelectProps } from "./Select";
export { default as Switch, type SwitchProps } from "./Switch";
export { Counter, default as Textarea, type TextareaProps } from "./Textarea";
+4 -3
View File
@@ -6,7 +6,7 @@ import { FormFieldProps } from "../useFormField";
import { useRadio } from "./useRadio";
export interface RadioProps
extends Omit<FormFieldProps, "error" | "errorId">,
extends Omit<FormFieldProps, "error" | "errorId" | "readOnly">,
Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "value"> {
/**
* Radio label
@@ -23,17 +23,18 @@ export interface RadioProps
}
export const Radio = forwardRef<HTMLInputElement, RadioProps>((props, ref) => {
const { inputProps, size, hasError } = useRadio(props);
const { inputProps, size, hasError, readOnly } = useRadio(props);
return (
<div
className={cl(props.className, "navds-radio", `navds-radio--${size}`, {
"navds-radio--error": hasError,
"navds-radio--disabled": inputProps.disabled,
"navds-radio--readonly": readOnly,
})}
>
<input
{...omit(props, ["children", "size", "description"])}
{...omit(props, ["children", "size", "description", "readOnly"])}
{...inputProps}
className="navds-radio__input"
ref={ref}
@@ -65,6 +65,7 @@ export const RadioGroup = forwardRef<HTMLFieldSetElement, RadioGroupProps>(
value,
onChange = () => {},
required,
readOnly,
...rest
},
ref
@@ -76,12 +77,14 @@ export const RadioGroup = forwardRef<HTMLFieldSetElement, RadioGroupProps>(
return (
<Fieldset
{...rest}
readOnly={readOnly}
ref={ref}
className={cl(
className,
"navds-radio-group",
`navds-radio-group--${rest.size ?? fieldset?.size ?? "medium"}`
)}
nativeReadOnly={false}
>
<RadioGroupContext.Provider
value={{
@@ -92,3 +92,30 @@ export const GroupDescription = () => (
<Radio value="tekst2">Radiotekst</Radio>
</RadioGroup>
);
export const UUDemo = () => (
<div className="colgap">
<RadioGroup
legend="Hvilken frukt vil du ha?"
description="Du kan bare velge en frukt"
defaultValue="eple"
readOnly
>
<Radio value="eple">Eple</Radio>
<Radio value="banan" description="Bananer er importert fra X">
Banan
</Radio>
<Radio value="druer">Druer</Radio>
</RadioGroup>
<RadioGroup
legend="Når har du ferie?"
defaultValue="1"
readOnly
error="du må velge en ferie"
>
<Radio value="1">August</Radio>
<Radio value="2">Juli</Radio>
<Radio value="3">Juni</Radio>
</RadioGroup>
</div>
);
+12 -1
View File
@@ -10,7 +10,7 @@ import { omit } from "../..";
export const useRadio = (props: RadioProps) => {
const radioGroup = useContext(RadioGroupContext);
const { inputProps, ...rest } = useFormField(
const { inputProps, readOnly, ...rest } = useFormField(
omit(props, ["description"]),
"radio"
);
@@ -25,6 +25,7 @@ export const useRadio = (props: RadioProps) => {
return {
...rest,
readOnly,
inputProps: {
...inputProps,
name: radioGroup?.name,
@@ -37,9 +38,19 @@ export const useRadio = (props: RadioProps) => {
? undefined
: radioGroup?.value === props.value,
onChange: (e) => {
if (readOnly) {
return;
}
props.onChange && props.onChange(e);
radioGroup?.onChange && radioGroup.onChange(props.value);
},
onClick: (e) => {
if (readOnly) {
e.preventDefault();
return;
}
props?.onClick?.(e);
},
required: radioGroup?.required,
type: "radio",
},
@@ -27,7 +27,7 @@ export type SearchClearEvent =
| { trigger: "Escape"; event: React.KeyboardEvent<HTMLDivElement> };
export interface SearchProps
extends FormFieldProps,
extends Omit<FormFieldProps, "readOnly">,
Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "onChange"> {
children?: React.ReactNode;
/**
@@ -221,7 +221,7 @@ export const Search = forwardRef<HTMLInputElement, SearchProps>(
)}
<input
ref={mergedRef}
{...omit(rest, ["error", "errorId", "size"])}
{...omit(rest, ["error", "errorId", "size", "readOnly"])}
{...inputProps}
value={value ?? internalValue}
onChange={(e) => handleChange(e.target.value)}
@@ -114,3 +114,20 @@ export const HideLabel = () => {
</Select>
);
};
export const Readonly = () => {
return (
<div className="colgap">
<Select
label="Hvilkets land er du fra?"
description="Velg landet du bor 180 dagen i året"
readOnly
>
{content}
</Select>
<Select label="Hvilkets land er du fra?" readOnly error="feilmelding">
{content}
</Select>
</div>
);
};
@@ -116,3 +116,17 @@ export const HideLabel = () => {
</div>
);
};
export const Readonly = () => {
return (
<div className="colgap">
<Switch description="Slår av alle notifikasjoner" readOnly>
Notifikasjoner
</Switch>
<Switch checked readOnly>
Notifikasjoner
</Switch>
</div>
);
};
@@ -83,3 +83,17 @@ export const Disabled = () => {
export const HideLabel = () => {
return <TextField label="Ipsum enim quis culpa" hideLabel />;
};
export const Readonly = () => {
return (
<div className="colgap">
<TextField
label="Bosted"
description="Skriv bosted i Norge"
readOnly
value="Oslo"
/>
<TextField label="Bosted" readOnly error="feilmelding" value="Oslo" />
</div>
);
};
@@ -120,3 +120,22 @@ export const MaxRows = () => {
export const Resize = () => {
return <Textarea resize label="Ipsum enim quis culpa" />;
};
export const Readonly = () => {
return (
<div className="colgap">
<Textarea
label="På hvilket grunnlag har du tatt denne vurderingen?"
description="Beskriv i korte punkter"
value="Denne vurderingen ble gjort på grunnlag av X og Y"
readOnly
/>
<Textarea
label="På hvilket grunnlag har du tatt denne vurderingen?"
readOnly
value="Denne vurderingen ble gjort på grunnlag av X og Y"
error="feilmelding"
/>
</div>
);
};
+44 -4
View File
@@ -29,12 +29,34 @@ export interface FormFieldProps {
* Override internal id
*/
id?: string;
/**
* Read only-state
*/
readOnly?: boolean;
}
export interface FormFieldType {
showErrorMsg: boolean;
hasError: boolean;
errorId: string;
inputDescriptionId: string;
size: "small" | "medium";
inputProps: {
id: string;
"aria-invalid"?: boolean;
"aria-describedby"?: string;
disabled?: boolean;
};
readOnly?: boolean;
}
/**
* Handles props and their state for various form-fields in context with Fieldset
*/
export const useFormField = (props: FormFieldProps, prefix: string) => {
export const useFormField = (
props: FormFieldProps,
prefix: string
): FormFieldType => {
const { size, error, errorId: propErrorId } = props;
const fieldset = useContext(FieldsetContext);
@@ -46,8 +68,24 @@ export const useFormField = (props: FormFieldProps, prefix: string) => {
const inputDescriptionId = `${prefix}-description-${genId}`;
const disabled = fieldset?.disabled || props.disabled;
const hasError: boolean = !disabled && !!(error || fieldset?.error);
const showErrorMsg = !disabled && !!error && typeof error !== "boolean";
const readOnly =
((fieldset?.readOnly || props.readOnly) && !disabled) || undefined;
const hasError: boolean =
!disabled && !readOnly && !!(error || fieldset?.error);
const showErrorMsg =
!disabled && !readOnly && !!error && typeof error !== "boolean";
const ariaInvalid = { ...(hasError ? { "aria-invalid": true } : {}) };
if ((props as any)?.required && process.env.NODE_ENV !== "production") {
console.warn(
"Aksel: Use of 'required' in form-elements is heavily discuouraged. Docs about why here:"
);
console.warn(
"https://aksel.nav.no/god-praksis/artikler/obligatoriske-og-valgfrie-skjemafelter#h3bfe00453471"
);
}
return {
showErrorMsg,
@@ -55,9 +93,10 @@ export const useFormField = (props: FormFieldProps, prefix: string) => {
errorId,
inputDescriptionId,
size: size ?? fieldset?.size ?? "medium",
readOnly,
inputProps: {
id,
"aria-invalid": hasError,
...ariaInvalid,
"aria-describedby":
cl(props["aria-describedby"], {
[inputDescriptionId]:
@@ -65,6 +104,7 @@ export const useFormField = (props: FormFieldProps, prefix: string) => {
[errorId]: showErrorMsg,
[fieldset?.errorId ?? ""]: hasError && !!fieldset?.error,
}) || undefined,
disabled,
},
};
+1 -1
View File
@@ -5,7 +5,7 @@ import { useId } from "..";
export interface LoaderProps extends SVGProps<SVGSVGElement> {
/**
* Changes Loader width/height
* 64px | 40px | 32px | 24px | 20px | 16px
* 88px | 64px | 40px | 32px | 24px | 20px | 16px
* @default "medium"
*/
size?:
+9 -1
View File
@@ -114,7 +114,15 @@ export const Stepper: StepperComponent = forwardRef<
{React.Children.map(children, (step, index) => {
return (
<li
className={cl("navds-stepper__item")}
className={cl("navds-stepper__item", {
"navds-stepper__item--behind": activeStep > index,
"navds-stepper__item--completed":
React.isValidElement<StepperStepProps>(step) &&
step?.props?.completed,
"navds-stepper__item--non-interactive":
React.isValidElement<StepperStepProps>(step) &&
!(step?.props?.interactive ?? interactive),
})}
key={index + (children?.toString?.() ?? "")}
>
<span className="navds-stepper__line navds-stepper__line--1" />
@@ -11,8 +11,8 @@ export default {
orientation: {
control: {
type: "radio",
options: ["horizontal", "vertical"],
},
options: ["horizontal", "vertical"],
},
activeStep: {
control: {
+23 -13
View File
@@ -19,13 +19,14 @@ import React from "react";
import { useTimelineContext } from "./hooks/useTimelineContext";
import { isVisible } from "./utils";
import { horizontalPositionAndWidth } from "./utils/calc";
import { AxisLabel } from "./utils/types.external";
import { AxisLabel, AxisLabelTemplates } from "./utils/types.external";
export const dayLabels = (
start: Date,
end: Date,
totalDays: number,
direction: "left" | "right"
direction: "left" | "right",
template: string = "dd.MM"
): AxisLabel[] => {
const increment = Math.ceil(totalDays / 10);
const lastDay = startOfDay(end);
@@ -43,7 +44,7 @@ export const dayLabels = (
return {
direction: direction,
horizontalPosition: horizontalPosition,
label: format(day, "dd.MM", { locale: nbLocale }),
label: format(day, template, { locale: nbLocale }),
date: day,
width: width,
};
@@ -54,7 +55,8 @@ export const dayLabels = (
export const monthLabels = (
start: Date,
end: Date,
direction: "left" | "right"
direction: "left" | "right",
template: string = "MMM yy"
): AxisLabel[] => {
const startMonth = startOfMonth(start);
const endMonth = endOfMonth(end);
@@ -70,7 +72,7 @@ export const monthLabels = (
return {
direction: direction,
horizontalPosition: horizontalPosition,
label: format(month, "MMM yy", { locale: nbLocale }),
label: format(month, template, { locale: nbLocale }),
date: month,
width: width,
};
@@ -80,7 +82,8 @@ export const monthLabels = (
export const yearLabels = (
start: Date,
end: Date,
direction: "left" | "right"
direction: "left" | "right",
template: string = "yyyy"
): AxisLabel[] => {
const firstYear = startOfYear(start);
const lastYear = endOfYear(end);
@@ -96,7 +99,7 @@ export const yearLabels = (
return {
direction: direction,
horizontalPosition: horizontalPosition,
label: year.getFullYear().toString(),
label: format(year, template, { locale: nbLocale }),
date: year,
width: width,
};
@@ -106,21 +109,28 @@ export const yearLabels = (
const axisLabels = (
start: Date,
end: Date,
direction: "left" | "right"
direction: "left" | "right",
templates?: AxisLabelTemplates
): AxisLabel[] => {
const totalDays = differenceInDays(end, start);
if (totalDays < 40) {
return dayLabels(start, end, totalDays, direction);
return dayLabels(start, end, totalDays, direction, templates?.day);
} else if (totalDays < 370) {
return monthLabels(start, end, direction);
return monthLabels(start, end, direction, templates?.month);
} else {
return yearLabels(start, end, direction);
return yearLabels(start, end, direction, templates?.year);
}
};
export const AxisLabels = () => {
export const AxisLabels = ({
templates,
}: {
templates?: AxisLabelTemplates;
}) => {
const { endDate, startDate, direction } = useTimelineContext();
const labels = axisLabels(startDate, endDate, direction).filter(isVisible);
const labels = axisLabels(startDate, endDate, direction, templates).filter(
isVisible
);
return (
<div className="navds-timeline__axislabels" aria-hidden="true">
+18 -2
View File
@@ -13,6 +13,7 @@ import Pin, { PinType } from "./Pin";
import TimelineRow, { TimelineRowType } from "./TimelineRow";
import { parseRows } from "./utils/timeline";
import Zoom, { ZoomType } from "./zoom";
import { AxisLabelTemplates } from "./utils/types.external";
export interface TimelineProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
@@ -33,6 +34,11 @@ export interface TimelineProps extends React.HTMLAttributes<HTMLDivElement> {
* @default "left"
*/
direction?: "left" | "right";
/**
* Templates for label texts. The templates are passed to the date-fns `format` function.
* Defaults to { day: "dd.MM", month: "MMM yy", year: "yyyy" }.
*/
axisLabelTemplates?: AxisLabelTemplates;
}
interface TimelineComponent
@@ -74,7 +80,17 @@ interface TimelineComponent
* ```
*/
export const Timeline = forwardRef<HTMLDivElement, TimelineProps>(
({ children, startDate, endDate, direction = "left", ...rest }, ref) => {
(
{
children,
startDate,
endDate,
direction = "left",
axisLabelTemplates,
...rest
},
ref
) => {
const isMultipleRows = Array.isArray(children);
const firstFocusabled = useRef<
@@ -194,7 +210,7 @@ export const Timeline = forwardRef<HTMLDivElement, TimelineProps>(
>
<div {...rest} ref={ref}>
<div className="navds-timeline">
<AxisLabels />
<AxisLabels templates={axisLabelTemplates} />
{pins.map((pin) => {
return pin;
@@ -65,3 +65,9 @@ export interface SimplePeriod {
*/
end: Date;
}
export type AxisLabelTemplates = {
day?: string;
month?: string;
year?: string;
};
@@ -0,0 +1,19 @@
import { useEffect, useRef } from "react";
/*
* usePrevious hook
* The ref object's "current" property is mutable and when changed wont re-render the component
* meaning it can be used to stay "one render behind" the current state
* https://usehooks.com/usePrevious/
* https://blog.logrocket.com/accessing-previous-props-state-react-hooks/
*/
const usePrevious = <T>(value: T): T | undefined => {
const ref = useRef<T>(value);
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
};
export default usePrevious;
+14
View File
@@ -1,5 +1,19 @@
# @navikt/ds-tailwind
## 4.7.3
## 4.7.2
## 4.7.1
## 4.7.0
## 4.6.1
## 4.6.0
## 4.5.0
## 4.4.2
## 4.4.1
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tailwind",
"version": "4.4.2",
"version": "4.7.3",
"description": "Tailwind config based on @navikt/ds-tokens",
"author": "Aksel | NAV designsystem team",
"keywords": [
@@ -22,7 +22,7 @@
"directory": "@navikt/core/tailwind"
},
"devDependencies": {
"@navikt/ds-tokens": "^4.4.2",
"@navikt/ds-tokens": "^4.7.3",
"@types/jest": "^29.0.0",
"color": "4.2.3",
"jest": "^29.0.0",
+14
View File
@@ -1,5 +1,19 @@
# @navikt/ds-tokens
## 4.7.3
## 4.7.2
## 4.7.1
## 4.7.0
## 4.6.1
## 4.6.0
## 4.5.0
## 4.4.2
## 4.4.1
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tokens",
"version": "4.4.2",
"version": "4.7.3",
"description": "Design-tokens for NAV designsystem",
"author": "Aksel | NAV designsystem team",
"keywords": [
+170 -98
View File
@@ -1,5 +1,77 @@
# Changelog
## 4.7.3
### @navikt/ds-react
- Chips: Removable Chips submitter ikke forms ved klikk lengre ([#2124](https://github.com/navikt/aksel/pull/2124))
## 4.7.2
### @navikt/ds-css
- Stepper: Fikset `hotizontal`-bug når step var `completed` ([#2116](https://github.com/navikt/aksel/pull/2116))
### @navikt/ds-react
- Stepper: Fikset `hotizontal`-bug når step var `completed` ([#2116](https://github.com/navikt/aksel/pull/2116))
## 4.7.1
### @navikt/ds-react
- Datepicker: Fikset bug ved bruk dynamisk oppdatering av minDate. Vist `month` vil nå alltid være oppdatert når datepicker åpnes ([#2117](https://github.com/navikt/aksel/pull/2117))
## 4.7.0
### @navikt/ds-css
- Ny komponent Combobox! ([#1868](https://github.com/navikt/aksel/pull/1868))
### @navikt/ds-react
- Ny komponent Combobox! ([#1868](https://github.com/navikt/aksel/pull/1868))
<!---->
- Timeline: Har nå egen `axisLabelTemplates`-prop for axixlabel formatering ([#2109](https://github.com/navikt/aksel/pull/2109))
- Combobox post-release tweaks ([#2112](https://github.com/navikt/aksel/pull/2112))
## 4.6.1
### @navikt/ds-css
- Skjema: Labels og Legends bruker nå inline-flex når readOnly er satt ([#2089](https://github.com/navikt/aksel/pull/2089))
### @navikt/ds-react
- Skjema: Labels og Legends bruker nå inline-flex når readOnly er satt ([#2089](https://github.com/navikt/aksel/pull/2089))
## 4.6.0
### @navikt/ds-css
- Skjema: De fleste skjemakomponenter har nå styling for `readOnly`-state ([#2080](https://github.com/navikt/aksel/pull/2080))
### @navikt/ds-react
- Skjema: De fleste skjemakomponenter støtter nå `readOnly`-state ([#2080](https://github.com/navikt/aksel/pull/2080))
## 4.5.0
### @navikt/ds-css
- - Alert: La til `closeButton`-prop ([#2079](https://github.com/navikt/aksel/pull/2079))
<!---->
- - Button: Fikset outline-bug i tertiary-variant ved `:active`-state ([#2079](https://github.com/navikt/aksel/pull/2079))
### @navikt/ds-react
- - Alert: La til `closeButton`-prop ([#2079](https://github.com/navikt/aksel/pull/2079))
## 4.4.2
### @navikt/ds-react
@@ -14,16 +86,6 @@
## 4.4.0
### @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-react
- Fikset klassenavn brukt for popover i Datepicker og Monthpicker ([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))
@@ -32,11 +94,17 @@
- 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))
## 4.3.0
### @navikt/ds-react
- Popover og Helptext har nå luft mot siden av skjerm på mindre flater ([PR](https://github.com/navikt/aksel/pull/2069))
- 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))
## 4.3.0
### @navikt/ds-css
@@ -46,16 +114,20 @@
- ExpansionCard: Ved nesting av komponetene fikk man styling fra parent ([PR](https://github.com/navikt/aksel/pull/2067))
## 4.2.0
### @navikt/ds-react
- Oppdatert Chat: `size` og `variant`-prop, optional `avatar`, uu og ui-forbedringer ([PR](https://github.com/navikt/aksel/pull/2048))
- Popover og Helptext har nå luft mot siden av skjerm på mindre flater ([PR](https://github.com/navikt/aksel/pull/2069))
## 4.2.0
### @navikt/ds-css
- Chat: `small`-size, innebygde varianter for farge og oppdatert utseende. ([PR](https://github.com/navikt/aksel/pull/2048))
### @navikt/ds-react
- Oppdatert Chat: `size` og `variant`-prop, optional `avatar`, uu og ui-forbedringer ([PR](https://github.com/navikt/aksel/pull/2048))
## 4.1.7
### @navikt/ds-react
@@ -108,21 +180,9 @@
## 4.1.0
### @navikt/ds-tailwind
### @navikt/aksel-stylelint
- :tada: Fargetokens for datavisualisering. ([PR](https://github.com/navikt/aksel/pull/2032))
### @navikt/ds-react
- Chips: `neutral` og `action`-varianter for Chips.Toggle. `checkmark`-prop for Chips.Toggle ([PR](https://github.com/navikt/aksel/pull/2035))
- Ny komponent Skeleton! ([PR](https://github.com/navikt/aksel/pull/1821))
<!---->
- La til JSDoc dokumentasjon for alle komponenter ([PR](https://github.com/navikt/aksel/pull/2034))
- Accordion: La til `indent`-prop ([PR](https://github.com/navikt/aksel/pull/2027))
- Deprecated klassenavn `navds-chips--icon-left` ([PR](https://github.com/navikt/aksel/pull/2035))
### @navikt/ds-tokens
@@ -136,17 +196,29 @@
- Alle description-felter på fieldsets har nå `text-subtle` som farge. ([PR](https://github.com/navikt/aksel/pull/2036))
### @navikt/aksel-stylelint
### @navikt/ds-react
- Deprecated klassenavn `navds-chips--icon-left` ([PR](https://github.com/navikt/aksel/pull/2035))
- Chips: `neutral` og `action`-varianter for Chips.Toggle. `checkmark`-prop for Chips.Toggle ([PR](https://github.com/navikt/aksel/pull/2035))
- Ny komponent Skeleton! ([PR](https://github.com/navikt/aksel/pull/1821))
<!---->
- La til JSDoc dokumentasjon for alle komponenter ([PR](https://github.com/navikt/aksel/pull/2034))
- Accordion: La til `indent`-prop ([PR](https://github.com/navikt/aksel/pull/2027))
### @navikt/ds-tailwind
- :tada: Fargetokens for datavisualisering. ([PR](https://github.com/navikt/aksel/pull/2032))
## 4.0.0
### @navikt/ds-react
### @navikt/aksel
- Datepicker og Monthpicker er ute av beta. [Guide](https://aksel.nav.no/grunnleggende/kode/migrering#h4ef68ae743b0) ([PR](https://github.com/navikt/aksel/pull/2026))
- Codemod for migrering av Datepicker/Monthpicker ut av Beta. `npx @navikt/aksel codemod v4-date` ([PR](https://github.com/navikt/aksel/pull/2026))
- 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))
- 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
@@ -158,11 +230,11 @@
- Styling for flyttede komponenter finnes nå på CDN [Guide](https://aksel.nav.no/grunnleggende/kode/css-import)
### @navikt/aksel
### @navikt/ds-react
- Codemod for migrering av Datepicker/Monthpicker ut av Beta. `npx @navikt/aksel codemod v4-date` ([PR](https://github.com/navikt/aksel/pull/2026))
- Datepicker og Monthpicker er ute av beta. [Guide](https://aksel.nav.no/grunnleggende/kode/migrering#h4ef68ae743b0) ([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))
- 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))
## 3.4.2
@@ -178,17 +250,17 @@
## 3.4.0
### @navikt/ds-react
### @navikt/aksel-stylelint
- Tag: `moderate`-variant ([PR](https://github.com/navikt/aksel/pull/2010))
- 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))
### @navikt/aksel-stylelint
### @navikt/ds-react
- Stylelint-pakke for Aksel ([PR](https://github.com/navikt/aksel/pull/1973))
- Tag: `moderate`-variant ([PR](https://github.com/navikt/aksel/pull/2010))
## 3.3.1
@@ -210,18 +282,18 @@
## 3.2.3
### @navikt/ds-react
- Datepicker: Oppdatert small-variant av Datepicker.Input. UI-oppdatert samtidig. ([PR](https://github.com/navikt/aksel/pull/1993))
- CopyButton: `xsmall`-variant for bruk i tabeller ([PR](https://github.com/navikt/aksel/pull/1994))
### @navikt/ds-css
- Datepicker: Fikset small-variant av datepicker-input. ([PR](https://github.com/navikt/aksel/pull/1993))
- CopyButton: `xsmall`-size for bruk i tabeller ([PR](https://github.com/navikt/aksel/pull/1994))
### @navikt/ds-react
- Datepicker: Oppdatert small-variant av Datepicker.Input. UI-oppdatert samtidig. ([PR](https://github.com/navikt/aksel/pull/1993))
- CopyButton: `xsmall`-variant for bruk i tabeller ([PR](https://github.com/navikt/aksel/pull/1994))
### @navikt/aksel-icons
- Nye ikoner `FileParagraph` og `FilePlus` ([PR](https://github.com/navikt/aksel/pull/1998))
@@ -246,10 +318,6 @@
## 3.1.0
### @navikt/ds-react
- Oppdatert Label og Description spacing for alle skjemakomponenter ([PR](https://github.com/navikt/aksel/pull/1967))
### @navikt/ds-css
- Alle komponenter bruker nå default `:focus-visible` for fokusmarkering. Medfølger også fallback for `:focus` ([PR](https://github.com/navikt/aksel/pull/1966))
@@ -258,6 +326,10 @@
- Oppdatert Label og Description spacing for skjemakomponenter ([PR](https://github.com/navikt/aksel/pull/1967))
### @navikt/ds-react
- Oppdatert Label og Description spacing for alle skjemakomponenter ([PR](https://github.com/navikt/aksel/pull/1967))
## 3.0.1
### @navikt/ds-react
@@ -268,16 +340,16 @@
## 3.0.0
### @navikt/ds-css
- Accordion: Chevron er left-aligned, deler av styling er refaktorert, `size`-props og `neutral`-variant ([PR](https://github.com/navikt/aksel/pull/1964))
### @navikt/ds-react
- Accordion: left-aligner chevron, `neutral`-variant, `size`-prop ([PR](https://github.com/navikt/aksel/pull/1964))
- Bruker nå nå `@navikt/aksel-icons` for interne ikoner ([PR](https://github.com/navikt/aksel/pull/1964))
### @navikt/ds-css
- Accordion: Chevron er left-aligned, deler av styling er refaktorert, `size`-props og `neutral`-variant ([PR](https://github.com/navikt/aksel/pull/1964))
## 2.9.8
### @navikt/aksel-icons
@@ -372,14 +444,14 @@
## 2.8.7
### @navikt/ds-react
- Fikset typografi-bruk for `Radio` og `Checkbox`.
### @navikt/ds-css
- Button: token for tertiary
### @navikt/ds-react
- Fikset typografi-bruk for `Radio` og `Checkbox`.
## 2.8.6
### @navikt/ds-css
@@ -408,14 +480,14 @@
## 2.8.2
### @navikt/ds-react
- ExpansioCard: Oppdatert typografibruk ([PR](https://github.com/navikt/aksel/pull/1870))
### @navikt/ds-css
- ExpansioCard: Oppdatert typografi ([PR](https://github.com/navikt/aksel/pull/1870))
### @navikt/ds-react
- ExpansioCard: Oppdatert typografibruk ([PR](https://github.com/navikt/aksel/pull/1870))
## 2.8.1
### @navikt/ds-react
@@ -478,14 +550,14 @@
## 2.5.0
### @navikt/ds-tailwind
- Breakpoint-tokens er lagt til. Overskriver native tailwind-breakpoints ([PR](https://github.com/navikt/aksel/pull/1832))
### @navikt/ds-tokens
- Breakpoint-tokens lagt til ([PR](https://github.com/navikt/aksel/pull/1832))
### @navikt/ds-tailwind
- Breakpoint-tokens er lagt til. Overskriver native tailwind-breakpoints ([PR](https://github.com/navikt/aksel/pull/1832))
## 2.4.3
### @navikt/ds-css
@@ -494,14 +566,14 @@
## 2.4.2
### @navikt/ds-react
- List: la til støtte for nestede lister ([PR](https://github.com/navikt/aksel/pull/1823))
### @navikt/ds-css
- List: La til støtte for nesting ([PR](https://github.com/navikt/aksel/pull/1823))
### @navikt/ds-react
- List: la til støtte for nestede lister ([PR](https://github.com/navikt/aksel/pull/1823))
## 2.4.1
### @navikt/ds-react
@@ -532,12 +604,6 @@
## 2.2.0
### @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))
@@ -550,6 +616,12 @@
- 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))
## 2.1.7
### @navikt/ds-react
@@ -594,14 +666,14 @@
## 2.1.0
### @navikt/ds-react
- 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))
### @navikt/ds-react
- Datepicker/Monthpicker: år med 2 siffer i input fungerer nå
## 2.0.18
### @navikt/ds-react
@@ -664,14 +736,6 @@
## 2.0.0
### @navikt/ds-tailwind
- Alle token er oppdatert til nytt format. ([Guide](https://aksel.nav.no/grunnleggende/kode/migrering#h76f47744d112))
### @navikt/ds-react
- Fonter blir bruk i all typografi blir nå lastet fra CDN
### @navikt/ds-tokens
- Alle token er oppdatert til nytt format. ([Guide](https://aksel.nav.no/grunnleggende/kode/migrering#h76f47744d112))
@@ -682,6 +746,14 @@
- Tokens: Alle komponenter bruker nå semantiske tokens for som standard, med innebygd støtte for komponent-spesifikke tokens.
### @navikt/ds-react
- 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))
## 1.5.10
### @navikt/ds-react
@@ -726,16 +798,16 @@
## 1.5.0
### @navikt/ds-css
- Tag: `filled`-varianter ([PR](https://github.com/navikt/aksel/pull/1684))
### @navikt/ds-react
- Tag: `filles`-varianter ([PR](https://github.com/navikt/aksel/pull/1684))
- Ny komponent Chips! ([PR](https://github.com/navikt/aksel/pull/1668))
### @navikt/ds-css
- Tag: `filled`-varianter ([PR](https://github.com/navikt/aksel/pull/1684))
## 1.4.4
### @navikt/ds-react
@@ -762,6 +834,10 @@
## 1.3.39
### @navikt/ds-css
- Fikset bruk av `:watch` for diverse parsere (less, parcel)
### @navikt/ds-react
- useId-bug fikset ved bruk i både react 17 og 18
@@ -784,10 +860,6 @@
- Stepper: `interactive` og `completed`-props for wizard-løsninger
### @navikt/ds-css
- Fikset bruk av `:watch` for diverse parsere (less, parcel)
## 1.3.24
### @navikt/ds-css
+11 -4
View File
@@ -11,7 +11,7 @@ CMS og nettside for [aksel.nav.no](https://aksel.nav.no/)
### /website/sanity
Det publiserte studioet finner man på [aksel.nav.no/admin](https://aksel.nav.no/admin)(krever NAV SSO).
Det publiserte studioet finner man på [aksel.nav.no/admin](https://aksel.nav.no/admin)(krever NAV SSO for innlogging).
`production`-datasettet er privat, så man må ha riktige tilganger for å kunne lese/jobbe med dataen der.
@@ -19,17 +19,24 @@ Det publiserte studioet finner man på [aksel.nav.no/admin](https://aksel.nav.no
### Bidrag
Branch `main` blir pusher rett til prod -> aksel.nav.no. For å utvikle nye features brukes branch `next` som ved push publiseres til devmiljø https://aksel.ekstern.dev.nav.no/. Kan også publisere andre branches til dev ved bruk av workflow-dispatch.
Branch `main` blir pusher rett til prod -> aksel.nav.no. For å utvikle nye features kan branch `next` brukes, som ved push publiseres til devmiljø https://aksel.ekstern.dev.nav.no/. Kan også publisere andre branches til dev ved bruk av workflow-dispatch.
## Kode-eksempler
Alle eksemplene våre ligger under /website/pages/eksempler. For å se endringene live kan man starte opp storybook med `yarn storybook`. Du trenger da ikke tilgang til sanity for å se endringene.
Alle eksemplene våre ligger under `/website/pages/eksempler`. For å se endringene live kan man starte opp storybook med `yarn storybook:aksel`.
> Du trenger ikke secrets for å teste storybook
### Nettside (localhost:3000)
```bash
// dev
yarn install
yarn dev
// prod
yarn build:next
yarn serve:next
```
**Rettigheter**
@@ -58,4 +65,4 @@ Kjøres backups hver 3 dag til GCP-bucket under `designsystem-prod`
Spørsmål og kontakt kan rettes til team Aksel
Aksel har også en egen Slack-kanal #aksel-designsystemet
Aksel har også en egen Slack-kanal `#aksel-designsystemet`
+5 -5
View File
@@ -15,11 +15,11 @@
"website"
],
"dependencies": {
"@navikt/aksel-icons": "^4.4.2",
"@navikt/ds-css": "^4.4.2",
"@navikt/ds-react": "^4.4.2",
"@navikt/ds-tailwind": "^4.4.2",
"@navikt/ds-tokens": "^4.4.2"
"@navikt/aksel-icons": "^4.7.3",
"@navikt/ds-css": "^4.7.3",
"@navikt/ds-react": "^4.7.3",
"@navikt/ds-tailwind": "^4.7.3",
"@navikt/ds-tokens": "^4.7.3"
},
"devDependencies": {
"prettier-plugin-tailwindcss": "^0.2.3"
@@ -1,4 +1,3 @@
import { ExternalLinkIcon } from "@navikt/aksel-icons";
import { BodyLong, Detail, Heading, Ingress, Link } from "@navikt/ds-react";
import BlockContent from "@sanity/block-content-to-react";
import cl from "clsx";
@@ -178,25 +177,11 @@ const serializers = {
if (!href) {
return children;
}
if (href && href.startsWith("mailto:")) {
return (
<NextLink href={href} passHref legacyBehavior>
<Link
onClick={(e) =>
logNav(
"link",
window.location.pathname,
e.currentTarget.getAttribute("href")
)
}
className="inline"
>
{children}
</Link>
</NextLink>
);
}
return blank ? (
const externalLink =
href.startsWith("http") && !href.startsWith("https://aksel.nav.no/");
return blank || externalLink ? (
<Link
href={href}
target="_blank"
@@ -209,44 +194,46 @@ const serializers = {
)
}
>
{children} <ExternalLinkIcon title="åpner lenken i ny fane" />
{children}
</Link>
) : (
<NextLink href={href} passHref legacyBehavior>
<Link
onClick={(e) =>
logNav(
"link",
window.location.pathname,
e.currentTarget.getAttribute("href")
)
}
className="inline"
>
{children}
</Link>
</NextLink>
<Link
as={NextLink}
href={href}
onClick={(e) =>
logNav(
"link",
window.location.pathname,
e.currentTarget.getAttribute("href")
)
}
className="inline"
>
{children}
</Link>
);
},
internalLink: ({ mark, children }: { mark: any; children: any }) => {
const { slug = {} } = mark;
if (!slug || !slug.current) return children;
if (!slug || !slug.current) {
return children;
}
const href = `/${slug?.current}`;
return (
<NextLink href={href} passHref legacyBehavior>
<Link
onClick={(e) =>
logNav(
"link",
window.location.pathname,
e.currentTarget.getAttribute("href")
)
}
>
{children}
</Link>
</NextLink>
<Link
as={NextLink}
href={href}
onClick={(e) =>
logNav(
"link",
window.location.pathname,
e.currentTarget.getAttribute("href")
)
}
>
{children}
</Link>
);
},
},
@@ -1,25 +1,76 @@
import { definePreview } from "next-sanity/preview";
import { useLiveQuery } from "next-sanity/preview";
import { ComponentType } from "react";
import { clientConfig } from "@/sanity/config";
const usePreview = definePreview({
...clientConfig,
subscriptionThrottleMs: 200,
});
import { getClient } from "@/sanity/client.server";
import { LiveQueryProvider } from "next-sanity/preview";
import { useMemo } from "react";
import dynamic from "next/dynamic";
import { useCheckAuth } from "components/website-modules/utils/useCheckAuth";
const WithPreview = ({
export const PreviewBanner = dynamic(
() => import("components/website-modules/PreviewBanner"),
{
ssr: false,
}
);
function PreviewProvider({ children }: { children: React.ReactNode }) {
const client = useMemo(() => {
return getClient().withConfig({
useCdn: false,
ignoreBrowserTokenWarning: true,
perspective: "previewDrafts",
});
}, []);
return <LiveQueryProvider client={client}>{children}</LiveQueryProvider>;
}
function LiveQuery({
comp: Comp,
query,
params,
props,
validUser,
}: {
comp: ComponentType;
query: string;
props: any;
params?: any;
validUser: boolean;
}) {
const [data, loading] = useLiveQuery(props, query, params);
return (
<>
<PreviewBanner loading={loading} validUser={validUser} />
<Comp {...props} {...data} />
</>
);
}
const WithPreview = ({
comp,
query,
params,
props,
}: {
comp;
query: string;
props: any;
params?: any;
}) => {
const data = usePreview(null, query, params);
return <Comp {...props} {...data} />;
const validUser = useCheckAuth();
return (
<PreviewProvider>
<LiveQuery
props={props}
query={query}
params={params}
comp={comp}
validUser={validUser}
/>
</PreviewProvider>
);
};
export default WithPreview;
-1
View File
@@ -20,6 +20,5 @@ export { default as Video } from "./sanity-modules/VideoEmbed";
export * from "./website-modules/BreadCrumbs";
export { default as Feedback } from "./website-modules/feedback";
export * from "./website-modules/H2";
export { default as PreviewBanner } from "./website-modules/PreviewBanner";
export * from "./website-modules/TOC";
export * from "./website-modules/utils";
@@ -75,7 +75,7 @@ export const WithSidebar = ({
<>
<Header />
<div className="bg-bg-default">
<div className="mx-auto mb-24 mt-6 flex w-full max-w-screen-2xl gap-6">
<div className="mx-auto mb-24 flex w-full max-w-screen-2xl gap-6 pt-6">
<Sidebar kategori={pageType.type} links={sidebar} />
<div className="relative w-full">
<main
@@ -2,8 +2,8 @@ import { BodyShort, Heading, Link } from "@navikt/ds-react";
import { FigmaIcon, GithubIcon, SlackIcon } from "components/assets";
import Logo from "components/assets/Logo";
import { logNav } from "components/website-modules/utils/amplitude";
import FooterForm from "./FooterForm";
import dynamic from "next/dynamic";
/* import FooterForm from "./FooterForm"; */
export const EditButton = dynamic(
() => import("components/website-modules/EditButton"),
@@ -12,6 +12,7 @@ export const EditButton = dynamic(
}
);
/* FooterForm is disabled to better comply with policy */
const Footer = () => {
return (
<footer
@@ -20,14 +21,13 @@ const Footer = () => {
data-theme="dark"
className="toc-ignore text-text-on-inverted bg-deepblue-800 relative flex w-full justify-center"
>
<div className="relative z-10 mx-auto grid w-full max-w-screen-2xl gap-12 px-4 pb-16 pt-12 md:grid-cols-2 md:px-6 lg:grid-cols-4 xl:grid-cols-5 xl:gap-6">
<div className="relative z-10 mx-auto grid w-full max-w-screen-2xl gap-12 px-4 pb-16 pt-12 md:grid-cols-2 md:px-6 lg:grid-cols-4 xl:grid-cols-4 xl:gap-6">
<LogoBlock />
<Snarveier />
<SideLenker />
<Kontakt />
<FooterForm />
{/* <FooterForm /> */}
</div>
<EditButton />
</footer>
);
@@ -28,13 +28,13 @@ const NavItem = ({
);
}}
className={cl(
"hover:text-deepblue-800 focus-visible:shadow-focus text-medium relative flex overflow-hidden rounded-sm py-1 pl-4 pr-2 leading-snug before:rounded-full hover:before:transition-colors focus:outline-none",
"hover:text-deepblue-800 focus-visible:shadow-focus text-medium relative flex overflow-hidden py-1 pl-4 pr-2 leading-snug before:rounded-full hover:before:transition-colors focus:outline-none focus-visible:z-10 focus-visible:rounded-sm",
{
"before:border-l-border-action-selected before:absolute before:left-0 before:top-1/2 before:h-6 before:-translate-y-1/2 before:border-l-[4px]":
isActive,
"text-deepblue-800 font-semibold": isActive,
"text-text-subtle": !isActive,
"before:absolute before:left-0 before:h-full before:border-l before:border-l-gray-200 hover:before:top-1/2 hover:before:h-6 hover:before:-translate-y-1/2 hover:before:border-l-2 hover:before:border-l-gray-400":
"before:absolute before:left-0 before:top-0 before:h-full before:border-l before:border-l-gray-200 hover:before:top-1/2 hover:before:h-6 hover:before:min-h-[70%] hover:before:-translate-y-1/2 hover:before:border-l-2 hover:before:border-l-gray-400":
!isActive,
}
)}
@@ -87,7 +87,7 @@ const Dropdown = ({
>
<Detail
as="span"
className="group-focus-visible:shadow-focus group-hover:bg-surface-neutral-subtle group-active:bg-bg-surface-neutral-subtle-hover mt-6 flex w-full items-center justify-between rounded-sm pl-2 font-semibold transition first:mt-0"
className="group-focus-visible:shadow-focus group-hover:bg-surface-neutral-subtle group-active:bg-surface-neutral-subtle-hover mt-6 flex w-full items-center justify-between rounded-sm pl-2 font-semibold transition-colors first:mt-0"
>
{title}
<span className="flex h-6 w-6 items-center justify-center rounded">
@@ -1,15 +1,13 @@
/**
* https://github.com/navikt/detsombetyrnoe/blob/main/src/components/PreviewBanner.tsx#L17
*/
import { BodyShort, Button } from "@navikt/ds-react";
import { useCheckAuth } from "components/website-modules/utils/useCheckAuth";
import Link from "next/link";
import { useRouter } from "next/router";
function PreviewBanner() {
const { asPath } = useRouter();
const validUser = useCheckAuth();
function PreviewBanner({
loading = true,
validUser,
}: {
loading: boolean;
validUser: boolean;
}) {
if (!validUser) {
return (
<>
@@ -32,7 +30,7 @@ function PreviewBanner() {
<Button
variant="secondary"
as="a"
href={`/api/exit-preview?slug=${asPath}`}
href={`/api/exit-preview?slug=${window.location.pathname}`}
>
Exit preview
</Button>
@@ -52,10 +50,10 @@ function PreviewBanner() {
return (
<a
href={`/api/exit-preview?slug=${asPath}`}
href={`/api/exit-preview?slug=${window.location.pathname}`}
className="text-text-on-inverted focus-visible:shadow-focus-inverted fixed left-0 top-2 z-[9999] w-80 -translate-x-24 translate-y-6 -rotate-45 bg-gray-900 p-4 text-center font-semibold no-underline hover:bg-gray-700 focus:outline-none"
>
<div>EXIT PREVIEW</div>
{loading ? <span>LASTER PREVIEW...</span> : <span>EXIT PREVIEW</span>}
</a>
);
}
@@ -64,15 +64,6 @@ const Feedback = ({
[asPath, activeState, hasLoggedFeedback]
);
useEffect(() => {
const callLogFeedback = () => logFeedback(false);
window.addEventListener("beforeunload", callLogFeedback);
return () => {
window.removeEventListener("beforeunload", callLogFeedback);
};
}, [logFeedback]);
const handleSend = (e) => {
e.preventDefault();
@@ -155,6 +146,9 @@ const Feedback = ({
"mx-auto": center,
});
/* Hidden to better comply with policy */
return null;
return (
<div className={classes} id="feedback-block" data-hj-suppress>
<div
@@ -192,7 +192,6 @@ export const IconPage = ({ name }: { name: string }) => {
{sub.icons.map((i) => {
const T = Icons[`${i.id}Icon`];
if (T === undefined) {
console.log(i);
return null;
}
return (
@@ -1,7 +1,7 @@
import { SearchLogT } from "@/types";
import getConfig from "next/config";
import { Router } from "next/router";
import { useCallback, useEffect, useMemo } from "react";
import { useCallback, useEffect } from "react";
import { track, init } from "@amplitude/analytics-browser";
const { publicRuntimeConfig } = getConfig();
@@ -74,15 +74,6 @@ export function logAmplitudeEvent(eventName: string, data?: any): Promise<any> {
}
export const usePageView = (router: Router, pageProps: any) => {
const pageId = useMemo(
() => pageProps?.id || pageProps?.page?._id,
[pageProps]
);
const isForside = useMemo(
() => pageProps?.page?._type === "aksel_forside",
[pageProps]
);
const logView = useCallback(
(e, first = false) => {
const data = {};
@@ -107,62 +98,30 @@ export const usePageView = (router: Router, pageProps: any) => {
},
first === true
);
try {
if (isForside && isProduction() && !!pageId) {
fetch(`/api/log-page-view?id=${pageId}`);
}
} catch (error) {
isDevelopment && console.error(error);
}
},
[pageProps, pageId, isForside]
[pageProps]
);
const logScroll = useCallback(
(highestPercent: number) => {
highestPercent > 100 && (highestPercent = 100);
if (
document === undefined ||
window?.location?.pathname?.startsWith?.("/eksempler")
) {
return;
}
const logScroll = useCallback((highestPercent: number) => {
highestPercent > 100 && (highestPercent = 100);
if (
document === undefined ||
window?.location?.pathname?.startsWith?.("/eksempler")
) {
return;
}
if (isNaN(highestPercent)) {
return;
}
if (isNaN(highestPercent)) {
return;
}
logAmplitudeEvent(AmplitudeEvents.scroll, {
side: window.location.pathname,
prosent: highestPercent,
});
try {
if (isForside && isProduction() && !!pageId) {
fetch(`/api/log-scroll?id=${pageId}&length=${highestPercent}`);
}
} catch (error) {
isDevelopment && console.error(error);
}
},
[pageId, isForside]
);
const logTimeSpent = useCallback(
(timeSpent: number) => {
try {
if (isForside && timeSpent <= 420 && isProduction() && !!pageId) {
fetch(`/api/log-time?id=${pageId}&time=${timeSpent}`);
}
} catch (error) {
isDevelopment && console.error(error);
}
},
[pageId, isForside]
);
logAmplitudeEvent(AmplitudeEvents.scroll, {
side: window.location.pathname,
prosent: highestPercent,
});
}, []);
useEffect(() => {
const startTime = new Date().getTime();
let highestPercent = 0;
let timeoutId = null;
const footer = document.querySelector("#aksel-footer") as HTMLElement;
@@ -171,7 +130,7 @@ export const usePageView = (router: Router, pageProps: any) => {
function scrollListener() {
timeoutId = setTimeout(() => {
const currentPercent = Math.round(
(window.pageYOffset /
(window.scrollY /
(document.body.scrollHeight -
window.innerHeight -
(footer ? footer.offsetHeight : 0))) *
@@ -194,11 +153,6 @@ export const usePageView = (router: Router, pageProps: any) => {
router.events.off("routeChangeComplete", logView);
router.events.off("routeChangeStart", logScroll);
window.removeEventListener("scroll", scrollListener);
if (isForside) {
logTimeSpent(Math.round((new Date().getTime() - startTime) / 1000));
logScroll(highestPercent);
}
};
}, [router.events, logView, logScroll, logTimeSpent, isForside]);
}, [router.events, logView, logScroll]);
};
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff. Show more