Compare commits

...
Author SHA1 Message Date
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
113 changed files with 3755 additions and 1702 deletions

No files matched your search

+8
View File
@@ -1,5 +1,13 @@
# @navikt/aksel-icons
## 4.7.2
## 4.7.1
## 4.7.0
## 4.6.1
## 4.6.0
## 4.5.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-icons",
"version": "4.6.0",
"version": "4.7.2",
"author": "Aksel | NAV",
"homepage": "https://aksel.nav.no/ikoner",
"repository": {
+8
View File
@@ -1,5 +1,13 @@
# @navikt/aksel-stylelint
## 4.7.2
## 4.7.1
## 4.7.0
## 4.6.1
## 4.6.0
## 4.5.0
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-stylelint",
"version": "4.6.0",
"version": "4.7.2",
"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.6.0",
"@navikt/ds-tokens": "^4.6.0",
"@navikt/ds-css": "^4.7.2",
"@navikt/ds-tokens": "^4.7.2",
"@types/jest": "^29.0.0",
"concurrently": "7.2.1",
"copyfiles": "2.4.1",
+28
View File
@@ -1,5 +1,33 @@
# @navikt/aksel
## 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
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel",
"version": "4.6.0",
"version": "4.7.2",
"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.6.0",
"@navikt/ds-css": "4.7.2",
"axios": "1.3.6",
"chalk": "4.1.0",
"clipboardy": "^2.3.0",
+20
View File
@@ -1,5 +1,25 @@
# @navikt/ds-css
## 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
+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,
+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;
+7 -2
View File
@@ -37,8 +37,13 @@
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);
vertical-align: middle;
margin-bottom: 3px;
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";
+4
View File
@@ -254,6 +254,10 @@
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);
+4
View File
@@ -197,6 +197,10 @@
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);
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-css",
"version": "4.6.0",
"version": "4.7.2",
"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.6.0",
"@navikt/ds-tokens": "^4.7.2",
"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",
+42
View File
@@ -1,5 +1,47 @@
# @navikt/ds-react
## 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
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-react",
"version": "4.6.0",
"version": "4.7.2",
"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.6.0",
"@navikt/aksel-icons": "^4.7.2",
"@radix-ui/react-tabs": "1.0.0",
"@radix-ui/react-toggle-group": "1.0.0",
"clsx": "^1.2.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>
);
@@ -73,6 +73,7 @@ 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,
}
@@ -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]
);
@@ -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]
);
+1
View File
@@ -99,6 +99,7 @@ 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,
}
@@ -73,10 +73,12 @@ 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,
}
)}
+1
View File
@@ -117,6 +117,7 @@ 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,
@@ -91,7 +91,11 @@ 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} />
)}
@@ -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";
+19 -1
View File
@@ -35,10 +35,28 @@ export interface FormFieldProps {
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);
+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;
+8
View File
@@ -1,5 +1,13 @@
# @navikt/ds-tailwind
## 4.7.2
## 4.7.1
## 4.7.0
## 4.6.1
## 4.6.0
## 4.5.0
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tailwind",
"version": "4.6.0",
"version": "4.7.2",
"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.6.0",
"@navikt/ds-tokens": "^4.7.2",
"@types/jest": "^29.0.0",
"color": "4.2.3",
"jest": "^29.0.0",
+8
View File
@@ -1,5 +1,13 @@
# @navikt/ds-tokens
## 4.7.2
## 4.7.1
## 4.7.0
## 4.6.1
## 4.6.0
## 4.5.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tokens",
"version": "4.6.0",
"version": "4.7.2",
"description": "Design-tokens for NAV designsystem",
"author": "Aksel | NAV designsystem team",
"keywords": [
+145 -103
View File
@@ -1,20 +1,58 @@
# Changelog
## 4.6.0
## 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
- Skjema: De fleste skjemakomponenter støtter nå `readOnly`-state ([#2080](https://github.com/navikt/aksel/pull/2080))
- 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))
## 4.5.0
### @navikt/ds-react
- - Alert: La til `closeButton`-prop ([#2079](https://github.com/navikt/aksel/pull/2079))
- Skjema: De fleste skjemakomponenter støtter nå `readOnly`-state ([#2080](https://github.com/navikt/aksel/pull/2080))
## 4.5.0
### @navikt/ds-css
@@ -24,6 +62,10 @@
- - 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
@@ -38,16 +80,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))
@@ -56,11 +88,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
@@ -70,16 +108,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
@@ -132,21 +174,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
@@ -160,17 +190,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
@@ -182,11 +224,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
@@ -202,17 +244,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
@@ -234,18 +276,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))
@@ -270,10 +312,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))
@@ -282,6 +320,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
@@ -292,16 +334,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
@@ -396,14 +438,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
@@ -432,14 +474,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
@@ -502,14 +544,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
@@ -518,14 +560,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
@@ -556,12 +598,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))
@@ -574,6 +610,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
@@ -618,14 +660,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
@@ -688,14 +730,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))
@@ -706,6 +740,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
@@ -750,16 +792,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
@@ -786,6 +828,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
@@ -808,10 +854,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
+5 -5
View File
@@ -15,11 +15,11 @@
"website"
],
"dependencies": {
"@navikt/aksel-icons": "^4.6.0",
"@navikt/ds-css": "^4.6.0",
"@navikt/ds-react": "^4.6.0",
"@navikt/ds-tailwind": "^4.6.0",
"@navikt/ds-tokens": "^4.6.0"
"@navikt/aksel-icons": "^4.7.2",
"@navikt/ds-css": "^4.7.2",
"@navikt/ds-react": "^4.7.2",
"@navikt/ds-tailwind": "^4.7.2",
"@navikt/ds-tokens": "^4.7.2"
},
"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";
@@ -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>
);
@@ -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]);
};
@@ -1,15 +1,26 @@
import { clientConfig } from "@/sanity/config";
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";
export const useCheckAuth = () => {
const [user, setUser] = useState<boolean>(true);
const [user, setUser] = useState(true);
useEffect(() => {
const handleAuth = async () => {
const _checkAuth = (await import("@sanity/preview-kit"))._checkAuth;
_checkAuth(clientConfig.projectId, null).then(setUser);
const fetchUser = async () => {
const response = await fetch(
`https://${clientConfig.projectId}.api.sanity.io/v1/users/me`,
{
credentials: "include",
}
).then((x) => x.text());
try {
setUser(!!JSON.parse(response)?.id);
return;
} catch (e) {
setUser(false);
}
setUser(false);
};
handleAuth();
fetchUser();
}, []);
return user;
+1 -1
View File
@@ -14,7 +14,7 @@ const ContentSecurityPolicy = `
img-src 'self' cdn.sanity.io https://avatars.githubusercontent.com data: ${cdnUrl};
script-src 'self' ${cdnUrl} https://in2.taskanalytics.com/tm.js 'nonce-4e1aa203a32e' 'unsafe-eval';
style-src 'self' ${cdnUrl} 'unsafe-inline';
connect-src 'self' ${cdnUrl} https://raw.githubusercontent.com/navikt/ wss://hnbe3yhs.api.sanity.io cdn.sanity.io *.api.sanity.io https://amplitude.nav.no https://in2.taskanalytics.com/03346;
connect-src 'self' ${cdnUrl} https://raw.githubusercontent.com/navikt/ https://hnbe3yhs.apicdn.sanity.io wss://hnbe3yhs.api.sanity.io cdn.sanity.io *.api.sanity.io https://amplitude.nav.no https://in2.taskanalytics.com/03346;
frame-ancestors 'self' localhost:3000;
media-src 'self' ${cdnUrl} cdn.sanity.io;
frame-src 'self' https://web.microsoftstream.com localhost:3000 https://aksel.ekstern.dev.nav.no;
+8 -9
View File
@@ -30,17 +30,17 @@
"dependencies": {
"@amplitude/analytics-browser": "^2.0.0",
"@sanity/block-content-to-react": "^3.0.0",
"@sanity/client": "^6.1.3",
"@sanity/client": "^6.1.5",
"@sanity/code-input": "4.1.1",
"@sanity/color-input": "^3.1.0",
"@sanity/icons": "2.3.1",
"@sanity/icons": "2.4.1",
"@sanity/image-url": "1.0.2",
"@sanity/preview-kit": "^1.5.4",
"@sanity/preview-kit": "^2.3.0",
"@sanity/table": "1.0.1",
"@sanity/ui": "^1.3.0",
"@sanity/vision": "3.12.1",
"@sanity/ui": "^1.7.0",
"@sanity/vision": "3.14.1",
"@svgr/webpack": "^5.5.0",
"boring-avatars": "1.7.0",
"boring-avatars": "1.10.1",
"clsx": "^1.2.1",
"codesandbox": "^2.2.3",
"date-fns": "2.29.3",
@@ -48,7 +48,7 @@
"lodash": "^4.17.21",
"next": "^13.2.1",
"next-logger": "^3.0.2",
"next-sanity": "^4.3.3",
"next-sanity": "^5.1.0",
"pino": "^8.14.1",
"prism-react-renderer": "^1.3.0",
"prismjs": "^1.27.0",
@@ -56,8 +56,7 @@
"react-dom": "^18.0.0",
"react-modal": "3.15.1",
"react-responsive-masonry": "^2.1.7",
"recharts": "^2.2.0",
"sanity": "3.9.1",
"sanity": "3.14.1",
"sanity-plugin-asset-source-unsplash": "^1.1.0",
"sanity-plugin-media": "^2.0.5",
"sharp": "0.31.3",
-9
View File
@@ -3,7 +3,6 @@ import {
usePageView,
} from "components/website-modules/utils/amplitude";
import { useScrollToHashOnPageLoad } from "components/website-modules/utils/util";
import dynamic from "next/dynamic";
import Head from "next/head";
import { Router } from "next/router";
import { useEffect } from "react";
@@ -12,13 +11,6 @@ import "../styles/index.css";
initAmplitude();
export const PreviewBanner = dynamic(
() => import("components/website-modules/PreviewBanner"),
{
ssr: false,
}
);
function App({
Component,
pageProps,
@@ -56,7 +48,6 @@ function App({
/>
<meta property="og:site_name" content="Aksel" key="ogsitename" />
</Head>
{pageProps?.preview && <PreviewBanner />}
<IdContext.Provider value={{ id: pageProps?.id ?? pageProps?.page?._id }}>
<Component {...pageProps} />
-6
View File
@@ -7,12 +7,6 @@ export default function Document() {
return (
<Html lang="no">
<Head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin=""
/>
<meta name="theme-color" content="#00243A" />
<link
rel="preload"
@@ -26,6 +26,8 @@ export default async function handler(
switch (req.method) {
case "POST":
try {
res.status(405).json({ msg: "Feedback is temporarily disabled" });
return;
const token = process.env.SANITY_WRITE_KEY;
if (!token && isDevelopment) {
@@ -1,107 +0,0 @@
import { noCdnClient } from "@/sanity/client.server";
import { format, isSameWeek } from "date-fns";
import { logger } from "logger";
import { NextApiRequest, NextApiResponse } from "next";
const token = process.env.SANITY_WRITE_KEY;
const client = noCdnClient(token);
export default async function logPageView(
req: NextApiRequest,
res: NextApiResponse
) {
const { id } = req.query;
if (!id) {
return res.status(400).json({
message: "Missing required parameter(s). id",
});
}
//check if document with id exists in sanity to prevent creating metrics for pages that don't exist
const page = await client.fetch(`*[_id == "${id}"][0]`);
if (!page) {
return res.status(400).json({
message: `Page with id: ${id} does not exist`,
});
}
//check if metrics doucment with id exists in sanity
const metrics = await client.fetch(
`*[_type=="metrics" && references("${id}")][0]`
);
if (!metrics) {
await client.create({
_type: "metrics",
reference: { _ref: id, _type: "reference" },
logStart: format(new Date(), "yyyy-MM-dd"),
pageviews: 1,
weeksObj: {
weeks: [
{
week: format(new Date(), "yyyy-MM-dd"),
views: 1,
_key: new Date().getTime().toString(),
scrollLength: 0,
time: 0,
},
],
},
avgScrollLength: 0,
avgTime: 0,
});
return res.status(200).json({ message: "Metrics created" });
}
await client
.patch(metrics._id)
.setIfMissing({ "weeksObj.weeks": [] })
.setIfMissing({ pageviews: 0 })
.inc({ pageviews: 1 })
.commit()
.catch((err) => {
logger.error(err);
return res.status(500).json({ message: "Error updating page" });
});
const { weeks } = metrics.weeksObj;
// If weeks array is not empty, compare last week
const lastWeek = weeks[weeks.length - 1].week;
if (isSameWeek(new Date(lastWeek), new Date())) {
// If last week is same as current week, increment views
await client
.patch(metrics._id)
.inc({ "weeksObj.weeks[0].views": 1 })
.commit()
.catch((err) => {
logger.error(err);
return res.status(500).json({ message: "Error updating current week" });
});
} else {
// If last week is not same as current week, add new week
await client
.patch(metrics._id)
.prepend("weeksObj.weeks", [
{
week: format(new Date(), "yyyy-MM-dd"),
views: 1,
scrollLength: 0,
time: 0,
},
])
.commit({
autoGenerateArrayKeys: true,
})
.catch((err) => {
logger.error(err);
return res.status(500).json({ message: "Error creating new week" });
});
}
return res.status(200).json({ message: `Metrics with id: ${id} updated.` });
}
@@ -1,80 +0,0 @@
import { noCdnClient } from "@/sanity/client.server";
import { logger } from "logger";
import { NextApiRequest, NextApiResponse } from "next";
const token = process.env.SANITY_WRITE_KEY;
const client = noCdnClient(token);
export default async function logPageScroll(
req: NextApiRequest,
res: NextApiResponse
) {
const { id, length } = req.query;
if (!id || !length) {
return res.status(400).json({
message: "Missing required parameter(s). id or length",
});
}
//check if document with id exists in sanity to prevent creating metrics for pages that don't exist
const page = await client.fetch(`*[_id == "${id}"][0]`);
if (!page) {
return res.status(400).json({
message: `Document with id: ${id} does not exist`,
});
}
//check if metrics doucment with id exists in sanity
const metrics = await client.fetch(
`*[_type=="metrics" && references("${id}")][0]`
);
if (!metrics) {
return res.status(400).json({
message: `Metrics document with id: ${id} does not exist`,
});
}
const { avgScrollLength, pageviews, weeksObj } = metrics;
const currrentWeek = weeksObj.weeks[0];
await client
.patch(metrics._id)
.setIfMissing({ avgScrollLength: 0 })
.set({
avgScrollLength: calcNewAverage(
avgScrollLength,
pageviews,
Number(length)
),
})
.set({
"weeksObj.weeks[0].scrollLength": calcNewAverage(
currrentWeek.scrollLength,
currrentWeek.views,
Number(length)
),
})
.commit()
.catch((err) => {
logger.error(err);
return res.status(500).json({ message: "Error updating metrics" });
});
return res.status(200).json({ message: `Metrics with id: ${id} updated.` });
}
export const calcNewAverage = (
oldAverage: number,
occurances: number,
newValue: number
): number => {
if (oldAverage === 0 || oldAverage === null) {
return Math.round(newValue / occurances);
}
return Math.round((oldAverage * (occurances - 1) + newValue) / occurances);
};
@@ -1,56 +0,0 @@
import { noCdnClient } from "@/sanity/client.server";
import { NextApiRequest, NextApiResponse } from "next";
import { calcNewAverage } from "./log-scroll";
import { logger } from "logger";
const token = process.env.SANITY_WRITE_KEY;
const client = noCdnClient(token);
export default async function logTime(
req: NextApiRequest,
res: NextApiResponse
) {
const { id, time } = req.query;
if (!(id && time)) {
return res.status(400).json({
message: "Missing required parameter(s). id or time",
});
}
const metrics = await client.fetch(
`*[_type=="metrics" && references("${id}")][0]`
);
if (!metrics) {
return res.status(400).json({
message: `Metrics document with id: ${id} does not exist`,
});
}
const { avgTime, pageviews, weeksObj } = metrics;
const currrentWeek = weeksObj.weeks[0];
const newAverage = calcNewAverage(avgTime, pageviews, Number(time));
await client
.patch(metrics._id)
.setIfMissing({ avgTime: 0 })
.set({ avgTime: newAverage })
.set({
"weeksObj.weeks[0].time": calcNewAverage(
currrentWeek.time,
currrentWeek.views,
Number(time)
),
})
.commit()
.catch((err) => {
logger.error(err);
return res.status(500).json({ message: "Error updating page" });
});
return res.status(200).json({ message: `Page with id: ${id} updated.` });
}
+8 -1
View File
@@ -1,4 +1,11 @@
import { previewClient } from "@/sanity/client.server";
import { createClient } from "next-sanity";
import { clientConfig } from "../../sanity/config";
const previewClient = createClient({
...clientConfig,
token: process.env.SANITY_PREVIEW_TOKEN,
ignoreBrowserTokenWarning: process.env.NODE_ENV === "test",
});
function redirectToPreview(res, Location) {
// Enable preview mode by setting the cookies
@@ -0,0 +1,19 @@
import { Alert } from "@navikt/ds-react";
import { withDsExample } from "components/website-modules/examples/withDsExample";
const Example = () => {
return (
<Alert variant="error">Noe gikk galt! Prøv igjen om noen minutter.</Alert>
);
};
export default withDsExample(Example);
/* Storybook story */
export const Demo = {
render: Example,
};
export const args = {
index: 0,
};
@@ -0,0 +1,23 @@
import { Alert } from "@navikt/ds-react";
import { withDsExample } from "components/website-modules/examples/withDsExample";
const Example = () => {
return (
<Alert variant="info">
Hvis du er mellom 62 og 67 år når du søker, må du som hovedregel ha hatt
en pensjonsgivende inntekt som tilsvarer x G, året før du fikk nedsatt
arbeidsevnen.
</Alert>
);
};
export default withDsExample(Example);
/* Storybook story */
export const Demo = {
render: Example,
};
export const args = {
index: 0,
};
@@ -0,0 +1,17 @@
import { Alert } from "@navikt/ds-react";
import { withDsExample } from "components/website-modules/examples/withDsExample";
const Example = () => {
return <Alert variant="success">Søknad er sendt inn!</Alert>;
};
export default withDsExample(Example);
/* Storybook story */
export const Demo = {
render: Example,
};
export const args = {
index: 0,
};
@@ -0,0 +1,21 @@
import { Alert } from "@navikt/ds-react";
import { withDsExample } from "components/website-modules/examples/withDsExample";
const Example = () => {
return (
<Alert variant="warning">
Du må være registrert hos NAV for å bruke aktivitetsplanen.
</Alert>
);
};
export default withDsExample(Example);
/* Storybook story */
export const Demo = {
render: Example,
};
export const args = {
index: 0,
};
@@ -44,4 +44,5 @@ export const Demo = {
export const args = {
index: 5,
desc: "Removable chip viser valgte verdier som brukeren kan fjerne.",
};
@@ -44,4 +44,5 @@ export const Demo = {
export const args = {
index: 4,
desc: "Removable chip viser valgte verdier som brukeren kan fjerne.",
};
@@ -47,4 +47,5 @@ export const Demo = {
export const args = {
index: 2,
desc: "Toggle chip brukes til filtrering av innhold og data.",
};
@@ -46,4 +46,5 @@ export const Demo = {
export const args = {
index: 1,
desc: "Toggle chip brukes til filtrering av innhold og data.",
};
@@ -0,0 +1,63 @@
import { UNSAFE_Combobox } from "@navikt/ds-react";
import { withDsExample } from "components/website-modules/examples/withDsExample";
import { useMemo, useState } from "react";
const initialOptions = [
"Norge",
"Sverige",
"Danmark",
"Finland",
"Island",
"Storbritannia",
"Tyskland",
"Frankrike",
"Spania",
"Portugal",
"Italia",
"Hellas",
"Kroatia",
"Tyrkia",
];
const initialSelectedOptions = ["Norge"];
export const Example = () => {
const [value, setValue] = useState("");
const [selectedOptions, setSelectedOptions] = useState(
initialSelectedOptions
);
const filteredOptions = useMemo(
() => initialOptions.filter((option) => option.includes(value)),
[value]
);
const onToggleSelected = (option, isSelected) => {
if (isSelected) {
setSelectedOptions([...selectedOptions, option]);
} else {
setSelectedOptions(selectedOptions.filter((o) => o !== option));
}
};
return (
<div>
<UNSAFE_Combobox
label="Hvilke land har du besøkt de siste 6 ukene? Velg opptil flere."
filteredOptions={filteredOptions}
isMultiSelect
onChange={(event) => setValue(event.target.value)}
onToggleSelected={onToggleSelected}
selectedOptions={selectedOptions}
options={initialOptions}
value={value}
/>
</div>
);
};
export default withDsExample(Example, "static");
export const args = {
index: 1,
desc: "Du kan overstyre blant annet value, selectedOptions, filteredOptions.",
};
@@ -0,0 +1,36 @@
import { UNSAFE_Combobox } from "@navikt/ds-react";
import { withDsExample } from "components/website-modules/examples/withDsExample";
const initialOptions = [
"tea",
"coffee",
"hot chocolate",
"lemonade",
"orange juice",
"apple juice",
"smoothie",
"milk",
"water",
"soda",
"beer",
"wine",
];
export const Example = () => {
return (
<div>
<UNSAFE_Combobox
label="Hva er dine favorittdrikker? Legg gjerne til flere alternativer."
options={initialOptions}
isMultiSelect
/>
</div>
);
};
export default withDsExample(Example, "static");
export const args = {
index: 1,
desc: "Ved Multi Select kan brukeren velge flere valg fra nedtrekkslisten.",
};
@@ -0,0 +1,36 @@
import { UNSAFE_Combobox } from "@navikt/ds-react";
import { withDsExample } from "components/website-modules/examples/withDsExample";
const initialOptions = [
"car",
"bus",
"train",
"skateboard",
"bicycle",
"motorcycle",
"boat",
"airplane",
"helicopter",
"truck",
"van",
"scooter",
];
export const Example = () => {
return (
<div>
<UNSAFE_Combobox
label="Hva er de kuleste transportmidlene?"
options={initialOptions}
isMultiSelect
/>
</div>
);
};
export default withDsExample(Example, "static");
export const args = {
index: 1,
desc: "Ved Multi Select kan brukeren velge flere valg fra listen.",
};
@@ -0,0 +1,35 @@
import { UNSAFE_Combobox } from "@navikt/ds-react";
import { withDsExample } from "components/website-modules/examples/withDsExample";
const initialOptions = [
"A New Hope",
"The Empire Strikes Back",
"Return of the Jedi",
"The Phantom Menace",
"Attack of the Clones",
"Revenge of the Sith",
"The Force Awakens",
"Rogue One",
"The Last Jedi",
"Solo",
"The Rise of Skywalker",
];
export const Example = () => {
return (
<div>
<UNSAFE_Combobox
label="Hva er den aller kuleste Star Wars-filmen noensinne, helt objektivt?"
options={initialOptions}
shouldAutocomplete={true}
/>
</div>
);
};
export default withDsExample(Example, "static");
export const args = {
index: 0,
desc: "Ved Single Select velger brukeren ett valg fra listen. Med autocomplete foreslås et valg fra listen som matcher det brukeren skriver.",
};
@@ -0,0 +1,35 @@
import { UNSAFE_Combobox } from "@navikt/ds-react";
import { withDsExample } from "components/website-modules/examples/withDsExample";
const initialOptions = [
"banana",
"apple",
"tangerine",
"pear",
"grape",
"kiwi",
"mango",
"passion fruit",
"pineapple",
"strawberry",
"watermelon",
"grape fruit",
];
export const Example = () => {
return (
<div>
<UNSAFE_Combobox
label="Hva er din favorittfrukt?"
options={initialOptions}
/>
</div>
);
};
export default withDsExample(Example, "static");
export const args = {
index: 0,
desc: "Ved Single Select velger brukeren kun ett valg fra nedtrekkslisten.",
};
@@ -8,10 +8,9 @@ import cl from "clsx";
import { Header } from "components/layout/header/Header";
import ArtikkelCard from "components/sanity-modules/cards/ArtikkelCard";
import { AkselCubeStatic } from "components/website-modules/cube";
import { PreviewSuspense } from "next-sanity/preview";
import Head from "next/head";
import Image from "next/legacy/image";
import { lazy } from "react";
import { Suspense, lazy } from "react";
import NotFotfund from "../404";
import { getAkselTema, urlFor } from "@/sanity/interface";
import { contributorsSingle, destructureBlocks } from "@/sanity/queries";
@@ -193,7 +192,7 @@ const Page = ({ tema: page }: PageProps["props"]) => {
</div>
</div>
<div className="relative px-4 pt-8 pb-24 md:pt-16 xl:pt-8 ">
<div className="relative px-4 pb-24 pt-8 md:pt-16 xl:pt-8 ">
{hasPages ? (
<div className="dynamic-wrapper grid gap-20">
{page.seksjoner.map((seksjon) =>
@@ -245,7 +244,7 @@ const WithPreview = lazy(() => import("../../components/WithPreview"));
const Wrapper = (props: any) => {
if (props?.preview) {
return (
<PreviewSuspense fallback={<Page {...props} />}>
<Suspense fallback={<Page {...props} />}>
<WithPreview
comp={Page}
query={query}
@@ -254,7 +253,7 @@ const Wrapper = (props: any) => {
slug: props?.slug,
}}
/>
</PreviewSuspense>
</Suspense>
);
}
@@ -10,10 +10,9 @@ import {
} from "@/types";
import { BodyShort, Detail, Heading, Ingress, Label } from "@navikt/ds-react";
import ArtikkelCard from "components/sanity-modules/cards/ArtikkelCard";
import { PreviewSuspense } from "next-sanity/preview";
import Head from "next/head";
import NextLink from "next/link";
import { lazy } from "react";
import { lazy, Suspense } from "react";
import NotFotfund from "../../404";
import {
@@ -322,7 +321,7 @@ const WithPreview = lazy(() => import("../../../components/WithPreview"));
const Wrapper = (props: any) => {
if (props?.preview) {
return (
<PreviewSuspense fallback={<Page {...props} />}>
<Suspense fallback={<Page {...props} />}>
<WithPreview
comp={Page}
query={query}
@@ -331,7 +330,7 @@ const Wrapper = (props: any) => {
slug: `god-praksis/artikler/${props?.slug}`,
}}
/>
</PreviewSuspense>
</Suspense>
);
}
@@ -18,9 +18,8 @@ import { Header } from "components/layout/header/Header";
import ArtikkelCard from "components/sanity-modules/cards/ArtikkelCard";
import GodPraksisCard from "components/sanity-modules/cards/GodPraksisCard";
import { AkselCubeStatic } from "components/website-modules/cube";
import { PreviewSuspense } from "next-sanity/preview";
import Head from "next/head";
import React, { lazy } from "react";
import React, { Suspense, lazy } from "react";
type PageProps = NextPageT<{
page: AkselGodPraksisLandingPageDocT;
@@ -169,9 +168,9 @@ const WithPreview = lazy(() => import("../../components/WithPreview"));
const Wrapper = (props: any) => {
if (props?.preview) {
return (
<PreviewSuspense fallback={<Page {...props} />}>
<Suspense fallback={<Page {...props} />}>
<WithPreview comp={Page} query={query} props={props} />
</PreviewSuspense>
</Suspense>
);
}
@@ -13,9 +13,8 @@ import { Detail } from "@navikt/ds-react";
import { WithSidebar } from "components/layout/WithSidebar";
import IntroSeksjon from "components/sanity-modules/IntroSeksjon";
import { StatusTag } from "components/website-modules/StatusTag";
import { PreviewSuspense } from "next-sanity/preview";
import Head from "next/head";
import { lazy } from "react";
import { Suspense, lazy } from "react";
import NotFotfund from "../404";
import { getDocumentsTmp, urlFor } from "@/sanity/interface";
import { destructureBlocks, sidebarQuery } from "@/sanity/queries";
@@ -145,7 +144,7 @@ const WithPreview = lazy(() => import("../../components/WithPreview"));
const Wrapper = (props: any) => {
if (props?.preview) {
return (
<PreviewSuspense fallback={<Page {...props} />}>
<Suspense fallback={<Page {...props} />}>
<WithPreview
comp={Page}
query={query}
@@ -155,7 +154,7 @@ const Wrapper = (props: any) => {
}}
props={props}
/>
</PreviewSuspense>
</Suspense>
);
}
@@ -10,9 +10,8 @@ import { Heading, Ingress } from "@navikt/ds-react";
import cl from "clsx";
import { WithSidebar } from "components/layout/WithSidebar";
import ComponentOverview from "components/sanity-modules/ComponentOverview";
import { PreviewSuspense } from "next-sanity/preview";
import Head from "next/head";
import { lazy } from "react";
import { Suspense, lazy } from "react";
import { grunnleggendeKategorier } from "../../sanity/config";
import { urlFor } from "@/sanity/interface";
import { sidebarQuery, landingPageQuery } from "@/sanity/queries";
@@ -130,7 +129,7 @@ const WithPreview = lazy(() => import("../../components/WithPreview"));
const Wrapper = (props: any) => {
if (props?.preview) {
return (
<PreviewSuspense fallback={<Page {...props} />}>
<Suspense fallback={<Page {...props} />}>
<WithPreview
comp={Page}
query={query}
@@ -139,7 +138,7 @@ const Wrapper = (props: any) => {
type: "ds_artikkel",
}}
/>
</PreviewSuspense>
</Suspense>
);
}
+4 -5
View File
@@ -22,9 +22,8 @@ import FrontpageBlock, {
import { AkselCube } from "components/website-modules/cube";
import { IntroCards } from "components/website-modules/IntroCards";
import { PrefersReducedMotion } from "components/website-modules/utils/prefers-reduced-motion";
import { PreviewSuspense } from "next-sanity/preview";
import Head from "next/head";
import { lazy, useEffect, useState } from "react";
import { lazy, Suspense, useEffect, useState } from "react";
type PageProps = NextPageT<{
tema: Array<AkselTemaT>;
@@ -238,7 +237,7 @@ const Forside = ({ page, tema, blocks }: PageProps["props"]) => {
<div className="bg-surface-default ring-border-subtle mx-auto w-full -translate-y-48 rounded-2xl px-4 py-12 ring-1 sm:-translate-y-32 sm:px-12 sm:py-20">
{!reducedMotion && (
<button
className="focus-visible:shadow-focus text-text-subtle hover:text-text-default absolute top-2 right-2 grid h-11 w-11 place-items-center rounded-xl text-2xl focus:outline-none focus-visible:ring-2"
className="focus-visible:shadow-focus text-text-subtle hover:text-text-default absolute right-2 top-2 grid h-11 w-11 place-items-center rounded-xl text-2xl focus:outline-none focus-visible:ring-2"
onClick={() => {
setPause(!pause);
localStorage.setItem(
@@ -298,9 +297,9 @@ const WithPreview = lazy(() => import("../components/WithPreview"));
const Page = (props: PageProps["props"]) => {
if (props?.preview) {
return (
<PreviewSuspense fallback={<Forside {...props} />}>
<Suspense fallback={<Forside {...props} />}>
<WithPreview comp={Forside} query={query} props={props} />
</PreviewSuspense>
</Suspense>
);
}
@@ -24,9 +24,8 @@ import ComponentOverview from "components/sanity-modules/ComponentOverview";
import IntroSeksjon from "components/sanity-modules/IntroSeksjon";
import { StatusTag } from "components/website-modules/StatusTag";
import { SuggestionBlock } from "components/website-modules/suggestionblock";
import { PreviewSuspense } from "next-sanity/preview";
import Head from "next/head";
import { lazy } from "react";
import { lazy, Suspense } from "react";
import NotFotfund from "../404";
const kodepakker = {
@@ -336,7 +335,7 @@ const WithPreview = lazy(() => import("../../components/WithPreview"));
const Wrapper = (props: any) => {
if (props?.preview) {
return (
<PreviewSuspense fallback={<Page {...props} />}>
<Suspense fallback={<Page {...props} />}>
<WithPreview
comp={Page}
query={query}
@@ -347,7 +346,7 @@ const Wrapper = (props: any) => {
}}
props={props}
/>
</PreviewSuspense>
</Suspense>
);
}
@@ -20,9 +20,8 @@ import {
import { WithSidebar } from "components/layout/WithSidebar";
import ComponentOverview from "components/sanity-modules/ComponentOverview";
import { IntroCards } from "components/website-modules/IntroCards";
import { PreviewSuspense } from "next-sanity/preview";
import Head from "next/head";
import { lazy } from "react";
import { Suspense, lazy } from "react";
import { komponentKategorier } from "../../sanity/config";
import { urlFor } from "@/sanity/interface";
import { sidebarQuery, landingPageQuery } from "@/sanity/queries";
@@ -190,13 +189,13 @@ const Page = ({ page, sidebar, links }: PageProps["props"]) => {
title: "Kom i gang med Kode",
desc: "Intro til alle kodepakkene våre",
icon: CodeIcon,
href: "/grunnleggende/kode/kom-i-gang-med-kodepakkene",
href: "/grunnleggende/introduksjon/kom-i-gang-med-kodepakkene",
},
{
title: "Kom i gang med Figma",
desc: "Hvordan bruke Figma-bibliotekene våre",
icon: FigmaIcon,
href: "/grunnleggende/styling/design-tokens",
href: "/grunnleggende/introduksjon/kom-i-gang-med-figma",
},
{
title: "Forslag til nye komponenter",
@@ -251,7 +250,7 @@ const WithPreview = lazy(() => import("../../components/WithPreview"));
const Wrapper = (props: any) => {
if (props?.preview) {
return (
<PreviewSuspense fallback={<Page {...props} />}>
<Suspense fallback={<Page {...props} />}>
<WithPreview
comp={Page}
query={query}
@@ -260,7 +259,7 @@ const Wrapper = (props: any) => {
type: "komponent_artikkel",
}}
/>
</PreviewSuspense>
</Suspense>
);
}
@@ -18,10 +18,9 @@ import {
import { BodyShort, Heading, Ingress, Label } from "@navikt/ds-react";
import cl from "clsx";
import { Header } from "components/layout/header/Header";
import { PreviewSuspense } from "next-sanity/preview";
import Head from "next/head";
import { GetServerSideProps } from "next/types";
import { lazy } from "react";
import { lazy, Suspense } from "react";
import NotFotfund from "../404";
import { urlFor } from "@/sanity/interface";
import { contributorsAll, destructureBlocks } from "@/sanity/queries";
@@ -243,7 +242,7 @@ const WithPreview = lazy(() => import("../../components/WithPreview"));
const Wrapper = (props: any) => {
if (props?.preview) {
return (
<PreviewSuspense fallback={<Page {...props} />}>
<Suspense fallback={<Page {...props} />}>
<WithPreview
comp={Page}
query={query}
@@ -253,7 +252,7 @@ const Wrapper = (props: any) => {
valid: "true",
}}
/>
</PreviewSuspense>
</Suspense>
);
}
@@ -16,11 +16,10 @@ import { AkselCubeStatic } from "components/website-modules/cube";
import Feedback from "components/website-modules/feedback";
import TableOfContents from "components/website-modules/TOC";
import { getImage } from "components/website-modules/utils/get-image";
import { PreviewSuspense } from "next-sanity/preview";
import Head from "next/head";
import Image from "next/legacy/image";
import { GetServerSideProps } from "next/types";
import { lazy } from "react";
import { Suspense, lazy } from "react";
import NotFotfund from "../404";
import { urlFor } from "@/sanity/interface";
import { destructureBlocks, contributorsAll } from "@/sanity/queries";
@@ -258,14 +257,14 @@ const WithPreview = lazy(() => import("../../components/WithPreview"));
const Wrapper = (props: any) => {
if (props?.preview) {
return (
<PreviewSuspense fallback={<Page {...props} />}>
<Suspense fallback={<Page {...props} />}>
<WithPreview
comp={Page}
query={query}
props={props}
params={{ slug: `produktbloggen/${props.slug}` }}
/>
</PreviewSuspense>
</Suspense>
);
}
@@ -13,9 +13,8 @@ import BloggCard from "components/sanity-modules/cards/BloggCard";
import { BloggAd } from "components/website-modules/BloggAd";
import { AkselCubeStatic } from "components/website-modules/cube";
import { LatestBloggposts } from "components/website-modules/blogg-page";
import { PreviewSuspense } from "next-sanity/preview";
import Head from "next/head";
import { lazy } from "react";
import { Suspense, lazy } from "react";
import NotFotfund from "../404";
import { urlFor } from "@/sanity/interface";
import { destructureBlocks, contributorsAll } from "@/sanity/queries";
@@ -139,9 +138,9 @@ const WithPreview = lazy(() => import("../../components/WithPreview"));
const Wrapper = (props: any) => {
if (props?.preview) {
return (
<PreviewSuspense fallback={<Page {...props} />}>
<Suspense fallback={<Page {...props} />}>
<WithPreview comp={Page} query={query} props={props} />
</PreviewSuspense>
</Suspense>
);
}
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more