mirror of
https://github.com/navikt/aksel.git
synced 2026-10-15 02:02:02 +00:00
Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b691b6cd0 | ||
|
|
1b048ba5c3 | ||
|
|
e92c897c01 | ||
|
|
a1041d13cd | ||
|
|
241dd79ecb | ||
|
|
6aaf56bc10 | ||
|
|
7becf3e4cf | ||
|
|
a358d222b5 | ||
|
|
24d4d48c87 | ||
|
|
2d0c2c6567 | ||
|
|
bd482c5d3e | ||
|
|
c8c3fc4ec5 | ||
|
|
834ba0cbae | ||
|
|
be653cc67a | ||
|
|
054835d1dd | ||
|
|
7f99d407c0 | ||
|
|
1202e0a6f7 | ||
|
|
0c06fa12d0 | ||
|
|
4797ccae9d | ||
|
|
ac06d882a7 | ||
|
|
8f5c956bad | ||
|
|
6b40a0ddd8 | ||
|
|
60a6cba329 | ||
|
|
7fd62f3b79 | ||
|
|
0944bbc256 | ||
|
|
16087a458a | ||
|
|
c47c372d0f | ||
|
|
0c479831df | ||
|
|
41e9fbd2a5 | ||
|
|
7b25f30e60 | ||
|
|
db9c858153 | ||
|
|
b21015bf54 | ||
|
|
bac48d9057 | ||
|
|
e1a85dfbeb | ||
|
|
3f65eee9e9 | ||
|
|
cd77198f1c | ||
|
|
82827ec712 | ||
|
|
710ed83661 | ||
|
|
3b44a97725 | ||
|
|
edcbd6e198 | ||
|
|
bd3f9355eb | ||
|
|
f11bce1f8c | ||
|
|
277525f39f | ||
|
|
210496c2cc | ||
|
|
8f317878fc | ||
|
|
c3064a01f7 | ||
|
|
02f0a911b0 | ||
|
|
ee8524229f | ||
|
|
d9932e203a | ||
|
|
b14fc23ecb | ||
|
|
c16f75d6f8 | ||
|
|
a01be91608 | ||
|
|
8826a79021 | ||
|
|
c2f3da84cc | ||
|
|
2c1476022d | ||
|
|
dacf648c47 | ||
|
|
5ec986771b | ||
|
|
ca6af8a5f7 | ||
|
|
9410840f74 | ||
|
|
d88884f785 | ||
|
|
0c8239ee00 | ||
|
|
987118fd47 | ||
|
|
8d13f11689 | ||
|
|
158e18454e | ||
|
|
be26bf22c9 | ||
|
|
c2968a0e6d | ||
|
|
3f92773273 | ||
|
|
0c3ab1d0e7 | ||
|
|
3a73a029f6 | ||
|
|
6fd0f501f5 | ||
|
|
a11b8ddf4e | ||
|
|
171db6a813 | ||
|
|
085cc532ee | ||
|
|
deae4ecba5 | ||
|
|
cd2adcb655 | ||
|
|
0d4385a545 | ||
|
|
6f1b9751f3 | ||
|
|
7fb97808f0 | ||
|
|
d8ac958de2 | ||
|
|
b417c74374 | ||
|
|
ff1d3534a6 | ||
|
|
57d015209d | ||
|
|
5969859024 |
No files matched your search
@@ -19,6 +19,7 @@ add links to project-tasks, slack discussions etc here
|
||||
- Check that styling is correctly exported from `@navikt/core/css/index.css`
|
||||
- Check that correct tokens for component is documented in `@navikt/core/css/tokens.json`
|
||||
- Check that only needed components and types are exported from `@navikt/core/react/src/index.ts`
|
||||
- Check that component is exported from `@navikt/core/react/package.json`
|
||||
- Add JSDoc to component (see other components for reference)
|
||||
- Create storybook-stories for relevant component variations and compositions. Stories are used for [visual regression testing](https://www.chromatic.com/docs/), so make sure they are complete.
|
||||
- Is the component sufficiently complex to warrant tests? Add some unit-tests for vitest.
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @navikt/aksel-icons
|
||||
|
||||
## 6.8.0
|
||||
|
||||
## 6.7.1
|
||||
|
||||
## 6.7.0
|
||||
|
||||
## 6.6.1
|
||||
|
||||
## 6.6.0
|
||||
|
||||
## 6.5.0
|
||||
|
||||
## 6.4.1
|
||||
|
||||
## 6.4.0
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"postcss": "^8.4.31",
|
||||
"postcss-url": "^10.1.3",
|
||||
"typescript": "^5.1.6",
|
||||
"vite": "^5.0.12",
|
||||
"vite": "^5.1.7",
|
||||
"vite-plugin-singlefile": "^1.0.0",
|
||||
"vite-plugin-svgr": "^4.2.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel-icons",
|
||||
"version": "6.4.1",
|
||||
"version": "6.8.0",
|
||||
"author": "Aksel | NAV",
|
||||
"homepage": "https://aksel.nav.no/ikoner",
|
||||
"repository": {
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @navikt/aksel-stylelint
|
||||
|
||||
## 6.8.0
|
||||
|
||||
## 6.7.1
|
||||
|
||||
## 6.7.0
|
||||
|
||||
## 6.6.1
|
||||
|
||||
## 6.6.0
|
||||
|
||||
## 6.5.0
|
||||
|
||||
## 6.4.1
|
||||
|
||||
## 6.4.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel-stylelint",
|
||||
"version": "6.4.1",
|
||||
"version": "6.8.0",
|
||||
"author": "Aksel | NAV",
|
||||
"homepage": "https://aksel.nav.no/grunnleggende/kode/stylelint",
|
||||
"repository": {
|
||||
@@ -35,8 +35,8 @@
|
||||
"dev": "yarn watch:lint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@navikt/ds-css": "^6.4.1",
|
||||
"@navikt/ds-tokens": "^6.4.1",
|
||||
"@navikt/ds-css": "^6.8.0",
|
||||
"@navikt/ds-tokens": "^6.8.0",
|
||||
"concurrently": "7.2.1",
|
||||
"postcss-selector-parser": "^6.0.13",
|
||||
"postcss-value-parser": "^4.2.0",
|
||||
|
||||
@@ -1,5 +1,49 @@
|
||||
# @navikt/aksel
|
||||
|
||||
## 6.8.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`bd482c5d3`](https://github.com/navikt/aksel/commit/bd482c5d3ea9c7f84114d1199e73594c7b593154), [`7becf3e4c`](https://github.com/navikt/aksel/commit/7becf3e4cf47b119e4b456a066b53e6077669bcd)]:
|
||||
- @navikt/ds-css@6.8.0
|
||||
|
||||
## 6.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`0c06fa12d`](https://github.com/navikt/aksel/commit/0c06fa12d0de4f3015be045d4e65e149ebd2c141)]:
|
||||
- @navikt/ds-css@6.7.1
|
||||
|
||||
## 6.7.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`db9c85815`](https://github.com/navikt/aksel/commit/db9c85815392528e59ef5082397ec14b1aa36e7a), [`277525f39`](https://github.com/navikt/aksel/commit/277525f39ff94c7ded1accf6031957efa3bdeb8b), [`b21015bf5`](https://github.com/navikt/aksel/commit/b21015bf54b4e19673b2849756c608ae323a9f70)]:
|
||||
- @navikt/ds-css@6.7.0
|
||||
|
||||
## 6.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-css@6.6.1
|
||||
|
||||
## 6.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Aksel CLI: Fjernet deprecated komponenter fra css oversikt. ([#2860](https://github.com/navikt/aksel/pull/2860))
|
||||
|
||||
- Updated dependencies [[`3a73a029f`](https://github.com/navikt/aksel/commit/3a73a029f6272f76e10cb6baefbaa2040feaef6b)]:
|
||||
- @navikt/ds-css@6.6.0
|
||||
|
||||
## 6.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-css@6.5.0
|
||||
|
||||
## 6.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel",
|
||||
"version": "6.4.1",
|
||||
"version": "6.8.0",
|
||||
"description": "Aksel command line interface. Handles css-imports, codemods and more",
|
||||
"author": "Aksel | NAV designsystem team",
|
||||
"license": "MIT",
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"homepage": "https://aksel.nav.no/grunnleggende/kode/kommandolinje",
|
||||
"dependencies": {
|
||||
"@navikt/ds-css": "6.4.1",
|
||||
"@navikt/ds-css": "6.8.0",
|
||||
"axios": "1.6.0",
|
||||
"chalk": "4.1.0",
|
||||
"clipboardy": "^2.3.0",
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
# @navikt/ds-css
|
||||
|
||||
## 6.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- :sparkles: Ny komponent: FormProgress ([#2855](https://github.com/navikt/aksel/pull/2855))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- HGrid: forenklet CSS. ([#2917](https://github.com/navikt/aksel/pull/2917))
|
||||
|
||||
## 6.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Progressbar: Tweak API, examples, stories and css ([#2892](https://github.com/navikt/aksel/pull/2892))
|
||||
|
||||
## 6.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- :sparkles: Ny komponent: ProgressBar ([#2845](https://github.com/navikt/aksel/pull/2845))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Checkbox, Radio: Erstatter `:focus-visible` med `:focus` for bedre UX brukt sammen med ErrorSummary. ([#2893](https://github.com/navikt/aksel/pull/2893))
|
||||
|
||||
- :lipstick: tweak CSS for timeline pins. ([#2889](https://github.com/navikt/aksel/pull/2889))
|
||||
|
||||
## 6.6.1
|
||||
|
||||
## 6.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- :sparkles: Ny komponent FormSummary ([#2802](https://github.com/navikt/aksel/pull/2802))
|
||||
|
||||
## 6.5.0
|
||||
|
||||
## 6.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* https://web.dev/prefers-reduced-motion/ */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*:not(.navds-loader *):not(.navds-loader),
|
||||
*:not(.navds-loader *):not(.navds-loader):not(.navds-progress-bar *):not(.navds-progress-bar),
|
||||
::before,
|
||||
::after {
|
||||
animation-delay: -1ms !important;
|
||||
|
||||
@@ -97,10 +97,6 @@ const StyleMappings = {
|
||||
main: formCss,
|
||||
dependencies: [typoCss],
|
||||
},
|
||||
{
|
||||
component: "ContentContainer",
|
||||
main: "content-container.css",
|
||||
},
|
||||
{
|
||||
component: "CopyButton",
|
||||
main: "copybutton.css",
|
||||
@@ -125,7 +121,6 @@ const StyleMappings = {
|
||||
main: formCss,
|
||||
dependencies: [typoCss, "button.css", "loader.css", "link.css"],
|
||||
},
|
||||
{ component: "Grid", main: "grid.css", dependencies: [typoCss] },
|
||||
{
|
||||
component: "GuidePanel",
|
||||
main: "guide-panel.css",
|
||||
@@ -164,6 +159,7 @@ const StyleMappings = {
|
||||
},
|
||||
{ component: "Panel", main: "panel.css" },
|
||||
{ component: "Popover", main: "popover.css" },
|
||||
{ component: "ProgressBar", main: "progress-bar.css" },
|
||||
{ component: "Provider", main: "" },
|
||||
{ component: "Radio", main: formCss, dependencies: [typoCss] },
|
||||
{ component: "RadioGroup", main: formCss, dependencies: [typoCss] },
|
||||
@@ -178,6 +174,22 @@ const StyleMappings = {
|
||||
{ component: "Tag", main: "tag.css", dependencies: [typoCss] },
|
||||
{ component: "TextField", main: formCss, dependencies: [typoCss] },
|
||||
{ component: "Textarea", main: formCss, dependencies: [typoCss] },
|
||||
{
|
||||
component: "FormSummary",
|
||||
main: formCss,
|
||||
dependencies: [typoCss, "link.css"],
|
||||
},
|
||||
{
|
||||
component: "FormProgress",
|
||||
main: formCss,
|
||||
dependencies: [
|
||||
typoCss,
|
||||
"progress-bar.css",
|
||||
"button.css",
|
||||
primitivesCss,
|
||||
"stepper.css",
|
||||
],
|
||||
},
|
||||
{
|
||||
component: "ToggleGroup",
|
||||
main: "toggle-group.css",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
.navds-form-progress__bar {
|
||||
margin-bottom: var(--a-spacing-2);
|
||||
}
|
||||
|
||||
.navds-form-progress__button[data-state="closed"] .navds-form-progress__btn-txt-hide,
|
||||
.navds-form-progress__button[data-state="open"] .navds-form-progress__btn-txt-show {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navds-form-progress__button:hover svg {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.navds-form-progress__button[data-state="open"] svg {
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
|
||||
.navds-form-progress__button[data-state="open"]:hover svg {
|
||||
transform: translateY(-1px) rotate(-180deg);
|
||||
}
|
||||
|
||||
.navds-form-progress__collapsible {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
overflow: hidden;
|
||||
transition: grid-template-rows 0.3s cubic-bezier(0.3, 0, 0.15, 1);
|
||||
}
|
||||
|
||||
.navds-form-progress__collapsible[hidden] {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.navds-form-progress__collapsible[data-state="open"] {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.navds-form-progress__collapsible-content {
|
||||
min-height: 0;
|
||||
transition: visibility 0.3s;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.navds-form-progress__collapsible[data-state="open"] .navds-form-progress__collapsible-content {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.navds-form-progress__stepper {
|
||||
border: 2px solid var(--a-border-subtle);
|
||||
border-radius: var(--a-border-radius-large);
|
||||
padding: var(--a-spacing-2) var(--a-spacing-4) 0;
|
||||
margin-top: var(--a-spacing-1);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
.navds-form-summary {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--a-border-subtle);
|
||||
border-radius: var(--a-border-radius-large);
|
||||
}
|
||||
|
||||
.navds-form-summary__header {
|
||||
background-color: var(--a-surface-subtle);
|
||||
padding: var(--a-spacing-4) var(--a-spacing-6);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0 var(--a-spacing-2);
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.navds-form-summary__header {
|
||||
padding: var(--a-spacing-3) var(--a-spacing-4);
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.navds-form-summary__edit {
|
||||
flex-shrink: 0;
|
||||
margin-top: var(--a-spacing-1);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.navds-form-summary > .navds-form-summary__answers {
|
||||
border-top: 1px solid var(--a-border-subtle);
|
||||
}
|
||||
|
||||
.navds-form-summary__answers {
|
||||
padding: var(--a-spacing-5) var(--a-spacing-6) var(--a-spacing-6);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.navds-form-summary__answers {
|
||||
padding: var(--a-spacing-4);
|
||||
}
|
||||
}
|
||||
|
||||
.navds-form-summary__answer:not(:last-child) {
|
||||
margin-bottom: var(--a-spacing-4);
|
||||
padding-bottom: var(--a-spacing-4);
|
||||
border-bottom: 1px solid var(--a-border-divider);
|
||||
}
|
||||
|
||||
.navds-form-summary__answer:has(.navds-form-summary__answer) {
|
||||
padding-bottom: var(--a-spacing-6);
|
||||
}
|
||||
|
||||
.navds-form-summary__answer:has(.navds-form-summary__answer):last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.navds-form-summary__answer:not(:last-child) {
|
||||
margin-bottom: var(--a-spacing-3);
|
||||
padding-bottom: var(--a-spacing-3);
|
||||
}
|
||||
|
||||
.navds-form-summary__answer:has(.navds-form-summary__answer) {
|
||||
padding-bottom: var(--a-spacing-5);
|
||||
}
|
||||
}
|
||||
|
||||
.navds-form-summary__value:first-of-type {
|
||||
margin-top: var(--a-spacing-1);
|
||||
}
|
||||
|
||||
.navds-form-summary__value :where(.navds-form-summary__answers) {
|
||||
margin-top: var(--a-spacing-2);
|
||||
padding: var(--a-spacing-4);
|
||||
background: var(--a-surface-selected);
|
||||
border-radius: var(--a-border-radius-large);
|
||||
}
|
||||
@@ -13,3 +13,5 @@
|
||||
@import "textarea.css";
|
||||
@import "search.css";
|
||||
@import "combobox.css";
|
||||
@import "form-summary.css";
|
||||
@import "form-progress.css";
|
||||
@@ -70,8 +70,8 @@
|
||||
height: calc(1.25rem - 0.25rem);
|
||||
}
|
||||
|
||||
.navds-checkbox__input:focus-visible + .navds-checkbox__label::before,
|
||||
.navds-radio__input:focus-visible + .navds-radio__label::before {
|
||||
.navds-checkbox__input:focus + .navds-checkbox__label::before,
|
||||
.navds-radio__input:focus + .navds-radio__label::before {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
box-shadow:
|
||||
@@ -80,33 +80,13 @@
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
|
||||
@supports not selector(:focus-visible) {
|
||||
.navds-checkbox__input:focus + .navds-checkbox__label::before,
|
||||
.navds-radio__input:focus + .navds-radio__label::before {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ac-radio-checkbox-border, var(--a-border-default)),
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
}
|
||||
|
||||
.navds-checkbox__input:hover:focus-visible + .navds-checkbox__label::before,
|
||||
.navds-radio__input:hover:focus-visible + .navds-radio__label::before {
|
||||
.navds-checkbox__input:hover:focus + .navds-checkbox__label::before,
|
||||
.navds-radio__input:hover:focus + .navds-radio__label::before {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ac-radio-checkbox-action, var(--a-surface-action-hover)),
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
|
||||
@supports not selector(:focus-visible) {
|
||||
.navds-checkbox__input:hover:focus + .navds-checkbox__label::before,
|
||||
.navds-radio__input:hover:focus + .navds-radio__label::before {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ac-radio-checkbox-action, var(--a-surface-action-hover)),
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
}
|
||||
|
||||
.navds-checkbox__input:indeterminate + .navds-checkbox__label::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -163,24 +143,14 @@
|
||||
background-position: 0.25rem center;
|
||||
}
|
||||
|
||||
.navds-checkbox__input:indeterminate:focus-visible + .navds-checkbox__label::before,
|
||||
.navds-checkbox__input:checked:focus-visible + .navds-checkbox__label::before {
|
||||
.navds-checkbox__input:indeterminate:focus + .navds-checkbox__label::before,
|
||||
.navds-checkbox__input:checked:focus + .navds-checkbox__label::before {
|
||||
box-shadow:
|
||||
0 0 0 1px var(--ac-radio-checkbox-action, var(--a-surface-action-selected)),
|
||||
0 0 0 2px var(--ac-radio-checkbox-bg, var(--a-surface-default)),
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
|
||||
@supports not selector(:focus-visible) {
|
||||
.navds-checkbox__input:indeterminate:focus + .navds-checkbox__label::before,
|
||||
.navds-checkbox__input:checked:focus + .navds-checkbox__label::before {
|
||||
box-shadow:
|
||||
0 0 0 1px var(--ac-radio-checkbox-action, var(--a-surface-action-selected)),
|
||||
0 0 0 2px var(--ac-radio-checkbox-bg, var(--a-surface-default)),
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
}
|
||||
|
||||
.navds-radio__input:checked + .navds-radio__label::before {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ac-radio-checkbox-action, var(--a-surface-action-selected)),
|
||||
@@ -188,40 +158,23 @@
|
||||
background-color: var(--ac-radio-checkbox-action, var(--a-surface-action-selected));
|
||||
}
|
||||
|
||||
.navds-radio__input:checked:focus-visible + .navds-radio__label::before {
|
||||
.navds-radio__input:checked:focus + .navds-radio__label::before {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ac-radio-checkbox-action, var(--a-surface-action-selected)),
|
||||
inset 0 0 0 2px var(--ac-radio-checkbox-bg, var(--a-surface-default)),
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
|
||||
@supports not selector(:focus-visible) {
|
||||
.navds-radio__input:checked:focus + .navds-radio__label::before {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ac-radio-checkbox-action, var(--a-surface-action)),
|
||||
inset 0 0 0 2px var(--ac-radio-checkbox-bg, var(--a-surface-default)),
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
}
|
||||
|
||||
.navds-checkbox__input:hover:not(:disabled) + .navds-checkbox__label,
|
||||
.navds-radio__input:hover:not(:disabled) + .navds-radio__label {
|
||||
color: var(--ac-radio-checkbox-action, var(--a-surface-action-hover));
|
||||
}
|
||||
|
||||
.navds-checkbox__input:hover:not(:disabled):not(:checked):not(:indeterminate):not(:focus-visible)
|
||||
+ .navds-checkbox__label::before,
|
||||
.navds-radio__input:hover:not(:disabled):not(:checked):not(:focus-visible) + .navds-radio__label::before {
|
||||
.navds-checkbox__input:hover:not(:disabled):not(:checked):not(:indeterminate):not(:focus) + .navds-checkbox__label::before,
|
||||
.navds-radio__input:hover:not(:disabled):not(:checked):not(:focus) + .navds-radio__label::before {
|
||||
box-shadow: 0 0 0 2px var(--ac-radio-checkbox-action, var(--a-surface-action-hover));
|
||||
}
|
||||
|
||||
@supports not selector(:focus-visible) {
|
||||
.navds-checkbox__input:hover:not(:disabled):not(:checked):not(:indeterminate):not(:focus) + .navds-checkbox__label::before,
|
||||
.navds-radio__input:hover:not(:disabled):not(:checked):not(:focus) + .navds-radio__label::before {
|
||||
box-shadow: 0 0 0 2px var(--ac-radio-checkbox-action, var(--a-surface-action-hover));
|
||||
}
|
||||
}
|
||||
|
||||
.navds-checkbox__input:hover:not(:disabled):not(:checked):not(:indeterminate) + .navds-checkbox__label::before,
|
||||
.navds-radio__input:hover:not(:disabled):not(:checked) + .navds-radio__label::before {
|
||||
background-color: var(--ac-radio-checkbox-action-hover-bg, var(--a-surface-action-subtle));
|
||||
@@ -235,63 +188,32 @@
|
||||
}
|
||||
|
||||
.navds-checkbox--error
|
||||
> .navds-checkbox__input:focus-visible:not(:hover):not(:disabled):not(:checked):not(:indeterminate)
|
||||
> .navds-checkbox__input:focus:not(:hover):not(:disabled):not(:checked):not(:indeterminate)
|
||||
+ .navds-checkbox__label::before,
|
||||
.navds-radio--error > .navds-radio__input:focus-visible:not(:hover):not(:disabled):not(:checked) + .navds-radio__label::before {
|
||||
.navds-radio--error > .navds-radio__input:focus:not(:hover):not(:disabled):not(:checked) + .navds-radio__label::before {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ac-radio-checkbox-error-border, var(--a-border-danger)),
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
|
||||
@supports not selector(:focus-visible) {
|
||||
.navds-checkbox--error
|
||||
> .navds-checkbox__input:focus:not(:hover):not(:disabled):not(:checked):not(:indeterminate)
|
||||
+ .navds-checkbox__label::before,
|
||||
.navds-radio--error > .navds-radio__input:focus:not(:hover):not(:disabled):not(:checked) + .navds-radio__label::before {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ac-radio-checkbox-error-border, var(--a-border-danger)),
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
}
|
||||
|
||||
.navds-checkbox--error
|
||||
> .navds-checkbox__input:hover:not(:disabled):not(:checked):not(:indeterminate):not(:focus-visible)
|
||||
> .navds-checkbox__input:hover:not(:disabled):not(:checked):not(:indeterminate):not(:focus)
|
||||
+ .navds-checkbox__label::before,
|
||||
.navds-radio--error > .navds-radio__input:hover:not(:disabled):not(:checked):not(:focus-visible) + .navds-radio__label::before {
|
||||
.navds-radio--error > .navds-radio__input:hover:not(:disabled):not(:checked):not(:focus) + .navds-radio__label::before {
|
||||
background-color: var(--ac-radio-checkbox-error-hover-bg, var(--a-surface-danger-subtle));
|
||||
box-shadow: 0 0 0 2px var(--ac-radio-checkbox-error-border, var(--a-border-danger));
|
||||
}
|
||||
|
||||
.navds-checkbox--error
|
||||
> .navds-checkbox__input:focus-visible:hover:not(:disabled):not(:checked):not(:indeterminate)
|
||||
> .navds-checkbox__input:focus:hover:not(:disabled):not(:checked):not(:indeterminate)
|
||||
+ .navds-checkbox__label::before,
|
||||
.navds-radio--error > .navds-radio__input:focus-visible:hover:not(:disabled):not(:checked) + .navds-radio__label::before {
|
||||
.navds-radio--error > .navds-radio__input:focus:hover:not(:disabled):not(:checked) + .navds-radio__label::before {
|
||||
background-color: var(--ac-radio-checkbox-error-hover-bg, var(--a-surface-danger-subtle));
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ac-radio-checkbox-error-border, var(--a-border-danger)),
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
|
||||
@supports not selector(:focus-visible) {
|
||||
.navds-checkbox--error
|
||||
> .navds-checkbox__input:hover:not(:disabled):not(:checked):not(:indeterminate):not(:focus)
|
||||
+ .navds-checkbox__label::before,
|
||||
.navds-radio--error > .navds-radio__input:hover:not(:disabled):not(:checked):not(:focus) + .navds-radio__label::before {
|
||||
background-color: var(--ac-radio-checkbox-error-hover-bg, var(--a-surface-danger-subtle));
|
||||
box-shadow: 0 0 0 2px var(--ac-radio-checkbox-error-border, var(--a-border-danger));
|
||||
}
|
||||
|
||||
.navds-checkbox--error
|
||||
> .navds-checkbox__input:focus:hover:not(:disabled):not(:checked):not(:indeterminate)
|
||||
+ .navds-checkbox__label::before,
|
||||
.navds-radio--error > .navds-radio__input:focus:hover:not(:disabled):not(:checked) + .navds-radio__label::before {
|
||||
background-color: var(--ac-radio-checkbox-error-hover-bg, var(--a-surface-danger-subtle));
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ac-radio-checkbox-error-border, var(--a-border-danger)),
|
||||
0 0 0 4px var(--a-border-focus);
|
||||
}
|
||||
}
|
||||
|
||||
.navds-checkbox--disabled,
|
||||
.navds-radio--disabled {
|
||||
opacity: 0.3;
|
||||
@@ -323,16 +245,16 @@
|
||||
> .navds-checkbox__input:not(:disabled):not(:checked):not(:indeterminate)
|
||||
+ .navds-checkbox__label::before,
|
||||
.navds-checkbox--readonly
|
||||
> .navds-checkbox__input:hover:not(:checked):not(:indeterminate):not(:focus-visible)
|
||||
> .navds-checkbox__input:hover:not(:checked):not(:indeterminate):not(:focus)
|
||||
+ .navds-checkbox__label::before,
|
||||
.navds-radio--readonly > .navds-radio__input:not(:disabled):not(:checked) + .navds-radio__label::before,
|
||||
.navds-radio--readonly > .navds-radio__input:hover:not(:checked):not(:focus-visible) + .navds-radio__label::before {
|
||||
.navds-radio--readonly > .navds-radio__input:hover:not(:checked):not(:focus) + .navds-radio__label::before {
|
||||
background-color: var(--__ac-radio-checkbox-readonly-bg);
|
||||
box-shadow: 0 0 0 2px var(--__ac-radio-checkbox-readonly-border);
|
||||
}
|
||||
|
||||
.navds-checkbox--readonly > .navds-checkbox__input:focus-visible + .navds-checkbox__label::before,
|
||||
.navds-radio--readonly > .navds-radio__input:focus-visible + .navds-radio__label::before {
|
||||
.navds-checkbox--readonly > .navds-checkbox__input:focus + .navds-checkbox__label::before,
|
||||
.navds-radio--readonly > .navds-radio__input:focus + .navds-radio__label::before {
|
||||
--__ac-radio-checkbox-readonly-border: var(--a-border-subtle), var(--a-shadow-focus);
|
||||
}
|
||||
|
||||
@@ -362,8 +284,8 @@
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.navds-checkbox__input:focus-visible + .navds-checkbox__label::before,
|
||||
.navds-radio__input:focus-visible + .navds-radio__label::before {
|
||||
.navds-checkbox__input:focus + .navds-checkbox__label::before,
|
||||
.navds-radio__input:focus + .navds-radio__label::before {
|
||||
outline-color: highlight;
|
||||
}
|
||||
|
||||
@@ -395,7 +317,7 @@
|
||||
background-color: var(--__ac-radio-checkbox-high-contrast-text);
|
||||
}
|
||||
|
||||
.navds-radio__input:checked:focus-visible + .navds-radio__label::before {
|
||||
.navds-radio__input:checked:focus + .navds-radio__label::before {
|
||||
border: 1px solid var(--__ac-radio-checkbox-high-contrast-bg);
|
||||
outline: 2px solid highlight;
|
||||
outline-offset: 2px;
|
||||
@@ -406,7 +328,7 @@
|
||||
color: highlight;
|
||||
}
|
||||
|
||||
.navds-checkbox__input:focus-visible + .navds-checkbox__label::before {
|
||||
.navds-checkbox__input:focus + .navds-checkbox__label::before {
|
||||
outline: 2px solid var(--__ac-radio-checkbox-high-contrast-highlight);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
@import "panel.css";
|
||||
@import "link-panel.css";
|
||||
@import "read-more.css";
|
||||
@import "progress-bar.css";
|
||||
@import "skeleton.css";
|
||||
@import "stepper.css";
|
||||
@import "table.css";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-css",
|
||||
"version": "6.4.1",
|
||||
"version": "6.8.0",
|
||||
"description": "CSS for NAV Designsystem",
|
||||
"author": "Aksel | NAV designsystem team",
|
||||
"keywords": [
|
||||
@@ -27,7 +27,7 @@
|
||||
"css:get-version": "node config/get-version.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@navikt/ds-tokens": "^6.4.1",
|
||||
"@navikt/ds-tokens": "^6.8.0",
|
||||
"cssnano": "6.0.0",
|
||||
"fast-glob": "3.2.11",
|
||||
"lodash": "4.17.21",
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
.navds-hgrid {
|
||||
--__ac-hgrid-columns-xs: initial;
|
||||
--__ac-hgrid-columns-sm: initial;
|
||||
--__ac-hgrid-columns-md: initial;
|
||||
--__ac-hgrid-columns-lg: initial;
|
||||
--__ac-hgrid-columns-xl: initial;
|
||||
--__ac-hgrid-columns-2xl: initial;
|
||||
--__ac-hgrid-columns-sm: var(--__ac-hgrid-columns-xs);
|
||||
--__ac-hgrid-columns-md: var(--__ac-hgrid-columns-sm);
|
||||
--__ac-hgrid-columns-lg: var(--__ac-hgrid-columns-md);
|
||||
--__ac-hgrid-columns-xl: var(--__ac-hgrid-columns-lg);
|
||||
--__ac-hgrid-columns-2xl: var(--__ac-hgrid-columns-xl);
|
||||
--__ac-hgrid-columns: var(--__ac-hgrid-columns-xs);
|
||||
--__ac-hgrid-gap-xs: initial;
|
||||
--__ac-hgrid-gap-sm: initial;
|
||||
--__ac-hgrid-gap-md: initial;
|
||||
--__ac-hgrid-gap-lg: initial;
|
||||
--__ac-hgrid-gap-xl: initial;
|
||||
--__ac-hgrid-gap-2xl: initial;
|
||||
--__ac-hgrid-gap-sm: var(--__ac-hgrid-gap-xs);
|
||||
--__ac-hgrid-gap-md: var(--__ac-hgrid-gap-sm);
|
||||
--__ac-hgrid-gap-lg: var(--__ac-hgrid-gap-md);
|
||||
--__ac-hgrid-gap-xl: var(--__ac-hgrid-gap-lg);
|
||||
--__ac-hgrid-gap-2xl: var(--__ac-hgrid-gap-xl);
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-xs);
|
||||
--__ac-hgrid-align: initial;
|
||||
|
||||
@@ -23,56 +23,35 @@
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.navds-hgrid {
|
||||
--__ac-hgrid-columns: var(--__ac-hgrid-columns-sm, var(--__ac-hgrid-columns-xs));
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-sm, var(--__ac-hgrid-gap-xs));
|
||||
--__ac-hgrid-columns: var(--__ac-hgrid-columns-sm);
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.navds-hgrid {
|
||||
--__ac-hgrid-columns: var(--__ac-hgrid-columns-md, var(--__ac-hgrid-columns-sm, var(--__ac-hgrid-columns-xs)));
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-md, var(--__ac-hgrid-gap-sm, var(--__ac-hgrid-gap-xs)));
|
||||
--__ac-hgrid-columns: var(--__ac-hgrid-columns-md);
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-md);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.navds-hgrid {
|
||||
--__ac-hgrid-columns: var(
|
||||
--__ac-hgrid-columns-lg,
|
||||
var(--__ac-hgrid-columns-md, var(--__ac-hgrid-columns-sm, var(--__ac-hgrid-columns-xs)))
|
||||
);
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-lg, var(--__ac-hgrid-gap-md, var(--__ac-hgrid-gap-sm, var(--__ac-hgrid-gap-xs))));
|
||||
--__ac-hgrid-columns: var(--__ac-hgrid-columns-lg);
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-lg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.navds-hgrid {
|
||||
--__ac-hgrid-columns: var(
|
||||
--__ac-hgrid-columns-xl,
|
||||
var(--__ac-hgrid-columns-lg, var(--__ac-hgrid-columns-md, var(--__ac-hgrid-columns-sm, var(--__ac-hgrid-columns-xs))))
|
||||
);
|
||||
--__ac-hgrid-gap: var(
|
||||
--__ac-hgrid-gap-xl,
|
||||
var(--__ac-hgrid-gap-lg, var(--__ac-hgrid-gap-md, var(--__ac-hgrid-gap-sm, var(--__ac-hgrid-gap-xs))))
|
||||
);
|
||||
--__ac-hgrid-columns: var(--__ac-hgrid-columns-xl);
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-xl);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
.navds-hgrid {
|
||||
--__ac-hgrid-columns: var(
|
||||
--__ac-hgrid-columns-2xl,
|
||||
var(
|
||||
--__ac-hgrid-columns-xl,
|
||||
var(--__ac-hgrid-columns-lg, var(--__ac-hgrid-columns-md, var(--__ac-hgrid-columns-sm, var(--__ac-hgrid-columns-xs))))
|
||||
)
|
||||
);
|
||||
--__ac-hgrid-gap: var(
|
||||
--__ac-hgrid-gap-2xl,
|
||||
var(
|
||||
--__ac-hgrid-gap-xl,
|
||||
var(--__ac-hgrid-gap-lg, var(--__ac-hgrid-gap-md, var(--__ac-hgrid-gap-sm, var(--__ac-hgrid-gap-xs))))
|
||||
)
|
||||
);
|
||||
--__ac-hgrid-columns: var(--__ac-hgrid-columns-2xl);
|
||||
--__ac-hgrid-gap: var(--__ac-hgrid-gap-2xl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
.navds-progress-bar {
|
||||
background: var(--a-surface-neutral-subtle);
|
||||
position: relative;
|
||||
border-radius: var(--a-border-radius-full);
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
inset 0 1px 3px 0 rgba(0 0 0 / 0.15),
|
||||
inset 0 0 1px 0 rgba(0 0 0 / 0.2);
|
||||
}
|
||||
|
||||
.navds-progress-bar--small {
|
||||
height: 12px;
|
||||
min-width: 12px;
|
||||
}
|
||||
|
||||
.navds-progress-bar--medium {
|
||||
height: 16px;
|
||||
min-width: 16px;
|
||||
}
|
||||
|
||||
.navds-progress-bar--large {
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.navds-progress-bar__foreground {
|
||||
--__ac-progress-bar-translate: initial;
|
||||
|
||||
transform: translateX(var(--__ac-progress-bar-translate));
|
||||
background: var(--ac-progress-bar-fg, var(--a-surface-alt-3));
|
||||
transform-origin: left;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
border-radius: inherit;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.navds-progress-bar__foreground--indeterminate {
|
||||
--__ac-progress-bar-simulated: initial;
|
||||
|
||||
animation-name: navds-progress-bar-indeterminate-grow, navds-progress-bar-indeterminate;
|
||||
animation-timing-function: ease-in-out, ease-in-out;
|
||||
animation-duration: var(--__ac-progress-bar-simulated), 2500ms;
|
||||
animation-fill-mode: forwards, none;
|
||||
animation-iteration-count: 1, infinite;
|
||||
animation-delay: 0s, var(--__ac-progress-bar-simulated);
|
||||
}
|
||||
|
||||
/* navds-progress-bar-indeterminate wave animation */
|
||||
@keyframes navds-progress-bar-indeterminate {
|
||||
0% {
|
||||
left: -50%;
|
||||
width: 50%;
|
||||
transform: translateX(0%);
|
||||
}
|
||||
|
||||
50% {
|
||||
animation-direction: reverse;
|
||||
left: 100%;
|
||||
transform: translateX(0%);
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
100% {
|
||||
left: -50%;
|
||||
transform: translateX(0%);
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes navds-progress-bar-indeterminate-grow {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translateX(-80%);
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translateX(-40%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(-20%);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translateX(-10%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(-10%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.navds-progress-bar__foreground {
|
||||
background: Highlight;
|
||||
}
|
||||
|
||||
.navds-progress-bar {
|
||||
outline: 1px solid transparent;
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,8 @@
|
||||
}
|
||||
|
||||
.navds-read-more__button:hover > .navds-read-more__expand-icon {
|
||||
transform: translateY(1px);
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
.navds-read-more--open > .navds-read-more__button > .navds-read-more__expand-icon {
|
||||
@@ -87,5 +88,5 @@
|
||||
}
|
||||
|
||||
.navds-read-more--open > .navds-read-more__button:hover > .navds-read-more__expand-icon {
|
||||
transform: translateY(-1px) rotate(-180deg);
|
||||
top: -1px;
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
.navds-tabs__tablist {
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
width: 100%;
|
||||
scroll-behavior: smooth;
|
||||
-ms-overflow-style: none; /* for Internet Explorer, Edge */
|
||||
scrollbar-width: none; /* for Firefox */
|
||||
@@ -83,6 +83,10 @@
|
||||
gap: var(--a-spacing-1);
|
||||
}
|
||||
|
||||
.navds-tabs__tab-inner > * {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.navds-tabs__tab-inner svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -117,6 +121,10 @@
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.navds-tabs__tab--fill {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.navds-tabs__tabpanel:focus-visible {
|
||||
outline: 2px solid transparent;
|
||||
box-shadow: inset 0 0 0 2px var(--a-border-focus);
|
||||
|
||||
@@ -245,7 +245,7 @@
|
||||
.navds-timeline__pin-wrapper::before {
|
||||
content: "";
|
||||
top: var(--navdsc-timeline-pin-size);
|
||||
height: calc(88% - var(--navdsc-timeline-pin-size));
|
||||
height: calc(100% - var(--navdsc-timeline-pin-size) * 2);
|
||||
width: 1px;
|
||||
margin: 0 auto;
|
||||
background: var(--a-surface-inverted);
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
gap: var(--a-spacing-2);
|
||||
}
|
||||
|
||||
.navds-toggle-group__wrapper--fill {
|
||||
justify-items: initial;
|
||||
}
|
||||
|
||||
.navds-toggle-group {
|
||||
border-radius: var(--a-border-radius-medium);
|
||||
background-color: var(--ac-toggle-group-bg, var(--a-surface-transparent));
|
||||
|
||||
@@ -217,6 +217,9 @@
|
||||
"--ac-popover-bg": "--a-surface-default",
|
||||
"--ac-popover-border": "--a-border-default"
|
||||
},
|
||||
"progress-bar": {
|
||||
"--ac-progress-bar-fg": "--a-surface-alt-3"
|
||||
},
|
||||
"readmore": {
|
||||
"--ac-read-more-text": "--a-text-action",
|
||||
"--ac-read-more-hover-bg": "--a-surface-hover",
|
||||
|
||||
@@ -1,5 +1,93 @@
|
||||
# @navikt/ds-react
|
||||
|
||||
## 6.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- :sparkles: Ny komponent: FormProgress ([#2855](https://github.com/navikt/aksel/pull/2855))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stepper.Step: Set aria-current to "step" instead of true ([#2920](https://github.com/navikt/aksel/pull/2920))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.8.0
|
||||
- @navikt/aksel-icons@6.8.0
|
||||
|
||||
## 6.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Progressbar: Tweak API, examples, stories and css ([#2892](https://github.com/navikt/aksel/pull/2892))
|
||||
|
||||
- Combobox: Remove 'Ingen søketreff' when custom options allowed ([#2895](https://github.com/navikt/aksel/pull/2895))
|
||||
|
||||
- Datepicker, MonthPicker: Rekkefølgen på årstall i Select er reversert slik at siste år er øverst. Dette er endret for å være bedre tilpasset ekspertsystemer der de mest relevante årene ble vist lengst unna musepeker ved klikk. ([#2882](https://github.com/navikt/aksel/pull/2882))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.7.1
|
||||
- @navikt/aksel-icons@6.7.1
|
||||
|
||||
## 6.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- :sparkles: Ny komponent: ProgressBar ([#2845](https://github.com/navikt/aksel/pull/2845))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.7.0
|
||||
- @navikt/aksel-icons@6.7.0
|
||||
|
||||
## 6.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Spacer: Er nå `span` (tidligere `div`). ([#2874](https://github.com/navikt/aksel/pull/2874))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.6.1
|
||||
- @navikt/aksel-icons@6.6.1
|
||||
|
||||
## 6.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- :sparkles: Ny komponent FormSummary ([#2802](https://github.com/navikt/aksel/pull/2802))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Combobox: Prevents "Enter" while Combobox is focused from submitting form. ([#2861](https://github.com/navikt/aksel/pull/2861))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.6.0
|
||||
- @navikt/aksel-icons@6.6.0
|
||||
|
||||
## 6.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Tabs: Ny prop `lazy` som rendrer innhold i TabPanel selv når panel er skjult (har fortsatt display:none) ([#2621](https://github.com/navikt/aksel/pull/2621))
|
||||
|
||||
- ToggleGroup: Erstattet bruk av dependency `@radix-ui/react-toggle-group` med egen implementasjon. ([#2620](https://github.com/navikt/aksel/pull/2620))
|
||||
|
||||
- Tabs: La til ny prop `fill` som lar Tabs.Tab-elementer strekke seg over tilgjengelig bredde. ([#2621](https://github.com/navikt/aksel/pull/2621))
|
||||
|
||||
- Tabs: Erstattet bruk av dependency `@radix-ui/react-tabs` med egen implementasjon. ([#2621](https://github.com/navikt/aksel/pull/2621))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- DatePicker/MonthPicker: Valgte datoer får nå `aria-pressed` for å bedre indikere valg for skjermleser. ([#2838](https://github.com/navikt/aksel/pull/2838))
|
||||
|
||||
- DatePicker/MonthPicker: `required`-prop stoppet ikke de-select av allerede valgt dato. ([#2838](https://github.com/navikt/aksel/pull/2838))
|
||||
|
||||
- ToggleGroup: La til ny prop `fill` som strekker ToggleGroup til å ta opp all tilgjengelig bredde. ([#2620](https://github.com/navikt/aksel/pull/2620))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.5.0
|
||||
- @navikt/aksel-icons@6.5.0
|
||||
|
||||
## 6.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-react",
|
||||
"version": "6.4.1",
|
||||
"version": "6.8.0",
|
||||
"description": "React components from the Norwegian Labour and Welfare Administration.",
|
||||
"author": "Aksel, a team part of the Norwegian Labour and Welfare Administration.",
|
||||
"license": "MIT",
|
||||
@@ -24,7 +24,8 @@
|
||||
"/cjs",
|
||||
"/esm",
|
||||
"/src",
|
||||
"!**/*.tsbuildinfo"
|
||||
"!**/*.tsbuildinfo",
|
||||
"!**/*.stories.*"
|
||||
],
|
||||
"main": "./cjs/index.js",
|
||||
"module": "./esm/index.js",
|
||||
@@ -309,6 +310,16 @@
|
||||
"default": "./cjs/popover/index.js"
|
||||
}
|
||||
},
|
||||
"./ProgressBar": {
|
||||
"import": {
|
||||
"types": "./esm/progress-bar/index.d.ts",
|
||||
"default": "./esm/progress-bar/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./cjs/progress-bar/index.d.ts",
|
||||
"default": "./cjs/progress-bar/index.js"
|
||||
}
|
||||
},
|
||||
"./Provider": {
|
||||
"import": {
|
||||
"types": "./esm/provider/index.d.ts",
|
||||
@@ -549,6 +560,26 @@
|
||||
"default": "./cjs/form/file-upload/index.js"
|
||||
}
|
||||
},
|
||||
"./FormSummary": {
|
||||
"import": {
|
||||
"types": "./esm/form/form-summary/index.d.ts",
|
||||
"default": "./esm/form/form-summary/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./cjs/form/form-summary/index.d.ts",
|
||||
"default": "./cjs/form/form-summary/index.js"
|
||||
}
|
||||
},
|
||||
"./FormProgress": {
|
||||
"import": {
|
||||
"types": "./esm/form/form-progress/index.d.ts",
|
||||
"default": "./esm/form/form-progress/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./cjs/form/form-progress/index.d.ts",
|
||||
"default": "./cjs/form/form-progress/index.js"
|
||||
}
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -562,18 +593,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "0.25.4",
|
||||
"@navikt/aksel-icons": "^6.4.1",
|
||||
"@navikt/ds-tokens": "^6.4.1",
|
||||
"@radix-ui/react-tabs": "1.0.0",
|
||||
"@radix-ui/react-toggle-group": "1.0.0",
|
||||
"@floating-ui/react-dom": "^2.0.9",
|
||||
"@navikt/aksel-icons": "^6.8.0",
|
||||
"@navikt/ds-tokens": "^6.8.0",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.0.0",
|
||||
"react-day-picker": "8.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "8.13.0",
|
||||
"@testing-library/dom": "9.3.4",
|
||||
"@testing-library/jest-dom": "^5.16.0",
|
||||
"@testing-library/react": "^13.3.0",
|
||||
"@testing-library/react": "^15.0.7",
|
||||
"@testing-library/user-event": "^14.2.0",
|
||||
"@types/faker": "5.5.8",
|
||||
"concurrently": "7.2.1",
|
||||
|
||||
@@ -183,3 +183,10 @@ export const Chromatic: Story = {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
};
|
||||
|
||||
export const ChromaticForcedColors: Story = {
|
||||
render: Chromatic.render,
|
||||
parameters: {
|
||||
chromatic: { disable: false, forcedColors: "active" },
|
||||
},
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import React, { HTMLAttributes, forwardRef } from "react";
|
||||
import { BodyLong } from "../typography";
|
||||
import Bubble from "./Bubble";
|
||||
|
||||
export const VARIANTS = ["subtle", "info", "neutral"] as const;
|
||||
export const POSITIONS = ["left", "right"] as const;
|
||||
export const SIZES = ["medium", "small"] as const;
|
||||
|
||||
@@ -28,7 +29,7 @@ export interface ChatProps extends HTMLAttributes<HTMLDivElement> {
|
||||
* Avoid using the same background as the surface behind Chat.
|
||||
* @default "neutral"
|
||||
*/
|
||||
variant?: "subtle" | "info" | "neutral";
|
||||
variant?: (typeof VARIANTS)[number];
|
||||
/**
|
||||
* Positions avatar and bubbles.
|
||||
* @default "left"
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { VStack } from "../layout/stack";
|
||||
import Chat, { POSITIONS, SIZES } from "./Chat";
|
||||
import Chat, { POSITIONS, SIZES, VARIANTS } from "./Chat";
|
||||
|
||||
export default {
|
||||
title: "ds-react/Chat",
|
||||
component: Chat,
|
||||
argTypes: {
|
||||
position: {
|
||||
control: { type: "radio" },
|
||||
options: POSITIONS,
|
||||
},
|
||||
toptextPosition: {
|
||||
control: { type: "radio" },
|
||||
options: POSITIONS,
|
||||
},
|
||||
size: {
|
||||
control: { type: "radio" },
|
||||
options: SIZES,
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
@@ -48,43 +34,49 @@ export const Controls: Story = {
|
||||
timestamp: "01.01.21 14:00",
|
||||
avatar: "ON",
|
||||
},
|
||||
argTypes: {
|
||||
position: {
|
||||
control: { type: "radio" },
|
||||
options: [...POSITIONS], // TypeScript does not like using readonly typed arrays here
|
||||
},
|
||||
toptextPosition: {
|
||||
control: { type: "radio" },
|
||||
options: [...POSITIONS],
|
||||
},
|
||||
size: {
|
||||
control: { type: "radio" },
|
||||
options: [...SIZES],
|
||||
},
|
||||
variant: {
|
||||
control: { type: "radio" },
|
||||
options: [...VARIANTS],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Size: Story = {
|
||||
render: () => (
|
||||
<VStack gap="4">
|
||||
<h3>Medium</h3>
|
||||
<Chat
|
||||
avatar="ON"
|
||||
name="Ola Normann"
|
||||
timestamp="01.01.21 14:00"
|
||||
size="medium"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Aute minim nisi sunt mollit duis sunt nulla minim non proident.
|
||||
</Chat.Bubble>
|
||||
<Chat.Bubble>Tempor fugiat amet eu sint in in ullamco.</Chat.Bubble>
|
||||
<Chat.Bubble>
|
||||
Adipisicing laborum est eu laborum est sit in commodo enim sint
|
||||
laboris labore nisi ut.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
<h3>Small</h3>
|
||||
<Chat
|
||||
avatar="ON"
|
||||
name="Ola Normann"
|
||||
timestamp="01.01.21 14:00"
|
||||
size="small"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Aute minim nisi sunt mollit duis sunt nulla minim non proident.
|
||||
</Chat.Bubble>
|
||||
<Chat.Bubble>Tempor fugiat amet eu sint in in ullamco.</Chat.Bubble>
|
||||
<Chat.Bubble>
|
||||
Adipisicing laborum est eu laborum est sit in commodo enim sint
|
||||
laboris labore nisi ut.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
{SIZES.map((size) => (
|
||||
<>
|
||||
<h3>{size}</h3>
|
||||
<Chat
|
||||
avatar="ON"
|
||||
name="Ola Normann"
|
||||
timestamp="01.01.21 14:00"
|
||||
size={size}
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Aute minim nisi sunt mollit duis sunt nulla minim non proident.
|
||||
</Chat.Bubble>
|
||||
<Chat.Bubble>Tempor fugiat amet eu sint in in ullamco.</Chat.Bubble>
|
||||
<Chat.Bubble>
|
||||
Adipisicing laborum est eu laborum est sit in commodo enim sint
|
||||
laboris labore nisi ut.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
</>
|
||||
))}
|
||||
</VStack>
|
||||
),
|
||||
};
|
||||
@@ -92,35 +84,21 @@ export const Size: Story = {
|
||||
export const Variants: Story = {
|
||||
render: () => (
|
||||
<VStack gap="4">
|
||||
<h3>Info</h3>
|
||||
<Chat avatar="NAV" name="NAV" timestamp="01.01.21 14:00" variant="info">
|
||||
<Chat.Bubble>
|
||||
Aute minim nisi sunt mollit duis sunt nulla minim non proident.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
<h3>Neutral</h3>
|
||||
<Chat
|
||||
avatar="KN"
|
||||
name="Kari Nordmann"
|
||||
timestamp="01.01.21 14:03"
|
||||
variant="neutral"
|
||||
position="right"
|
||||
>
|
||||
<Chat.Bubble>Tempor fugiat amet eu sint in in ullamco.</Chat.Bubble>
|
||||
</Chat>
|
||||
<h3>Subtle</h3>
|
||||
<Chat
|
||||
avatar="ON"
|
||||
name="Ola Nordmann"
|
||||
timestamp="01.01.21 14:07"
|
||||
variant="subtle"
|
||||
position="right"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Adipisicing laborum est eu laborum est sit in commodo enim sint
|
||||
laboris labore nisi ut.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
{VARIANTS.map((variant) => (
|
||||
<>
|
||||
<h3>{variant}</h3>
|
||||
<Chat
|
||||
avatar="NAV"
|
||||
name="NAV"
|
||||
timestamp="01.01.21 14:00"
|
||||
variant={variant}
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Aute minim nisi sunt mollit duis sunt nulla minim non proident.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
</>
|
||||
))}
|
||||
</VStack>
|
||||
),
|
||||
};
|
||||
@@ -131,8 +109,7 @@ export const Position: Story = {
|
||||
<h3>Default</h3>
|
||||
<Chat avatar="ON" name="Ola Normann" timestamp="01.01.21 14:00">
|
||||
<Chat.Bubble>
|
||||
Adipisicing laborum est eu laborum est sit in commodo enim sint
|
||||
laboris labore nisi ut.
|
||||
Reprehenderit pariatur officia exercitation laboris.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
<h3>Right</h3>
|
||||
@@ -159,10 +136,7 @@ export const Position: Story = {
|
||||
timestamp="01.01.21 15:00"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Exercitation irure Lorem cupidatat culpa anim cillum esse ullamco qui
|
||||
dolore laborum et Lorem. Labore sunt duis id Lorem voluptate commodo
|
||||
ea esse. Dolore esse aliqua proident ea ad commodo ut dolore voluptate
|
||||
labore sunt aute.
|
||||
Reprehenderit pariatur officia exercitation laboris.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
</VStack>
|
||||
@@ -171,8 +145,8 @@ export const Position: Story = {
|
||||
|
||||
export const ToptextPosition: Story = {
|
||||
render: () => (
|
||||
<VStack gap="4">
|
||||
<h3>Right</h3>
|
||||
<VStack gap="4" style={{ width: 500 }}>
|
||||
<h3>All right</h3>
|
||||
<Chat
|
||||
avatar="ON"
|
||||
name="Ola Normann"
|
||||
@@ -180,20 +154,31 @@ export const ToptextPosition: Story = {
|
||||
toptextPosition="right"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Adipisicing laborum est eu laborum est sit in commodo enim sint
|
||||
laboris labore nisi ut.
|
||||
Reprehenderit pariatur officia exercitation laboris.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
<h3>Left</h3>
|
||||
<Chat avatar="ON" name="Ola Normann" timestamp="01.01.21 15:00">
|
||||
<Chat.Bubble toptextPosition="left">
|
||||
Exercitation irure Lorem cupidatat culpa anim cillum esse ullamco qui
|
||||
dolore laborum et Lorem. Labore sunt duis id Lorem voluptate commodo
|
||||
ea esse. Dolore esse aliqua proident ea ad commodo ut dolore voluptate
|
||||
labore sunt aute.
|
||||
<h3>Single bubble right</h3>
|
||||
<Chat avatar="ON" name="Ola Normann" timestamp="01.01.21 14:00">
|
||||
<Chat.Bubble>
|
||||
Reprehenderit pariatur officia exercitation laboris.
|
||||
</Chat.Bubble>
|
||||
<Chat.Bubble timestamp="14:02" toptextPosition="right">
|
||||
Enim velit deserunt do quis.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
<h3>Left</h3>
|
||||
|
||||
<h3>All left</h3>
|
||||
<Chat
|
||||
avatar="ON"
|
||||
name="Ola Normann"
|
||||
timestamp="01.01.21 15:00"
|
||||
toptextPosition="left"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Reprehenderit pariatur officia exercitation laboris.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
<h3>All left, position right</h3>
|
||||
<Chat
|
||||
name="Kari Høyli"
|
||||
timestamp="01.01.21 14:32"
|
||||
@@ -203,25 +188,6 @@ export const ToptextPosition: Story = {
|
||||
<Chat.Bubble>
|
||||
Reprehenderit pariatur officia exercitation laboris.
|
||||
</Chat.Bubble>
|
||||
<Chat.Bubble>
|
||||
Enim velit deserunt do quis. Eu fugiat magna esse dolore ad sunt sit
|
||||
est dolore incididunt. Occaecat cupidatat magna.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
<h3>Right</h3>
|
||||
<Chat
|
||||
name="Kari Høyli"
|
||||
timestamp="01.01.21 14:32"
|
||||
position="right"
|
||||
toptextPosition="right"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Reprehenderit pariatur officia exercitation laboris.
|
||||
</Chat.Bubble>
|
||||
<Chat.Bubble>
|
||||
Enim velit deserunt do quis. Eu fugiat magna esse dolore ad sunt sit
|
||||
est dolore incididunt. Occaecat cupidatat magna.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
</VStack>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createContext } from "../util/create-context";
|
||||
import { CollapsibleBaseProps } from "./Collapsible.types";
|
||||
|
||||
export type CollapsibleContextProps = Pick<CollapsibleBaseProps, "open"> & {
|
||||
/**
|
||||
* Allows connecting trigger to content
|
||||
*/
|
||||
contentId: string;
|
||||
/**
|
||||
* Allows connecting content to trigger
|
||||
*/
|
||||
triggerId: string;
|
||||
/**
|
||||
* Callback for toggling open state
|
||||
*/
|
||||
onOpenToggle: () => void;
|
||||
/**
|
||||
* Allows always rendering children in content when closed
|
||||
*/
|
||||
lazy: boolean;
|
||||
/**
|
||||
* Utility for getting state as string. Usefull for data-attributes.
|
||||
*/
|
||||
state: "open" | "closed";
|
||||
};
|
||||
|
||||
export const [CollapsibleContextProvider, useCollapsibleContext] =
|
||||
createContext<CollapsibleContextProps>({
|
||||
name: "CollapsibleContext",
|
||||
errorMessage:
|
||||
"<Collapsible.Trigger> and <Collapsible.Content> must be used within a <Collapsible>",
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { Meta, StoryFn, StoryObj } from "@storybook/react";
|
||||
import React, { useState } from "react";
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "@navikt/aksel-icons";
|
||||
import { Button } from "../button";
|
||||
import { Box } from "../layout/box";
|
||||
import { HStack, VStack } from "../layout/stack";
|
||||
import { Collapsible } from "./Collapsible";
|
||||
|
||||
const meta: Meta<typeof Collapsible> = {
|
||||
title: "Utilities/Collapsible",
|
||||
component: Collapsible,
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Collapsible>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (...props) => (
|
||||
<Collapsible {...props}>
|
||||
<Collapsible.Trigger>Trigger</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Recusandae
|
||||
corporis maxime aliquam, voluptates nobis numquam, non odit optio
|
||||
architecto iure laborum possimus! Quibusdam sit ullam, consequatur sunt
|
||||
tempore optio aliquid!
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
),
|
||||
args: {
|
||||
open: false,
|
||||
defaultOpen: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const InContext: StoryFn<typeof Collapsible> = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<HStack justify="space-between" gap="4" align="center">
|
||||
Steg 1 av 8{" "}
|
||||
<Collapsible.Trigger asChild>
|
||||
<Button
|
||||
size="small"
|
||||
variant="tertiary"
|
||||
iconPosition="right"
|
||||
icon={
|
||||
open ? (
|
||||
<ChevronUpIcon aria-hidden />
|
||||
) : (
|
||||
<ChevronDownIcon aria-hidden />
|
||||
)
|
||||
}
|
||||
>
|
||||
Vis steg
|
||||
</Button>
|
||||
</Collapsible.Trigger>
|
||||
</HStack>
|
||||
<Collapsible.Content>
|
||||
<p>Lorem ipsum dolor sit</p>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export const Animated: StoryFn<typeof Collapsible> = () => (
|
||||
<Collapsible lazy>
|
||||
<Collapsible.Trigger asChild>
|
||||
<Button size="small" variant="secondary">
|
||||
Animer open/lukk
|
||||
</Button>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content className="collapsible">
|
||||
<div className="collapsibleContent">
|
||||
<Box padding="8" background="surface-alt-3-subtle">
|
||||
<div>lorem ipsum</div>
|
||||
</Box>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
);
|
||||
|
||||
Animated.decorators = [
|
||||
(Story) => (
|
||||
<div style={{ height: 300 }}>
|
||||
<style>{`
|
||||
.collapsible {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
overflow: hidden;
|
||||
transition: grid-template-rows 1s ease-in-out;
|
||||
}
|
||||
|
||||
.collapsible[data-state="closed"] {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.collapsible[data-state="open"] {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.collapsibleContent {
|
||||
min-height: 0;
|
||||
transition: visibility 1s;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.collapsible[data-state="open"] .collapsibleContent {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
`}</style>
|
||||
{Story()}
|
||||
</div>
|
||||
),
|
||||
];
|
||||
|
||||
export const Lazy: StoryFn<typeof Collapsible> = () => (
|
||||
<Collapsible lazy>
|
||||
<Collapsible.Trigger>Trigger</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Recusandae
|
||||
corporis maxime aliquam, voluptates nobis numquam, non odit optio
|
||||
architecto iure laborum possimus! Quibusdam sit ullam, consequatur sunt
|
||||
tempore optio aliquid!
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
);
|
||||
|
||||
export const AsChild: StoryFn<typeof Collapsible> = () => (
|
||||
<Collapsible>
|
||||
<Collapsible.Trigger asChild>
|
||||
<Button>Button</Button>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content asChild>
|
||||
<Box padding="4" background="surface-alt-3-subtle">
|
||||
<div>lorem ipsum</div>
|
||||
</Box>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
);
|
||||
|
||||
export const DefaultOpen: StoryFn<typeof Collapsible> = () => (
|
||||
<Collapsible defaultOpen>
|
||||
<Collapsible.Trigger asChild>
|
||||
<Button>Button</Button>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content asChild>
|
||||
<Box padding="4" background="surface-alt-3-subtle">
|
||||
<div>lorem ipsum</div>
|
||||
</Box>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
);
|
||||
|
||||
export const ControlledOpen: StoryFn<typeof Collapsible> = () => (
|
||||
<Collapsible open>
|
||||
<Collapsible.Trigger asChild>
|
||||
<Button>Button</Button>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content asChild>
|
||||
<Box padding="4" background="surface-alt-3-subtle">
|
||||
<div>lorem ipsum</div>
|
||||
</Box>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
);
|
||||
|
||||
export const Disabled: StoryFn<typeof Collapsible> = ({ open = false }) => (
|
||||
<Collapsible open={open}>
|
||||
<Collapsible.Trigger asChild disabled>
|
||||
<Button>Button</Button>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content asChild>
|
||||
<Box padding="4" background="surface-alt-3-subtle">
|
||||
<div>lorem ipsum</div>
|
||||
</Box>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
);
|
||||
|
||||
export const Chromatic: Story = {
|
||||
render: () => (
|
||||
<VStack gap="8">
|
||||
<div>
|
||||
<h2>In context</h2>
|
||||
<InContext />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Animated</h2>
|
||||
<Animated />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Lazy</h2>
|
||||
<Lazy />
|
||||
</div>
|
||||
<div>
|
||||
<h2>AsChild</h2>
|
||||
<AsChild />
|
||||
</div>
|
||||
<div>
|
||||
<h2>DefaultOpen</h2>
|
||||
<DefaultOpen />
|
||||
</div>
|
||||
<div>
|
||||
<h2>ControlledOpen</h2>
|
||||
<ControlledOpen />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Disabled</h2>
|
||||
<Disabled />
|
||||
<Disabled open />
|
||||
</div>
|
||||
</VStack>
|
||||
),
|
||||
parameters: {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { forwardRef, useCallback } from "react";
|
||||
import { useControllableState, useId } from "../util/hooks";
|
||||
import { CollapsibleContextProvider } from "./Collapsible.context";
|
||||
import { CollapsibleBaseProps } from "./Collapsible.types";
|
||||
import CollapsibleContent from "./parts/Collapsible.Content";
|
||||
import CollapsibleTrigger from "./parts/Collapsible.Trigger";
|
||||
|
||||
export interface CollapsibleProps
|
||||
extends CollapsibleBaseProps,
|
||||
React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
interface CollapsibleComponent
|
||||
extends React.ForwardRefExoticComponent<
|
||||
CollapsibleProps & React.RefAttributes<HTMLDivElement>
|
||||
> {
|
||||
/**
|
||||
* @see 🏷️ {@link CollapsibleTriggerProps}
|
||||
*/
|
||||
Trigger: typeof CollapsibleTrigger;
|
||||
/**
|
||||
* @see 🏷️ {@link CollapsibleContentProps}
|
||||
*/
|
||||
Content: typeof CollapsibleContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible is a component that allows you to toggle visibility of content.
|
||||
*
|
||||
* @example
|
||||
* ```jsx
|
||||
* <Collapsible>
|
||||
* <Collapsible.Trigger>Trigger</Collapsible.Trigger>
|
||||
* <Collapsible.Content>
|
||||
* Lorem ipsum dolor sit amet consectetur adipisicing elit. Recusandae
|
||||
* corporis maxime aliquam, voluptates nobis numquam, non odit optio
|
||||
* architecto iure laborum possimus! Quibusdam sit ullam, consequatur sunt
|
||||
* tempore optio aliquid!
|
||||
* </Collapsible.Content>
|
||||
* </Collapsible>
|
||||
* ```
|
||||
*
|
||||
* @example With asChild
|
||||
* ```jsx
|
||||
* <Collapsible>
|
||||
* <Collapsible.Trigger asChild>
|
||||
* <Button>Button</Button>
|
||||
* </Collapsible.Trigger>
|
||||
* <Collapsible.Content asChild>
|
||||
* <Box padding="4" background="surface-alt-3-subtle">
|
||||
* <div>lorem ipsum</div>
|
||||
* </Box>
|
||||
* </Collapsible.Content>
|
||||
* </Collapsible>
|
||||
* ```
|
||||
*/
|
||||
export const Collapsible = forwardRef<HTMLDivElement, CollapsibleProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
lazy = false,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [_open, setOpen] = useControllableState({
|
||||
value: open,
|
||||
defaultValue: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
});
|
||||
|
||||
const internalId = useId();
|
||||
const state = _open ? "open" : "closed";
|
||||
|
||||
return (
|
||||
<CollapsibleContextProvider
|
||||
open={_open}
|
||||
onOpenToggle={useCallback(
|
||||
() => setOpen((prevOpen) => !prevOpen),
|
||||
[setOpen],
|
||||
)}
|
||||
contentId={`collapsible-content-${internalId}`}
|
||||
triggerId={`collapsible-trigger-${internalId}`}
|
||||
lazy={lazy}
|
||||
state={state}
|
||||
>
|
||||
<div ref={ref} data-state={state} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContextProvider>
|
||||
);
|
||||
},
|
||||
) as CollapsibleComponent;
|
||||
|
||||
Collapsible.Trigger = CollapsibleTrigger;
|
||||
Collapsible.Content = CollapsibleContent;
|
||||
|
||||
export default Collapsible;
|
||||
@@ -0,0 +1,19 @@
|
||||
export interface CollapsibleBaseProps {
|
||||
/**
|
||||
* The open state of the collapsible when it is initially rendered. Use when you do not need to control its open state.
|
||||
*/
|
||||
defaultOpen?: boolean;
|
||||
/**
|
||||
* The controlled open state of the collapsible. Must be used in conjunction with onOpenChange.
|
||||
*/
|
||||
open?: boolean;
|
||||
/**
|
||||
* Event handler called when the open state of the collapsible changes.
|
||||
*/
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/**
|
||||
* Render the collapsible content lazily. This means that the content will not be rendered until the collapsible is open.
|
||||
* @default false
|
||||
*/
|
||||
lazy?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
export { default as Collapsible, type CollapsibleProps } from "./Collapsible";
|
||||
export {
|
||||
default as CollapsibleTrigger,
|
||||
type CollapsibleTriggerProps,
|
||||
} from "./parts/Collapsible.Trigger";
|
||||
export {
|
||||
default as CollapsibleContent,
|
||||
type CollapsibleContentProps,
|
||||
} from "./parts/Collapsible.Content";
|
||||
@@ -0,0 +1,39 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { Slot } from "../../util/Slot";
|
||||
import { useCollapsibleContext } from "../Collapsible.context";
|
||||
|
||||
export interface CollapsibleContentProps
|
||||
extends Omit<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
"hidden" | "aria-controls" | "id"
|
||||
> {
|
||||
/**
|
||||
* When true, will render element as its child. This merges classes, styles and event handlers.
|
||||
* @default false
|
||||
*/
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export const CollapsibleContent = forwardRef<
|
||||
HTMLDivElement,
|
||||
CollapsibleContentProps
|
||||
>(({ children, asChild, ...rest }, ref) => {
|
||||
const ctx = useCollapsibleContext();
|
||||
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
{...rest}
|
||||
data-state={ctx.state}
|
||||
hidden={!ctx.open}
|
||||
aria-controls={ctx.open ? ctx.triggerId : undefined}
|
||||
id={ctx.contentId}
|
||||
>
|
||||
{ctx.lazy || ctx.open ? children : null}
|
||||
</Comp>
|
||||
);
|
||||
});
|
||||
|
||||
export default CollapsibleContent;
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { Slot } from "../../util/Slot";
|
||||
import { composeEventHandlers } from "../../util/composeEventHandlers";
|
||||
import { useCollapsibleContext } from "../Collapsible.context";
|
||||
|
||||
export interface CollapsibleTriggerProps
|
||||
extends Omit<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"id" | "aria-controls" | "aria-expanded"
|
||||
> {
|
||||
/**
|
||||
* When true, will render element as its child. This merges classes, styles and event handlers.
|
||||
* @default false
|
||||
*/
|
||||
asChild?: boolean;
|
||||
/**
|
||||
* You may disable aria-expanded if the accessable name of the trigger is changed when toggled, e.g. "Show"/"Hide".
|
||||
*/
|
||||
"aria-expanded"?: undefined;
|
||||
}
|
||||
|
||||
export const CollapsibleTrigger = forwardRef<
|
||||
HTMLButtonElement,
|
||||
CollapsibleTriggerProps
|
||||
>(({ children, asChild, onClick, ...rest }, ref) => {
|
||||
const ctx = useCollapsibleContext();
|
||||
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
type="button"
|
||||
data-state={ctx.state}
|
||||
onClick={composeEventHandlers(onClick, ctx.onOpenToggle)}
|
||||
aria-expanded={ctx.open}
|
||||
{...rest}
|
||||
id={ctx.triggerId}
|
||||
aria-controls={ctx.open ? ctx.contentId : undefined}
|
||||
>
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
});
|
||||
|
||||
export default CollapsibleTrigger;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import { expect, userEvent, within } from "@storybook/test";
|
||||
import { isSameDay } from "date-fns";
|
||||
import React, { useId, useState } from "react";
|
||||
import { Button } from "../../button";
|
||||
@@ -440,6 +441,39 @@ export const WeekDayClick = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const Required = {
|
||||
render: () => {
|
||||
const { datepickerProps } = useDatepicker({
|
||||
defaultSelected: new Date("Apr 10 2024"),
|
||||
required: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ height: "20rem" }}>
|
||||
<DatePicker.Standalone {...datepickerProps} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const button10 = canvas.getByRole("button", { pressed: true });
|
||||
|
||||
await userEvent.click(button10);
|
||||
|
||||
expect(button10.ariaPressed).toBe("true");
|
||||
|
||||
const button17 = canvas.getByText("17").closest("button");
|
||||
|
||||
expect(button17?.ariaPressed).toBe("false");
|
||||
|
||||
button17 && (await userEvent.click(button17));
|
||||
|
||||
expect(button17?.ariaPressed).toBe("true");
|
||||
expect(button10.ariaPressed).toBe("false");
|
||||
},
|
||||
};
|
||||
|
||||
export const ModalDemo = () => {
|
||||
const { datepickerProps, inputProps } = useDatepicker({
|
||||
fromDate: new Date("Aug 23 2019"),
|
||||
|
||||
@@ -23,6 +23,8 @@ const DayButton = (props: DayProps) => {
|
||||
role={undefined}
|
||||
aria-label={dateTime}
|
||||
aria-hidden={dayRender.activeModifiers.outside}
|
||||
aria-selected={undefined}
|
||||
aria-pressed={!!dayRender.activeModifiers.selected}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -37,7 +37,11 @@ export const DropdownCaption = ({ displayMonth, id }: CaptionProps) => {
|
||||
const handleMonthChange: React.ChangeEventHandler<HTMLSelectElement> = (e) =>
|
||||
goToMonth(setMonth(startOfMonth(displayMonth), Number(e.target.value)));
|
||||
|
||||
const years = getYears(fromDate, toDate, displayMonth.getFullYear());
|
||||
const years = getYears(
|
||||
fromDate,
|
||||
toDate,
|
||||
displayMonth.getFullYear(),
|
||||
).reverse();
|
||||
const months = getMonths(fromDate, toDate, displayMonth);
|
||||
|
||||
const previousLabel = labelPrevious(previousMonth, { locale });
|
||||
|
||||
@@ -229,12 +229,16 @@ export const useDatepicker = (
|
||||
|
||||
/* Only allow de-selecting if not required */
|
||||
const handleDayClick: DayClickEventHandler = (day, { selected }) => {
|
||||
if (selected && required) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (day && !selected) {
|
||||
handleOpen(false);
|
||||
anchorRef?.focus();
|
||||
}
|
||||
|
||||
if (!required && selected) {
|
||||
if (selected) {
|
||||
updateDate(undefined);
|
||||
setInputValue("");
|
||||
updateValidation({ isValidDate: false, isEmpty: true });
|
||||
|
||||
@@ -225,13 +225,17 @@ export const useMonthpicker = (
|
||||
|
||||
/* Only allow de-selecting if not required */
|
||||
const handleMonthClick = (month?: Date) => {
|
||||
if (!month && required) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (month) {
|
||||
handleOpen(false);
|
||||
setYear(month);
|
||||
anchorRef?.focus();
|
||||
}
|
||||
|
||||
if (!required && !month) {
|
||||
if (!month) {
|
||||
updateMonth(undefined);
|
||||
updateValidation({ isValidMonth: false, isEmpty: true });
|
||||
setInputValue("");
|
||||
|
||||
@@ -66,6 +66,7 @@ export const MonthButton = ({
|
||||
type="button"
|
||||
onClick={() => onSelect(isSelected ? undefined : month)}
|
||||
disabled={isDisabled}
|
||||
aria-pressed={!!isSelected}
|
||||
className={cl("navds-date__month-button", {
|
||||
"rdp-day_today": dateIsInCurrentMonth(month, year),
|
||||
"rdp-day_selected": isSelected,
|
||||
|
||||
@@ -35,7 +35,7 @@ export const MonthCaption = () => {
|
||||
if (!years.map((x) => x.getFullYear()).includes(year.getFullYear())) {
|
||||
years.push(setYear(startOfYear(new Date()), year.getFullYear()));
|
||||
}
|
||||
years.sort((a, b) => a.getFullYear() - b.getFullYear());
|
||||
years.sort((a, b) => b.getFullYear() - a.getFullYear());
|
||||
}
|
||||
|
||||
const handleYearChange = (e) =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Meta, StoryFn, StoryObj } from "@storybook/react";
|
||||
import { expect, userEvent, within } from "@storybook/test";
|
||||
import { setYear } from "date-fns";
|
||||
import React, { useId, useState } from "react";
|
||||
import { Button } from "../../button";
|
||||
@@ -121,25 +122,37 @@ export const UseMonthpickerFormat = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const Required = () => {
|
||||
const { inputProps, monthpickerProps } = useMonthpicker({
|
||||
locale: "nb",
|
||||
defaultSelected: new Date(),
|
||||
disabled: [new Date("Apr 1 2022")],
|
||||
required: true,
|
||||
});
|
||||
export const Required = {
|
||||
render: () => {
|
||||
const { monthpickerProps } = useMonthpicker({
|
||||
defaultSelected: new Date("Apr 10 2024"),
|
||||
required: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ height: "20rem" }}>
|
||||
<MonthPicker {...monthpickerProps}>
|
||||
<MonthPicker.Input
|
||||
{...inputProps}
|
||||
label="Velg måned"
|
||||
variant="monthpicker"
|
||||
/>
|
||||
</MonthPicker>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{ height: "20rem" }}>
|
||||
<MonthPicker.Standalone {...monthpickerProps} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const buttonApr = canvas.getByRole("button", { pressed: true });
|
||||
|
||||
await userEvent.click(buttonApr);
|
||||
|
||||
expect(buttonApr.ariaPressed).toBe("true");
|
||||
|
||||
const buttonSep = canvas.getByText("september").closest("button");
|
||||
|
||||
expect(buttonSep?.ariaPressed).toBe("false");
|
||||
|
||||
buttonSep && (await userEvent.click(buttonSep));
|
||||
|
||||
expect(buttonSep?.ariaPressed).toBe("true");
|
||||
expect(buttonApr.ariaPressed).toBe("false");
|
||||
},
|
||||
};
|
||||
|
||||
export const UserControlled = () => {
|
||||
@@ -193,7 +206,6 @@ export const Chromatic: Story = {
|
||||
<DisabledMonths />
|
||||
<UseMonthpicker />
|
||||
<UseMonthpickerFormat />
|
||||
<Required />
|
||||
<UserControlled />
|
||||
<FollowYear />
|
||||
</div>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import React from "react";
|
||||
import { XMarkIcon } from "@navikt/aksel-icons";
|
||||
|
||||
interface ClearButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
handleClear: (event: any) => void;
|
||||
clearButtonLabel?: string;
|
||||
}
|
||||
|
||||
export const ClearButton: React.FC<ClearButtonProps> = ({
|
||||
handleClear,
|
||||
clearButtonLabel,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="navds-combobox__button-clear"
|
||||
{...rest}
|
||||
>
|
||||
<span className="navds-sr-only">
|
||||
{clearButtonLabel ? clearButtonLabel : "Tøm"}
|
||||
</span>
|
||||
<XMarkIcon aria-hidden />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClearButton;
|
||||
@@ -1,57 +1,31 @@
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef, useRef } from "react";
|
||||
import React, { forwardRef } from "react";
|
||||
import { BodyShort, ErrorMessage, Label } from "../../typography";
|
||||
import { useMergeRefs } from "../../util/hooks/useMergeRefs";
|
||||
import ClearButton from "./ClearButton";
|
||||
import ComboboxWrapper from "./ComboboxWrapper";
|
||||
import FilteredOptions from "./FilteredOptions/FilteredOptions";
|
||||
import { useFilteredOptionsContext } from "./FilteredOptions/filteredOptionsContext";
|
||||
import Input from "./Input/Input";
|
||||
import { useInputContext } from "./Input/inputContext";
|
||||
import SelectedOptions from "./SelectedOptions/SelectedOptions";
|
||||
import { useSelectedOptionsContext } from "./SelectedOptions/selectedOptionsContext";
|
||||
import ToggleListButton from "./ToggleListButton";
|
||||
import { useInputContext } from "./Input/Input.context";
|
||||
import { InputController } from "./Input/InputController";
|
||||
import { ComboboxProps } from "./types";
|
||||
|
||||
export const Combobox = forwardRef<
|
||||
HTMLInputElement,
|
||||
Omit<ComboboxProps, "onChange" | "options" | "size" | "onClear" | "value">
|
||||
>((props, ref) => {
|
||||
const {
|
||||
className,
|
||||
hideLabel = false,
|
||||
description,
|
||||
label,
|
||||
clearButton = true,
|
||||
clearButtonLabel,
|
||||
toggleListButton = true,
|
||||
toggleListButtonLabel,
|
||||
inputClassName,
|
||||
shouldShowSelectedOptions = true,
|
||||
...rest
|
||||
} = props;
|
||||
const { className, hideLabel = false, description, label, ...rest } = props;
|
||||
|
||||
const toggleListButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const { activeDecendantId, toggleIsListOpen } = useFilteredOptionsContext();
|
||||
const { selectedOptions } = useSelectedOptionsContext();
|
||||
const { toggleIsListOpen } = useFilteredOptionsContext();
|
||||
|
||||
const {
|
||||
clearInput,
|
||||
error,
|
||||
errorId,
|
||||
focusInput,
|
||||
hasError,
|
||||
inputDescriptionId,
|
||||
inputProps,
|
||||
inputRef,
|
||||
value,
|
||||
showErrorMsg,
|
||||
size = "medium",
|
||||
} = useInputContext();
|
||||
|
||||
const mergedInputRef = useMergeRefs(inputRef, ref);
|
||||
|
||||
return (
|
||||
<ComboboxWrapper
|
||||
className={className}
|
||||
@@ -59,7 +33,6 @@ export const Combobox = forwardRef<
|
||||
inputProps={inputProps}
|
||||
inputSize={size}
|
||||
toggleIsListOpen={toggleIsListOpen}
|
||||
toggleListButtonRef={toggleListButtonRef}
|
||||
>
|
||||
<Label
|
||||
htmlFor={inputProps.id}
|
||||
@@ -83,50 +56,7 @@ export const Combobox = forwardRef<
|
||||
</BodyShort>
|
||||
)}
|
||||
<div className="navds-combobox__wrapper">
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className={cl(
|
||||
"navds-combobox__wrapper-inner navds-text-field__input",
|
||||
{
|
||||
"navds-combobox__wrapper-inner--virtually-unfocused":
|
||||
activeDecendantId !== undefined,
|
||||
},
|
||||
)}
|
||||
onClick={focusInput}
|
||||
>
|
||||
{!shouldShowSelectedOptions ? (
|
||||
<Input
|
||||
id={inputProps.id}
|
||||
ref={mergedInputRef}
|
||||
inputClassName={inputClassName}
|
||||
{...rest}
|
||||
/>
|
||||
) : (
|
||||
<SelectedOptions selectedOptions={selectedOptions} size={size}>
|
||||
<Input
|
||||
id={inputProps.id}
|
||||
ref={mergedInputRef}
|
||||
inputClassName={inputClassName}
|
||||
{...rest}
|
||||
/>
|
||||
</SelectedOptions>
|
||||
)}
|
||||
<div>
|
||||
{value && clearButton && (
|
||||
<ClearButton
|
||||
handleClear={clearInput}
|
||||
clearButtonLabel={clearButtonLabel}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
)}
|
||||
{toggleListButton && (
|
||||
<ToggleListButton
|
||||
toggleListButtonLabel={toggleListButtonLabel}
|
||||
ref={toggleListButtonRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<InputController ref={ref} {...rest} />
|
||||
<FilteredOptions />
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import Combobox from "./Combobox";
|
||||
import { FilteredOptionsProvider } from "./FilteredOptions/filteredOptionsContext";
|
||||
import { InputContextProvider } from "./Input/inputContext";
|
||||
import { InputContextProvider } from "./Input/Input.context";
|
||||
import { SelectedOptionsProvider } from "./SelectedOptions/selectedOptionsContext";
|
||||
import { mapToComboboxOptionArray } from "./combobox-utils";
|
||||
import { CustomOptionsProvider } from "./customOptionsContext";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import cl from "clsx";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { useInputContext } from "./Input/Input.context";
|
||||
|
||||
type ComboboxWrapperProps = {
|
||||
children: any;
|
||||
@@ -10,7 +11,6 @@ type ComboboxWrapperProps = {
|
||||
};
|
||||
inputSize: string;
|
||||
toggleIsListOpen: (isListOpen: boolean) => void;
|
||||
toggleListButtonRef: React.RefObject<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
const ComboboxWrapper = ({
|
||||
@@ -20,15 +20,16 @@ const ComboboxWrapper = ({
|
||||
inputProps,
|
||||
inputSize,
|
||||
toggleIsListOpen,
|
||||
toggleListButtonRef,
|
||||
}: ComboboxWrapperProps) => {
|
||||
const { toggleOpenButtonRef } = useInputContext();
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const [hasFocusWithin, setHasFocusWithin] = useState(false);
|
||||
|
||||
function onFocusInsideWrapper(e) {
|
||||
if (
|
||||
!wrapperRef.current?.contains(e.relatedTarget) &&
|
||||
toggleListButtonRef?.current !== e.target
|
||||
toggleOpenButtonRef?.current !== e.target
|
||||
) {
|
||||
toggleIsListOpen(true);
|
||||
setHasFocusWithin(true);
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
const CheckIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="13"
|
||||
viewBox="0 0 16 13"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
fill="#005B82"
|
||||
d="M14.2014 0L16 1.89047L4.77943 13L0 8.39552L1.79361 6.5L4.77418 9.3019L14.2014 0Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckIcon;
|
||||
@@ -3,7 +3,7 @@ import React from "react";
|
||||
import { CheckmarkIcon, PlusIcon } from "@navikt/aksel-icons";
|
||||
import { Loader } from "../../../loader";
|
||||
import { BodyShort, Label } from "../../../typography";
|
||||
import { useInputContext } from "../Input/inputContext";
|
||||
import { useInputContext } from "../Input/Input.context";
|
||||
import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
|
||||
import { isInList, toComboboxOption } from "../combobox-utils";
|
||||
import { ComboboxOption } from "../types";
|
||||
@@ -38,7 +38,7 @@ const FilteredOptions = () => {
|
||||
const shouldRenderNonSelectables =
|
||||
maxSelected?.isLimitReached || // Render maxSelected message
|
||||
isLoading || // Render loading message
|
||||
(!isLoading && filteredOptions.length === 0); // Render no hits message
|
||||
(!isLoading && filteredOptions.length === 0 && !allowNewValues); // Render no hits message
|
||||
|
||||
const shouldRenderFilteredOptionsList =
|
||||
(allowNewValues && isValueNew && !maxSelected?.isLimitReached) || // Render add new option
|
||||
@@ -72,7 +72,7 @@ const FilteredOptions = () => {
|
||||
<Loader title="Søker..." />
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && filteredOptions.length === 0 && (
|
||||
{!isLoading && filteredOptions.length === 0 && !allowNewValues && (
|
||||
<div
|
||||
className="navds-combobox__list-item--no-options"
|
||||
id={filteredOptionsUtil.getNoHitsId(id)}
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import cl from "clsx";
|
||||
import React, {
|
||||
SetStateAction,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { SetStateAction, useCallback, useMemo, useState } from "react";
|
||||
import { createContext } from "../../../util/create-context";
|
||||
import { useClientLayoutEffect, usePrevious } from "../../../util/hooks";
|
||||
import { useInputContext } from "../Input/inputContext";
|
||||
import { useInputContext } from "../Input/Input.context";
|
||||
import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
|
||||
import { toComboboxOption } from "../combobox-utils";
|
||||
import { useCustomOptionsContext } from "../customOptionsContext";
|
||||
import { useComboboxCustomOptions } from "../customOptionsContext";
|
||||
import { ComboboxOption, ComboboxProps } from "../types";
|
||||
import filteredOptionsUtils from "./filtered-options-util";
|
||||
import useVirtualFocus, { VirtualFocusType } from "./useVirtualFocus";
|
||||
@@ -24,7 +18,7 @@ type FilteredOptionsProps = {
|
||||
};
|
||||
};
|
||||
|
||||
type FilteredOptionsContextType = {
|
||||
type FilteredOptionsContextValue = {
|
||||
activeDecendantId?: string;
|
||||
allowNewValues?: boolean;
|
||||
ariaDescribedBy?: string;
|
||||
@@ -42,11 +36,14 @@ type FilteredOptionsContextType = {
|
||||
shouldAutocomplete?: boolean;
|
||||
virtualFocus: VirtualFocusType;
|
||||
};
|
||||
const FilteredOptionsContext = createContext<FilteredOptionsContextType>(
|
||||
{} as FilteredOptionsContextType,
|
||||
);
|
||||
const [FilteredOptionsContextProvider, useFilteredOptionsContext] =
|
||||
createContext<FilteredOptionsContextValue>({
|
||||
name: "FilteredOptionsContext",
|
||||
errorMessage:
|
||||
"useFilteredOptionsContext must be used within a FilteredOptionsProvider",
|
||||
});
|
||||
|
||||
export const FilteredOptionsProvider = ({
|
||||
const FilteredOptionsProvider = ({
|
||||
children,
|
||||
value: props,
|
||||
}: FilteredOptionsProps) => {
|
||||
@@ -71,7 +68,7 @@ export const FilteredOptionsProvider = ({
|
||||
const { maxSelected } = useSelectedOptionsContext();
|
||||
|
||||
const [isInternalListOpen, setInternalListOpen] = useState(false);
|
||||
const { customOptions } = useCustomOptionsContext();
|
||||
const { customOptions } = useComboboxCustomOptions();
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (externalFilteredOptions) {
|
||||
@@ -97,9 +94,17 @@ export const FilteredOptionsProvider = ({
|
||||
[filteredOptionsUtils.getAddNewOptionId(id)]: allowNewValues
|
||||
? toComboboxOption(value)
|
||||
: undefined,
|
||||
...customOptions.reduce(
|
||||
(acc, customOption) => ({
|
||||
...acc,
|
||||
[filteredOptionsUtils.getOptionId(id, customOption.label)]:
|
||||
customOption,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
},
|
||||
),
|
||||
[allowNewValues, id, options, value],
|
||||
[allowNewValues, customOptions, id, options, value],
|
||||
);
|
||||
|
||||
useClientLayoutEffect(() => {
|
||||
@@ -144,9 +149,9 @@ export const FilteredOptionsProvider = ({
|
||||
|
||||
const ariaDescribedBy = useMemo(() => {
|
||||
let activeOption;
|
||||
if (!isLoading && filteredOptions.length === 0) {
|
||||
if (!isLoading && filteredOptions.length === 0 && !allowNewValues) {
|
||||
activeOption = filteredOptionsUtils.getNoHitsId(id);
|
||||
} else if ((value && value !== "") || isLoading) {
|
||||
} else if (value || isLoading) {
|
||||
if (shouldAutocomplete && filteredOptions[0]) {
|
||||
activeOption = filteredOptionsUtils.getOptionId(
|
||||
id,
|
||||
@@ -172,6 +177,7 @@ export const FilteredOptionsProvider = ({
|
||||
shouldAutocomplete,
|
||||
filteredOptions,
|
||||
id,
|
||||
allowNewValues,
|
||||
]);
|
||||
|
||||
const currentOption = useMemo(
|
||||
@@ -203,18 +209,10 @@ export const FilteredOptionsProvider = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<FilteredOptionsContext.Provider value={filteredOptionsState}>
|
||||
<FilteredOptionsContextProvider {...filteredOptionsState}>
|
||||
{children}
|
||||
</FilteredOptionsContext.Provider>
|
||||
</FilteredOptionsContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useFilteredOptionsContext = () => {
|
||||
const context = useContext(FilteredOptionsContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useFilteredOptionsContext must be used within a FilteredOptionsProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
export { FilteredOptionsProvider, useFilteredOptionsContext };
|
||||
+30
-33
@@ -1,18 +1,18 @@
|
||||
import React, {
|
||||
ChangeEvent,
|
||||
ChangeEventHandler,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createContext } from "../../../util/create-context";
|
||||
import { useClientLayoutEffect } from "../../../util/hooks";
|
||||
import { FormFieldType, useFormField } from "../../useFormField";
|
||||
import { ComboboxProps } from "../types";
|
||||
|
||||
interface InputContextType extends FormFieldType {
|
||||
clearInput: (event: React.PointerEvent | React.KeyboardEvent) => void;
|
||||
interface InputContextValue extends FormFieldType {
|
||||
clearInput: NonNullable<ComboboxProps["onClear"]>;
|
||||
error?: string;
|
||||
focusInput: () => void;
|
||||
inputRef: React.RefObject<HTMLInputElement>;
|
||||
@@ -22,11 +22,16 @@ interface InputContextType extends FormFieldType {
|
||||
searchTerm: string;
|
||||
setSearchTerm: React.Dispatch<React.SetStateAction<string>>;
|
||||
shouldAutocomplete?: boolean;
|
||||
toggleOpenButtonRef: React.RefObject<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
const InputContext = createContext<InputContextType>({} as InputContextType);
|
||||
const [InputContextProvider, useInputContext] =
|
||||
createContext<InputContextValue>({
|
||||
name: "InputContext",
|
||||
errorMessage: "useInputContext must be used within an InputContextProvider",
|
||||
});
|
||||
|
||||
export const InputContextProvider = ({ children, value: props }) => {
|
||||
const InputProvider = ({ children, value: props }) => {
|
||||
const {
|
||||
defaultValue = "",
|
||||
description,
|
||||
@@ -52,6 +57,7 @@ export const InputContextProvider = ({ children, value: props }) => {
|
||||
"comboboxfield",
|
||||
);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const toggleOpenButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [internalValue, setInternalValue] = useState<string>(defaultValue);
|
||||
|
||||
const value = useMemo(
|
||||
@@ -79,7 +85,7 @@ export const InputContextProvider = ({ children, value: props }) => {
|
||||
);
|
||||
|
||||
const clearInput = useCallback(
|
||||
(event: React.PointerEvent | React.KeyboardEvent) => {
|
||||
(event: React.PointerEvent | React.KeyboardEvent | React.MouseEvent) => {
|
||||
onClear?.(event);
|
||||
externalOnChange?.(null, "");
|
||||
setValue("");
|
||||
@@ -98,33 +104,24 @@ export const InputContextProvider = ({ children, value: props }) => {
|
||||
}
|
||||
}, [value, searchTerm, shouldAutocomplete]);
|
||||
|
||||
const contextValue = {
|
||||
...formFieldProps,
|
||||
clearInput,
|
||||
error,
|
||||
focusInput,
|
||||
inputRef,
|
||||
value,
|
||||
setValue,
|
||||
onChange,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
shouldAutocomplete,
|
||||
toggleOpenButtonRef,
|
||||
};
|
||||
|
||||
return (
|
||||
<InputContext.Provider
|
||||
value={{
|
||||
...formFieldProps,
|
||||
clearInput,
|
||||
error,
|
||||
focusInput,
|
||||
inputRef,
|
||||
value,
|
||||
setValue,
|
||||
onChange,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
shouldAutocomplete,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</InputContext.Provider>
|
||||
<InputContextProvider {...contextValue}>{children}</InputContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useInputContext = () => {
|
||||
const context = useContext(InputContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useInputContext must be used within an InputContextProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
export { InputProvider as InputContextProvider, useInputContext };
|
||||
@@ -9,7 +9,7 @@ import { omit } from "../../../util";
|
||||
import filteredOptionsUtil from "../FilteredOptions/filtered-options-util";
|
||||
import { useFilteredOptionsContext } from "../FilteredOptions/filteredOptionsContext";
|
||||
import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
|
||||
import { useInputContext } from "./inputContext";
|
||||
import { useInputContext } from "./Input.context";
|
||||
|
||||
interface InputProps
|
||||
extends Omit<InputHTMLAttributes<HTMLInputElement>, "value"> {
|
||||
@@ -128,6 +128,10 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
removeSelectedOption(lastSelectedOption);
|
||||
}
|
||||
}
|
||||
} else if (e.key === "Enter" || e.key === "Accept") {
|
||||
if (activeDecendantId || value) {
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if (e.key === "ArrowDown") {
|
||||
// Check that cursor position is at the end of the input field,
|
||||
// so we don't interfere with text editing
|
||||
@@ -182,6 +186,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
{...omit(inputProps, ["aria-invalid"])}
|
||||
ref={ref}
|
||||
value={value}
|
||||
onBlur={() => virtualFocus.moveFocusToTop()}
|
||||
onChange={onChangeHandler}
|
||||
type="text"
|
||||
role="combobox"
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef } from "react";
|
||||
import { XMarkIcon } from "@navikt/aksel-icons";
|
||||
import { useMergeRefs } from "../../../util/hooks";
|
||||
import { useFilteredOptionsContext } from "../FilteredOptions/filteredOptionsContext";
|
||||
import SelectedOptions from "../SelectedOptions/SelectedOptions";
|
||||
import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
|
||||
import { ComboboxProps } from "../types";
|
||||
import Input from "./Input";
|
||||
import { useInputContext } from "./Input.context";
|
||||
import ToggleListButton from "./ToggleListButton";
|
||||
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
export const InputController = forwardRef<
|
||||
HTMLInputElement,
|
||||
Omit<
|
||||
ComboboxProps,
|
||||
| "label"
|
||||
| "description"
|
||||
| "hideLabel"
|
||||
| "onChange"
|
||||
| "options"
|
||||
| "size"
|
||||
| "onClear"
|
||||
| "value"
|
||||
>
|
||||
>((props, ref) => {
|
||||
const {
|
||||
clearButton = true,
|
||||
clearButtonLabel,
|
||||
toggleListButton = true,
|
||||
toggleListButtonLabel,
|
||||
inputClassName,
|
||||
shouldShowSelectedOptions = true,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const {
|
||||
clearInput,
|
||||
focusInput,
|
||||
inputProps,
|
||||
value,
|
||||
size = "medium",
|
||||
inputRef,
|
||||
toggleOpenButtonRef,
|
||||
} = useInputContext();
|
||||
|
||||
const { activeDecendantId } = useFilteredOptionsContext();
|
||||
const { selectedOptions } = useSelectedOptionsContext();
|
||||
|
||||
const mergedInputRef = useMergeRefs(inputRef, ref);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cl("navds-combobox__wrapper-inner navds-text-field__input", {
|
||||
"navds-combobox__wrapper-inner--virtually-unfocused":
|
||||
activeDecendantId !== undefined,
|
||||
})}
|
||||
onClick={focusInput}
|
||||
>
|
||||
{!shouldShowSelectedOptions ? (
|
||||
<Input
|
||||
id={inputProps.id}
|
||||
ref={mergedInputRef}
|
||||
inputClassName={inputClassName}
|
||||
{...rest}
|
||||
/>
|
||||
) : (
|
||||
<SelectedOptions selectedOptions={selectedOptions} size={size}>
|
||||
<Input
|
||||
id={inputProps.id}
|
||||
ref={mergedInputRef}
|
||||
inputClassName={inputClassName}
|
||||
{...rest}
|
||||
/>
|
||||
</SelectedOptions>
|
||||
)}
|
||||
<div>
|
||||
{value && clearButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearInput}
|
||||
className="navds-combobox__button-clear"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<span className="navds-sr-only">
|
||||
{clearButtonLabel ? clearButtonLabel : "Tøm"}
|
||||
</span>
|
||||
<XMarkIcon aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
{toggleListButton && (
|
||||
<ToggleListButton
|
||||
toggleListButtonLabel={toggleListButtonLabel}
|
||||
ref={toggleOpenButtonRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "@navikt/aksel-icons";
|
||||
import { useFilteredOptionsContext } from "./FilteredOptions/filteredOptionsContext";
|
||||
import { useFilteredOptionsContext } from "../FilteredOptions/filteredOptionsContext";
|
||||
|
||||
interface ToggleListButtonProps {
|
||||
toggleListButtonLabel?: string;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { Chips } from "../../../chips";
|
||||
import { useInputContext } from "../Input/inputContext";
|
||||
import { useInputContext } from "../Input/Input.context";
|
||||
import { ComboboxOption } from "../types";
|
||||
import { useSelectedOptionsContext } from "./selectedOptionsContext";
|
||||
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { createContext } from "../../../util/create-context";
|
||||
import { usePrevious } from "../../../util/hooks";
|
||||
import { useInputContext } from "../Input/inputContext";
|
||||
import { useInputContext } from "../Input/Input.context";
|
||||
import { isInList } from "../combobox-utils";
|
||||
import { useCustomOptionsContext } from "../customOptionsContext";
|
||||
import { useComboboxCustomOptions } from "../customOptionsContext";
|
||||
import { ComboboxOption, ComboboxProps, MaxSelected } from "../types";
|
||||
|
||||
type SelectedOptionsContextType = {
|
||||
type SelectedOptionsContextValue = {
|
||||
addSelectedOption: (option: ComboboxOption) => void;
|
||||
isMultiSelect?: boolean;
|
||||
removeSelectedOption: (option: ComboboxOption) => void;
|
||||
@@ -25,11 +20,10 @@ type SelectedOptionsContextType = {
|
||||
) => void;
|
||||
};
|
||||
|
||||
const SelectedOptionsContext = createContext<SelectedOptionsContextType>(
|
||||
{} as SelectedOptionsContextType,
|
||||
);
|
||||
const [SelectedOptionsContextProvider, useSelectedOptionsContext] =
|
||||
createContext<SelectedOptionsContextValue>();
|
||||
|
||||
export const SelectedOptionsProvider = ({
|
||||
const SelectedOptionsProvider = ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
@@ -45,7 +39,7 @@ export const SelectedOptionsProvider = ({
|
||||
removeCustomOption,
|
||||
addCustomOption,
|
||||
setCustomOptions,
|
||||
} = useCustomOptionsContext();
|
||||
} = useComboboxCustomOptions();
|
||||
const {
|
||||
allowNewValues,
|
||||
isMultiSelect,
|
||||
@@ -149,18 +143,10 @@ export const SelectedOptionsProvider = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectedOptionsContext.Provider value={selectedOptionsState}>
|
||||
<SelectedOptionsContextProvider {...selectedOptionsState}>
|
||||
{children}
|
||||
</SelectedOptionsContext.Provider>
|
||||
</SelectedOptionsContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSelectedOptionsContext = () => {
|
||||
const context = useContext(SelectedOptionsContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useSelectedOptionsContext must be used within a SelectedOptionsProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
export { SelectedOptionsProvider, useSelectedOptionsContext };
|
||||
+1
-1
@@ -2,7 +2,7 @@ import {
|
||||
isInList,
|
||||
mapToComboboxOptionArray,
|
||||
toComboboxOption,
|
||||
} from "./combobox-utils";
|
||||
} from "../combobox-utils";
|
||||
|
||||
const list = [
|
||||
{ label: "Hjelpemidler", value: "HJE" },
|
||||
+2
-3
@@ -1,10 +1,9 @@
|
||||
/* eslint-disable testing-library/no-unnecessary-act -- https://kentcdodds.com/blog/fix-the-not-wrapped-in-act-warning */
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React, { useId } from "react";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { UNSAFE_Combobox } from "./index";
|
||||
import { UNSAFE_Combobox } from "../index";
|
||||
|
||||
const options = [
|
||||
"banana",
|
||||
@@ -464,13 +464,13 @@ export const RemoveSelectedMultiSelectTest: StoryObject = {
|
||||
},
|
||||
};
|
||||
|
||||
export const AddWhenAddNewDisabledTest: StoryObject = {
|
||||
export const AllowNewValuesTest: StoryObject = {
|
||||
render: () => {
|
||||
return (
|
||||
<UNSAFE_Combobox
|
||||
options={options}
|
||||
label="Hva er dine favorittfrukter?"
|
||||
isMultiSelect
|
||||
allowNewValues
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -497,6 +497,35 @@ export const AddWhenAddNewDisabledTest: StoryObject = {
|
||||
},
|
||||
};
|
||||
|
||||
export const DisallowNewValuesTest: StoryObject = {
|
||||
render: () => {
|
||||
return (
|
||||
<UNSAFE_Combobox options={options} label="Hva er dine favorittfrukter?" />
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const input = canvas.getByLabelText("Hva er dine favorittfrukter?");
|
||||
|
||||
userEvent.click(input);
|
||||
await userEvent.type(input, "aaa", { delay: 200 });
|
||||
await sleep(250);
|
||||
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{ArrowDown}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{Enter}");
|
||||
await sleep(250);
|
||||
userEvent.keyboard("{Escape}");
|
||||
await sleep(250);
|
||||
|
||||
const invalidSelect = canvas.queryByLabelText("aaa slett");
|
||||
expect(invalidSelect).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const TestThatCallbacksOnlyFireWhenExpected: StoryObj<{
|
||||
onChange: ReturnType<typeof fn>;
|
||||
onClear: ReturnType<typeof fn>;
|
||||
@@ -624,3 +653,71 @@ export const TestHoverAndFocusSwitching: StoryObject = {
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const TestEnterNotSubmittingForm: StoryObj<{
|
||||
onSubmit: ReturnType<typeof fn>;
|
||||
}> = {
|
||||
args: {
|
||||
onSubmit: fn(),
|
||||
},
|
||||
render: ({ onSubmit }) => {
|
||||
return (
|
||||
<form action="https://www.aksel.nav.no" method="get" onSubmit={onSubmit}>
|
||||
<UNSAFE_Combobox
|
||||
options={options}
|
||||
label="Hva er dine favorittfrukter?"
|
||||
isMultiSelect
|
||||
allowNewValues
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
args.onSubmit.mockClear();
|
||||
const canvas = within(canvasElement);
|
||||
const waitTime = 0; // Change for debugging
|
||||
|
||||
await sleep(waitTime);
|
||||
|
||||
const getInput = () =>
|
||||
canvas.getByRole("combobox", {
|
||||
name: "Hva er dine favorittfrukter?",
|
||||
});
|
||||
|
||||
const getOption = (name: string, selected: boolean) =>
|
||||
canvas.getByRole("option", { name, selected });
|
||||
await userEvent.click(getInput(), { delay: waitTime });
|
||||
|
||||
await userEvent.keyboard("{ArrowDown}", { delay: waitTime });
|
||||
expect(getInput().getAttribute("aria-activedescendant")).toBe(
|
||||
getOption("banana", false).getAttribute("id"),
|
||||
);
|
||||
|
||||
await userEvent.keyboard("{Enter}", { delay: waitTime });
|
||||
expect(args.onSubmit).not.toHaveBeenCalled();
|
||||
expect(getOption("banana", true)).toBeVisible();
|
||||
|
||||
await userEvent.keyboard("{Shift>}{Tab}", { delay: waitTime });
|
||||
|
||||
await userEvent.keyboard("{Enter}", { delay: waitTime });
|
||||
expect(args.onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.keyboard("test"); // Type option that does not exist
|
||||
await userEvent.keyboard("{Enter}", { delay: waitTime });
|
||||
expect(args.onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.keyboard("{ArrowDown}", { delay: waitTime }); // Select "test" custom option
|
||||
expect(
|
||||
canvas.getByRole("option", { name: "test", selected: true }),
|
||||
).toBeVisible();
|
||||
await userEvent.keyboard("{Enter}", { delay: waitTime }); // De-select "test"
|
||||
expect(
|
||||
canvas.queryByRole("option", { name: "test", selected: false }),
|
||||
).toBeNull();
|
||||
expect(args.onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.keyboard("{Escape}", { delay: waitTime }); // Clear input field
|
||||
await userEvent.keyboard("{Enter}", { delay: waitTime }); // Enter on empty Input with closed list
|
||||
expect(args.onSubmit).toHaveBeenCalledOnce();
|
||||
},
|
||||
};
|
||||
@@ -1,19 +1,23 @@
|
||||
import React, { createContext, useCallback, useContext, useState } from "react";
|
||||
import { useInputContext } from "./Input/inputContext";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { createContext } from "../../util/create-context";
|
||||
import { useInputContext } from "./Input/Input.context";
|
||||
import { ComboboxOption } from "./types";
|
||||
|
||||
type CustomOptionsContextType = {
|
||||
type CustomOptionsContextValue = {
|
||||
customOptions: ComboboxOption[];
|
||||
removeCustomOption: (option: ComboboxOption) => void;
|
||||
addCustomOption: (option: ComboboxOption) => void;
|
||||
setCustomOptions: React.Dispatch<React.SetStateAction<ComboboxOption[]>>;
|
||||
};
|
||||
|
||||
const CustomOptionsContext = createContext<CustomOptionsContextType>(
|
||||
{} as CustomOptionsContextType,
|
||||
);
|
||||
const [ComboboxCustomOptionsProvider, useComboboxCustomOptions] =
|
||||
createContext<CustomOptionsContextValue>({
|
||||
name: "ComboboxCustomOptions",
|
||||
errorMessage:
|
||||
"useComboboxCustomOptions must be used within a ComboboxCustomOptionsProvider",
|
||||
});
|
||||
|
||||
export const CustomOptionsProvider = ({
|
||||
const CustomOptionsProvider = ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
@@ -54,18 +58,10 @@ export const CustomOptionsProvider = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomOptionsContext.Provider value={customOptionsState}>
|
||||
<ComboboxCustomOptionsProvider {...customOptionsState}>
|
||||
{children}
|
||||
</CustomOptionsContext.Provider>
|
||||
</ComboboxCustomOptionsProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useCustomOptionsContext = () => {
|
||||
const context = useContext(CustomOptionsContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useCustomOptionsContext must be used within a CustomOptionsProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
export { CustomOptionsProvider, useComboboxCustomOptions };
|
||||
@@ -100,7 +100,9 @@ export interface ComboboxProps
|
||||
*
|
||||
* @param event
|
||||
*/
|
||||
onClear?: (event: React.PointerEvent | React.KeyboardEvent) => void;
|
||||
onClear?: (
|
||||
event: React.PointerEvent | React.KeyboardEvent | React.MouseEvent,
|
||||
) => void;
|
||||
/**
|
||||
* Callback function triggered whenever an option is selected or de-selected.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext } from "../../util/create-context";
|
||||
import { ComponentTranslation } from "./i18n/i18n.types";
|
||||
import { ComponentTranslation } from "../../util/i18n/i18n.types";
|
||||
|
||||
export type FileUploadLocaleContextProps = {
|
||||
translations?: ComponentTranslation<"FileUpload">;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import cl from "clsx";
|
||||
import React, { HTMLAttributes, forwardRef } from "react";
|
||||
import { ComponentTranslation } from "../../util/i18n/i18n.types";
|
||||
import { FileUploadLocaleContextProvider } from "./FileUpload.context";
|
||||
import { ComponentTranslation } from "./i18n/i18n.types";
|
||||
import Trigger from "./parts/Trigger";
|
||||
import Dropzone from "./parts/dropzone/Dropzone";
|
||||
import Item from "./parts/item/Item";
|
||||
|
||||
@@ -80,7 +80,7 @@ export const States: StoryObj = {
|
||||
</div>
|
||||
),
|
||||
};
|
||||
States.play = async ({ canvasElement }) => {
|
||||
States.play = ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const button = canvas.getByText("Velg fil");
|
||||
fireEvent.dragEnter(button);
|
||||
|
||||
@@ -5,10 +5,10 @@ import { Button } from "../../../../button";
|
||||
import { BodyShort, ErrorMessage, Label } from "../../../../typography";
|
||||
import { composeEventHandlers } from "../../../../util/composeEventHandlers";
|
||||
import { useId } from "../../../../util/hooks";
|
||||
import { useI18n } from "../../../../util/i18n/i18n.context";
|
||||
import { omit } from "../../../../util/omit";
|
||||
import { useFormField } from "../../../useFormField";
|
||||
import { useFileUploadTranslation } from "../../FileUpload.context";
|
||||
import { useI18n } from "../../i18n/i18n.context";
|
||||
import { useFileUpload } from "../../useFileUpload";
|
||||
import { FileUploadDropzoneProps } from "./dropzone.types";
|
||||
import { useDropzone } from "./useDropzone";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ComponentTranslation } from "../../../../util/i18n/i18n.types";
|
||||
import { FormFieldProps } from "../../../useFormField";
|
||||
import { FileUploadBaseProps } from "../../FileUpload.types";
|
||||
import { ComponentTranslation } from "../../i18n/i18n.types";
|
||||
|
||||
export interface FileUploadDropzoneProps
|
||||
extends FileUploadBaseProps,
|
||||
|
||||
@@ -3,9 +3,9 @@ import React, { MouseEvent, forwardRef } from "react";
|
||||
import { ExclamationmarkTriangleIcon } from "@navikt/aksel-icons";
|
||||
import { BodyShort } from "../../../../typography";
|
||||
import { OverridableComponent } from "../../../../util";
|
||||
import { useI18n } from "../../../../util/i18n/i18n.context";
|
||||
import { ComponentTranslation } from "../../../../util/i18n/i18n.types";
|
||||
import { useFileUploadTranslation } from "../../FileUpload.context";
|
||||
import { useI18n } from "../../i18n/i18n.context";
|
||||
import { ComponentTranslation } from "../../i18n/i18n.types";
|
||||
import { FileItem } from "./Item.types";
|
||||
import ItemButton from "./ItemButton";
|
||||
import ItemIcon from "./ItemIcon";
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Meta, StoryFn } from "@storybook/react";
|
||||
import React, { useState } from "react";
|
||||
import VStack from "../../layout/stack/VStack";
|
||||
import FormProgress, { FormProgressProps } from "./FormProgress";
|
||||
|
||||
export default {
|
||||
title: "ds-react/FormProgress",
|
||||
component: FormProgress,
|
||||
decorators: [(story) => <div style={{ width: 300 }}>{story()}</div>],
|
||||
parameters: { chromatic: { disable: true } },
|
||||
} satisfies Meta<typeof FormProgress>;
|
||||
|
||||
type ControllableProps = Pick<
|
||||
FormProgressProps,
|
||||
"activeStep" | "totalSteps" | "interactiveSteps"
|
||||
>;
|
||||
|
||||
export const Default: StoryFn<ControllableProps> = (props) => (
|
||||
<div style={{ minHeight: 700 }}>
|
||||
<FormProgress {...props}>
|
||||
<FormProgress.Step href="#" completed>
|
||||
Start søknad
|
||||
</FormProgress.Step>
|
||||
<FormProgress.Step href="#">Personopplysninger</FormProgress.Step>
|
||||
<FormProgress.Step interactive={false}>
|
||||
Saksopplysninger
|
||||
</FormProgress.Step>
|
||||
<FormProgress.Step interactive={false}>
|
||||
Søknadstekst for en veldig spesifikk prosess i NAV som har lang tekst
|
||||
</FormProgress.Step>
|
||||
<FormProgress.Step href="#">Vedlegg</FormProgress.Step>
|
||||
<FormProgress.Step href="#">Oppsummering</FormProgress.Step>
|
||||
<FormProgress.Step href="#">Innsending</FormProgress.Step>
|
||||
</FormProgress>
|
||||
<p>Noe innhold som dyttes ned</p>
|
||||
</div>
|
||||
);
|
||||
Default.args = { activeStep: 2, totalSteps: 7, interactiveSteps: true };
|
||||
|
||||
export const Controlled: StoryFn = () => {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen(!open)}>State: {open.toString()}</button>
|
||||
<FormProgress
|
||||
totalSteps={5}
|
||||
activeStep={3}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<FormProgress.Step completed>Start søknad</FormProgress.Step>
|
||||
<FormProgress.Step completed interactive={false}>
|
||||
Personopplysninger
|
||||
</FormProgress.Step>
|
||||
<FormProgress.Step>Saksopplysninger</FormProgress.Step>
|
||||
<FormProgress.Step>Vedlegg</FormProgress.Step>
|
||||
<FormProgress.Step interactive={false}>Oppsummering</FormProgress.Step>
|
||||
</FormProgress>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Chromatic: StoryFn = () => (
|
||||
<VStack gap="10">
|
||||
<div>
|
||||
<Default activeStep={1} totalSteps={7} />
|
||||
</div>
|
||||
<div>
|
||||
<Controlled />
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
Chromatic.parameters = { chromatic: { disable: false } };
|
||||
@@ -0,0 +1,152 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { ChevronDownIcon } from "@navikt/aksel-icons";
|
||||
import Button from "../../button/Button";
|
||||
import Collapsible from "../../collapsible/Collapsible";
|
||||
import { HStack } from "../../layout/stack";
|
||||
import { ProgressBar } from "../../progress-bar";
|
||||
import { Stepper, StepperStepProps } from "../../stepper";
|
||||
import { BodyShort } from "../../typography";
|
||||
import { useI18n } from "../../util/i18n/i18n.context";
|
||||
import { ComponentTranslation } from "../../util/i18n/i18n.types";
|
||||
|
||||
export interface FormProgressProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Total number of steps.
|
||||
*/
|
||||
totalSteps: number;
|
||||
/**
|
||||
* Current active step.
|
||||
*
|
||||
* Index starts at 1, not 0.
|
||||
*/
|
||||
activeStep: number;
|
||||
/**
|
||||
* Shows Stepper if `true`, hides if `false`.
|
||||
* Using this prop removes automatic control of open-state.
|
||||
*/
|
||||
open?: boolean;
|
||||
/**
|
||||
* Callback for current open-state
|
||||
*/
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/**
|
||||
* Should contain <FormProgress.Step> elements.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Callback for next `activeStep`.
|
||||
*
|
||||
* Index starts at 1, not 0.
|
||||
*/
|
||||
onStepChange?: (step: number) => void;
|
||||
/**
|
||||
* Makes Stepper non-interactive if false.
|
||||
* @default true
|
||||
*/
|
||||
interactiveSteps?: boolean;
|
||||
/**
|
||||
* i18n API for customizing texts and labels.
|
||||
*/
|
||||
translations?: ComponentTranslation<"FormProgress">;
|
||||
}
|
||||
|
||||
export type FormProgressStepProps = StepperStepProps;
|
||||
|
||||
interface FormProgressComponent
|
||||
extends React.ForwardRefExoticComponent<
|
||||
FormProgressProps & React.RefAttributes<HTMLDivElement>
|
||||
> {
|
||||
/**
|
||||
* To be used inside `<FormProgress>`.
|
||||
*/
|
||||
Step: typeof Stepper.Step;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for visualizing progression in a form with multiple steps.
|
||||
*
|
||||
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/formprogress)
|
||||
* @see 🏷️ {@link FormProgressProps}
|
||||
*
|
||||
* @example
|
||||
* <FormProgress activeStep={2} totalSteps={3}>
|
||||
* <FormProgress.Step completed href="#">Children</FormProgress.Step>
|
||||
* <FormProgress.Step href="#">Personal information</FormProgress.Step>
|
||||
* <FormProgress.Step interactive={false}>Summary</FormProgress.Step>
|
||||
* </FormProgress>
|
||||
*/
|
||||
export const FormProgress = forwardRef<HTMLDivElement, FormProgressProps>(
|
||||
(
|
||||
{
|
||||
totalSteps,
|
||||
activeStep,
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
onStepChange,
|
||||
interactiveSteps,
|
||||
translations,
|
||||
...rest
|
||||
}: FormProgressProps,
|
||||
ref,
|
||||
) => {
|
||||
const translate = useI18n("FormProgress", translations);
|
||||
|
||||
return (
|
||||
<div ref={ref} {...rest}>
|
||||
<ProgressBar
|
||||
aria-hidden
|
||||
value={activeStep}
|
||||
valueMax={totalSteps}
|
||||
className="navds-form-progress__bar"
|
||||
/>
|
||||
|
||||
<Collapsible lazy open={open} onOpenChange={onOpenChange}>
|
||||
<HStack justify="space-between" align="center">
|
||||
<BodyShort as="span">
|
||||
{translate("step", {
|
||||
replacements: { activeStep, totalSteps },
|
||||
})}
|
||||
</BodyShort>
|
||||
<Collapsible.Trigger asChild aria-expanded={undefined}>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="small"
|
||||
className="navds-form-progress__button"
|
||||
icon={<ChevronDownIcon aria-hidden />}
|
||||
>
|
||||
<span className="navds-form-progress__btn-txt-hide">
|
||||
{translate("hideAllSteps")}
|
||||
</span>
|
||||
<span className="navds-form-progress__btn-txt-show">
|
||||
{translate("showAllSteps")}
|
||||
</span>
|
||||
</Button>
|
||||
</Collapsible.Trigger>
|
||||
</HStack>
|
||||
|
||||
<Collapsible.Content className="navds-form-progress__collapsible">
|
||||
<div className="navds-form-progress__collapsible-content">
|
||||
<div className="navds-form-progress__stepper">
|
||||
<Stepper
|
||||
activeStep={activeStep}
|
||||
onStepChange={onStepChange}
|
||||
interactive={interactiveSteps}
|
||||
>
|
||||
{children}
|
||||
</Stepper>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
) as FormProgressComponent;
|
||||
|
||||
FormProgress.Step = Stepper.Step;
|
||||
|
||||
export const FormProgressStep = Stepper.Step;
|
||||
|
||||
export default FormProgress;
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
export {
|
||||
default as FormProgress,
|
||||
type FormProgressProps,
|
||||
FormProgressStep,
|
||||
type FormProgressStepProps,
|
||||
} from "./FormProgress";
|
||||
@@ -0,0 +1,388 @@
|
||||
import { Meta, StoryFn, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { LinkIcon } from "@navikt/aksel-icons";
|
||||
import { VStack } from "../../layout/stack";
|
||||
import { Link } from "../../link";
|
||||
import FormSummary from "./FormSummary";
|
||||
|
||||
const meta: Meta<typeof FormSummary> = {
|
||||
title: "ds-react/FormSummary",
|
||||
component: FormSummary,
|
||||
decorators: [
|
||||
(story) => (
|
||||
<div style={{ width: 600, maxWidth: "100%", margin: "0 auto" }}>
|
||||
{story()}
|
||||
</div>
|
||||
),
|
||||
],
|
||||
parameters: { layout: "padded", chromatic: { disable: true } },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
export const Default: StoryFn<typeof FormSummary> = () => (
|
||||
<FormSummary>
|
||||
<FormSummary.Header>
|
||||
<FormSummary.Heading level="2">Personalia</FormSummary.Heading>
|
||||
<FormSummary.EditLink href="#" />
|
||||
</FormSummary.Header>
|
||||
|
||||
<FormSummary.Answers>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Navn</FormSummary.Label>
|
||||
<FormSummary.Value>Ola Nordmann</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Adresse</FormSummary.Label>
|
||||
<FormSummary.Value>
|
||||
Gate 123
|
||||
<br />
|
||||
1234 Sted
|
||||
</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Fødselsnummer</FormSummary.Label>
|
||||
<FormSummary.Value>12345678910</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Barn nr. 1</FormSummary.Label>
|
||||
<FormSummary.Value>
|
||||
<FormSummary.Answers>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Navn</FormSummary.Label>
|
||||
<FormSummary.Value>Kari Nordmann</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Kjønn</FormSummary.Label>
|
||||
<FormSummary.Value>Jente</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Alder</FormSummary.Label>
|
||||
<FormSummary.Value>6</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
</FormSummary.Answers>
|
||||
</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Barn nr. 2</FormSummary.Label>
|
||||
<FormSummary.Value>
|
||||
<FormSummary.Answers>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Navn</FormSummary.Label>
|
||||
<FormSummary.Value>Per Nordmann</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Kjønn</FormSummary.Label>
|
||||
<FormSummary.Value>Gutt</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Alder</FormSummary.Label>
|
||||
<FormSummary.Value>7</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
</FormSummary.Answers>
|
||||
</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Kontaktinformasjon</FormSummary.Label>
|
||||
<FormSummary.Value>
|
||||
<Link>Fyll ut kontaktinformasjon</Link>
|
||||
</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Hvordan vil du bli varslet?</FormSummary.Label>
|
||||
<FormSummary.Value>E-post</FormSummary.Value>
|
||||
<FormSummary.Value>SMS</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
</FormSummary.Answers>
|
||||
</FormSummary>
|
||||
);
|
||||
|
||||
export const LongTexts: StoryFn<typeof FormSummary> = () => (
|
||||
<FormSummary>
|
||||
<FormSummary.Header>
|
||||
<FormSummary.Heading level="2">
|
||||
Arbeidsforhold som du har i eller utenfor EØS-området
|
||||
</FormSummary.Heading>
|
||||
<FormSummary.EditLink href="#">
|
||||
<LinkIcon />
|
||||
Jeg vil endre dette svaret
|
||||
</FormSummary.EditLink>
|
||||
</FormSummary.Header>
|
||||
<FormSummary.Answers>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>
|
||||
Har du jobbet i et annet EØS-land, Sveits eller Storbritannia i løpet
|
||||
av de siste 36 månedene? Om ja, hvilket land og hvor lenge jobbet du
|
||||
der? Kan du fortelle litt om hva du jobbet med?
|
||||
</FormSummary.Label>
|
||||
<FormSummary.Value>
|
||||
Nei, jeg har ikke jobbet i et annet EØS-land, Sveits eller
|
||||
Storbritannia i løpet av de siste 36 månedene. Og jeg har heller ikke
|
||||
jobbet i Norge i løpet av de siste 36 månedene. Men jeg har jobbet i
|
||||
et annet land utenfor EØS-området. Det var i Sør-Korea, og jeg jobbet
|
||||
som lærer på en internasjonal skole. Jeg jobbet der i 12 måneder, og
|
||||
det var en veldig spennende opplevelse. Jeg lærte mye om koreansk
|
||||
kultur og språk, og jeg fikk mange nye venner. Jeg savner Sør-Korea
|
||||
veldig mye, og jeg håper å kunne dra tilbake.
|
||||
<FormSummary.Answers>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>
|
||||
Har du jobbet i et annet EØS-land, Sveits eller Storbritannia i
|
||||
løpet av de siste 36 månedene? Om ja, hvilket land og hvor lenge
|
||||
jobbet du der? Kan du fortelle litt om hva du jobbet med?
|
||||
</FormSummary.Label>
|
||||
<FormSummary.Value>
|
||||
Nei, jeg har ikke jobbet i et annet EØS-land, Sveits eller
|
||||
Storbritannia i løpet av de siste 36 månedene. Og jeg har heller
|
||||
ikke jobbet i Norge i løpet av de siste 36 månedene. Men jeg har
|
||||
jobbet i et annet land utenfor EØS-området. Det var i Sør-Korea,
|
||||
og jeg jobbet som lærer på en internasjonal skole. Jeg jobbet
|
||||
der i 12 måneder, og det var en veldig spennende opplevelse. Jeg
|
||||
lærte mye om koreansk kultur og språk, og jeg fikk mange nye
|
||||
venner. Jeg savner Sør-Korea veldig mye, og jeg håper å kunne
|
||||
dra tilbake.
|
||||
</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
</FormSummary.Answers>
|
||||
</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
</FormSummary.Answers>
|
||||
</FormSummary>
|
||||
);
|
||||
|
||||
export const NoLink: StoryFn<typeof FormSummary> = () => (
|
||||
<FormSummary>
|
||||
<FormSummary.Header>
|
||||
<FormSummary.Heading level="2">
|
||||
Arbeidsforhold som du har i eller utenfor EØS-området
|
||||
</FormSummary.Heading>
|
||||
</FormSummary.Header>
|
||||
|
||||
<FormSummary.Answers>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Navn</FormSummary.Label>
|
||||
<FormSummary.Value>Ola Nordmann</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Fødselsnummer</FormSummary.Label>
|
||||
<FormSummary.Value>12345678910</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
</FormSummary.Answers>
|
||||
</FormSummary>
|
||||
);
|
||||
|
||||
const answers = [
|
||||
{
|
||||
label: "Fortell din livshistorie",
|
||||
value: `Det hele startet da jeg ble født en kald vinterdag i desember.
|
||||
Jeg var en litt spesiell baby, for jeg hadde masse hår på hodet. Mamma
|
||||
og pappa ble veldig overrasket da de så meg for første gang. De hadde egentlig
|
||||
forventet at jeg skulle være helt normal og kjedelig. Du vil kanskje ikke høre resten
|
||||
av historien min, for den er ganske lang og kjedelig...`,
|
||||
},
|
||||
{
|
||||
label: "Fødselsnummer",
|
||||
value: "12345678910",
|
||||
},
|
||||
{
|
||||
label: "Ting du har i sekken.",
|
||||
value: [
|
||||
{
|
||||
label: "Saks x2",
|
||||
value: `Jeg liker å ha med meg en saks i sekken min. Det er alltid kjekt å ha en saks. Jeg pakker gjerne med meg to saks. Da har jeg en reserve hvis den første saksen min skulle bli borte. Det er veldig kjekt.`,
|
||||
},
|
||||
{
|
||||
label: "Blyant",
|
||||
value:
|
||||
"Det er ikke mulig å skrive uten en blyant. Da blir det bare rot.",
|
||||
},
|
||||
{
|
||||
label: "Kake",
|
||||
value: `Jeg har alltid en kake i sekken min. Den blir litt most etter en
|
||||
stund i sekken min men det gjør ingenting. Jeg liker moste kaker. Det er veldig godt.`,
|
||||
},
|
||||
{
|
||||
label: "Kaffekopp",
|
||||
value: "Dette har jeg ikke i sekken min",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Flere ting du har i sekken.",
|
||||
value: [
|
||||
{
|
||||
label: "Saks",
|
||||
value: `En saks til`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const RealisticUsage: StoryFn<typeof FormSummary> = () => (
|
||||
<FormSummary>
|
||||
<FormSummary.Header>
|
||||
<FormSummary.Heading level="2">
|
||||
Diverse informasjon om forskjellige ting og tang. Kjekt å vite.
|
||||
</FormSummary.Heading>
|
||||
<FormSummary.EditLink href="#" />
|
||||
</FormSummary.Header>
|
||||
|
||||
<FormSummary.Answers>
|
||||
{answers.map((answer, index) => (
|
||||
<FormSummary.Answer key={index}>
|
||||
<FormSummary.Label>{answer.label}</FormSummary.Label>
|
||||
<FormSummary.Value>
|
||||
{Array.isArray(answer.value) ? (
|
||||
<FormSummary.Answers>
|
||||
{answer.value.map((subAnswer, subIndex) => (
|
||||
<FormSummary.Answer key={subIndex}>
|
||||
<FormSummary.Label>{subAnswer.label}</FormSummary.Label>
|
||||
<FormSummary.Value>{subAnswer.value}</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
))}
|
||||
</FormSummary.Answers>
|
||||
) : (
|
||||
answer.value
|
||||
)}
|
||||
</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
))}
|
||||
</FormSummary.Answers>
|
||||
</FormSummary>
|
||||
);
|
||||
|
||||
export const Empty: StoryFn<typeof FormSummary> = () => (
|
||||
<VStack gap="8">
|
||||
<FormSummary>
|
||||
<FormSummary.Header>
|
||||
<FormSummary.Heading level="2">Just Header</FormSummary.Heading>
|
||||
<FormSummary.EditLink href="#" />
|
||||
</FormSummary.Header>
|
||||
</FormSummary>
|
||||
|
||||
<FormSummary>
|
||||
<FormSummary.Header>
|
||||
<FormSummary.Heading level="2">Empty Answers</FormSummary.Heading>
|
||||
<FormSummary.EditLink href="#" />
|
||||
</FormSummary.Header>
|
||||
|
||||
<FormSummary.Answers>{null}</FormSummary.Answers>
|
||||
</FormSummary>
|
||||
|
||||
<FormSummary>
|
||||
<FormSummary.Header>
|
||||
<FormSummary.Heading level="2">Empty Answer</FormSummary.Heading>
|
||||
<FormSummary.EditLink href="#" />
|
||||
</FormSummary.Header>
|
||||
|
||||
<FormSummary.Answers>
|
||||
<FormSummary.Answer>{null}</FormSummary.Answer>
|
||||
</FormSummary.Answers>
|
||||
</FormSummary>
|
||||
|
||||
<FormSummary>
|
||||
<FormSummary.Header>
|
||||
<FormSummary.Heading level="2">Empty Label & Value</FormSummary.Heading>
|
||||
<FormSummary.EditLink href="#" />
|
||||
</FormSummary.Header>
|
||||
|
||||
<FormSummary.Answers>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>{null}</FormSummary.Label>
|
||||
<FormSummary.Value>{null}</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
</FormSummary.Answers>
|
||||
</FormSummary>
|
||||
|
||||
<FormSummary>
|
||||
<FormSummary.Header>
|
||||
<FormSummary.Heading level="2">Empty Value</FormSummary.Heading>
|
||||
<FormSummary.EditLink href="#" />
|
||||
</FormSummary.Header>
|
||||
|
||||
<FormSummary.Answers>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Adresse</FormSummary.Label>
|
||||
<FormSummary.Value>{null}</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
</FormSummary.Answers>
|
||||
</FormSummary>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
export const Chromatic: StoryObj<typeof FormSummary> = {
|
||||
render: () => (
|
||||
<div>
|
||||
<div>
|
||||
<h2>Default</h2>
|
||||
<Default>{null}</Default>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Long Texts</h2>
|
||||
<LongTexts>{null}</LongTexts>
|
||||
</div>
|
||||
<div>
|
||||
<h2>No Link</h2>
|
||||
<NoLink>{null}</NoLink>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Realistic Usage</h2>
|
||||
<RealisticUsage>{null}</RealisticUsage>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Empty</h2>
|
||||
<Empty>{null}</Empty>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
chromatic: {
|
||||
disable: false,
|
||||
modes: {
|
||||
default: {
|
||||
viewport: {
|
||||
width: 620,
|
||||
},
|
||||
},
|
||||
sm: {
|
||||
viewport: {
|
||||
width: 479,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CommaSeparated: StoryFn<typeof FormSummary> = () => (
|
||||
<FormSummary>
|
||||
<FormSummary.Header>
|
||||
<FormSummary.Heading level="2">Personalia</FormSummary.Heading>
|
||||
<FormSummary.EditLink href="#" />
|
||||
</FormSummary.Header>
|
||||
|
||||
<FormSummary.Answers>
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>
|
||||
Hvilket transportmiddel er du mest kjent for?
|
||||
</FormSummary.Label>
|
||||
<FormSummary.Value>
|
||||
sykkel, tog, bil, buss, sparkesykkel
|
||||
</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
|
||||
<FormSummary.Answer>
|
||||
<FormSummary.Label>Favoritt slagord?</FormSummary.Label>
|
||||
<FormSummary.Value>{`I'll be back.`}</FormSummary.Value>
|
||||
<FormSummary.Value>Hasta la vista, baby.</FormSummary.Value>
|
||||
<FormSummary.Value>Say hello to my little friend.</FormSummary.Value>
|
||||
</FormSummary.Answer>
|
||||
</FormSummary.Answers>
|
||||
</FormSummary>
|
||||
);
|
||||
@@ -0,0 +1,106 @@
|
||||
import cl from "clsx";
|
||||
import React, { HTMLAttributes, forwardRef } from "react";
|
||||
import FormSummaryAnswer from "./FormSummaryAnswer";
|
||||
import FormSummaryAnswers from "./FormSummaryAnswers";
|
||||
import FormSummaryEditLink from "./FormSummaryEditLink";
|
||||
import FormSummaryHeader from "./FormSummaryHeader";
|
||||
import FormSummaryHeading from "./FormSummaryHeading";
|
||||
import FormSummaryLabel from "./FormSummaryLabel";
|
||||
import FormSummaryValue from "./FormSummaryValue";
|
||||
|
||||
interface FormSummaryComponent
|
||||
extends React.ForwardRefExoticComponent<
|
||||
FormSummaryProps & React.RefAttributes<HTMLDivElement>
|
||||
> {
|
||||
/**
|
||||
* Must include `<FormSummary.Heading>` and optionally `<FormSummary.EditLink>`.
|
||||
*/
|
||||
Header: typeof FormSummaryHeader;
|
||||
/**
|
||||
* Typographic Heading to use in the `FormSummary.Header` component.
|
||||
*/
|
||||
Heading: typeof FormSummaryHeading;
|
||||
/**
|
||||
* Link to edit the answers to use in the `FormSummary.Header` component. Should link to the relevant part of the form.
|
||||
*/
|
||||
EditLink: typeof FormSummaryEditLink;
|
||||
/**
|
||||
* Wrapper component for the answers.
|
||||
*/
|
||||
Answers: typeof FormSummaryAnswers;
|
||||
/**
|
||||
* Wrapper component for each answer. To be used in the `FormSummary.Answers` component.
|
||||
*/
|
||||
Answer: typeof FormSummaryAnswer;
|
||||
/**
|
||||
* Corresponds to the question in the form. To be used in the `FormSummary.Answer` component.
|
||||
*/
|
||||
Label: typeof FormSummaryLabel;
|
||||
/**
|
||||
* Corresponds to the answer in the form. To be used in the `FormSummary.Answer` component.
|
||||
*/
|
||||
Value: typeof FormSummaryValue;
|
||||
}
|
||||
|
||||
export interface FormSummaryProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Must include:
|
||||
*
|
||||
* - `<FormSummary.Header>`
|
||||
* - `<FormSummary.Answers>`
|
||||
*
|
||||
* @example
|
||||
* <FormSummary>
|
||||
* <FormSummary.Header>
|
||||
* <FormSummary.Heading level="2">HeadingTekst</FormSummary.Heading>
|
||||
* <FormSummary.EditLink href="#" />
|
||||
* </FormSummary.Header>
|
||||
* <FormSummary.Answers>
|
||||
* <FormSummary.Answer>
|
||||
* <FormSummary.Label>Navn</FormSummary.Label>
|
||||
* <FormSummary.Value>Ola Nordmann</FormSummary.Value>
|
||||
* </FormSummary.Answer>
|
||||
* </FormSummary.Answers>
|
||||
* </FormSummary>
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A summary of a previously answered form.
|
||||
*
|
||||
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/formsummary)
|
||||
*
|
||||
* @example
|
||||
* <FormSummary>
|
||||
* <FormSummary.Header>
|
||||
* <FormSummary.Heading level="2">HeadingTekst</FormSummary.Heading>
|
||||
* <FormSummary.EditLink href="#" />
|
||||
* </FormSummary.Header>
|
||||
* <FormSummary.Answers>
|
||||
* <FormSummary.Answer>
|
||||
* <FormSummary.Label>Navn</FormSummary.Label>
|
||||
* <FormSummary.Value>Ola Nordmann</FormSummary.Value>
|
||||
* </FormSummary.Answer>
|
||||
* </FormSummary.Answers>
|
||||
* </FormSummary>
|
||||
*/
|
||||
export const FormSummary = forwardRef<HTMLDivElement, FormSummaryProps>(
|
||||
({ children, className, ...rest }, ref) => {
|
||||
return (
|
||||
<div ref={ref} {...rest} className={cl("navds-form-summary", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
) as FormSummaryComponent;
|
||||
|
||||
FormSummary.Header = FormSummaryHeader;
|
||||
FormSummary.Heading = FormSummaryHeading;
|
||||
FormSummary.EditLink = FormSummaryEditLink;
|
||||
FormSummary.Answers = FormSummaryAnswers;
|
||||
FormSummary.Answer = FormSummaryAnswer;
|
||||
FormSummary.Label = FormSummaryLabel;
|
||||
FormSummary.Value = FormSummaryValue;
|
||||
|
||||
export default FormSummary;
|
||||
@@ -0,0 +1,27 @@
|
||||
import cl from "clsx";
|
||||
import React from "react";
|
||||
|
||||
export interface FormSummaryAnswerProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Must include:
|
||||
* - `<FormSummary.Label>`
|
||||
* - `<FormSummary.Value>`
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FormSummaryAnswer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
FormSummaryAnswerProps
|
||||
>(({ children, className, ...rest }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
{...rest}
|
||||
className={cl("navds-form-summary__answer", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
));
|
||||
|
||||
export default FormSummaryAnswer;
|
||||
@@ -0,0 +1,25 @@
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
export interface FormSummaryAnswersProps
|
||||
extends React.HTMLAttributes<HTMLDListElement> {
|
||||
/**
|
||||
* Must include one or more of `<FormSummary.Answer>`.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FormSummaryAnswers = forwardRef<
|
||||
HTMLDListElement,
|
||||
FormSummaryAnswersProps
|
||||
>(({ children, className, ...rest }: FormSummaryAnswersProps, ref) => (
|
||||
<dl
|
||||
ref={ref}
|
||||
{...rest}
|
||||
className={cl("navds-form-summary__answers", className)}
|
||||
>
|
||||
{children}
|
||||
</dl>
|
||||
));
|
||||
|
||||
export default FormSummaryAnswers;
|
||||
@@ -0,0 +1,35 @@
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef } from "react";
|
||||
import { Link } from "../../link";
|
||||
import { OverridableComponent } from "../../util/types";
|
||||
|
||||
// export type FormSummaryEditProps = Partial<LinkProps>;
|
||||
export interface FormSummaryEditProps
|
||||
extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
/**
|
||||
* URL to the relevant part of the form, where the answers can be edited.
|
||||
*/
|
||||
href?: string;
|
||||
/**
|
||||
* Link text.
|
||||
*
|
||||
* [How to write good links](https://aksel.nav.no/god-praksis/artikler/lenker).
|
||||
* @default "Endre svar"
|
||||
*/
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FormSummaryEditLink: OverridableComponent<
|
||||
FormSummaryEditProps,
|
||||
HTMLAnchorElement
|
||||
> = forwardRef(({ children = "Endre svar", className, ...rest }, ref) => (
|
||||
<Link
|
||||
ref={ref}
|
||||
{...rest}
|
||||
className={cl("navds-form-summary__edit", className)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
));
|
||||
|
||||
export default FormSummaryEditLink;
|
||||
@@ -0,0 +1,25 @@
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
export interface FormSummaryHeaderProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Must include `<FormSummary.Heading>` and optionally `<FormSummary.EditLink>`.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FormSummaryHeader = forwardRef<
|
||||
HTMLDivElement,
|
||||
FormSummaryHeaderProps
|
||||
>(({ children, className, ...rest }, ref) => (
|
||||
<header
|
||||
ref={ref}
|
||||
{...rest}
|
||||
className={cl("navds-form-summary__header", className)}
|
||||
>
|
||||
{children}
|
||||
</header>
|
||||
));
|
||||
|
||||
export default FormSummaryHeader;
|
||||
@@ -0,0 +1,23 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { Heading, HeadingProps } from "../../typography";
|
||||
|
||||
export interface FormSummaryHeadingProps
|
||||
extends React.HTMLAttributes<HTMLHeadingElement> {
|
||||
/**
|
||||
* Heading text.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* The heading level.
|
||||
*/
|
||||
level: Exclude<HeadingProps["level"], "1">;
|
||||
}
|
||||
|
||||
export const FormSummaryHeading = forwardRef<
|
||||
HTMLHeadingElement,
|
||||
FormSummaryHeadingProps
|
||||
>((props: FormSummaryHeadingProps, ref) => (
|
||||
<Heading ref={ref} {...props} size="medium" />
|
||||
));
|
||||
|
||||
export default FormSummaryHeading;
|
||||
@@ -0,0 +1,17 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { Label } from "../../typography";
|
||||
|
||||
export interface FormSummaryLabelProps
|
||||
extends React.HTMLAttributes<HTMLElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FormSummaryLabel = forwardRef<HTMLElement, FormSummaryLabelProps>(
|
||||
({ children, ...rest }, ref) => (
|
||||
<Label ref={ref} {...rest} as="dt">
|
||||
{children}
|
||||
</Label>
|
||||
),
|
||||
);
|
||||
|
||||
export default FormSummaryLabel;
|
||||
@@ -0,0 +1,24 @@
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef } from "react";
|
||||
import { BodyLong } from "../../typography";
|
||||
|
||||
export interface FormSummaryValueProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FormSummaryValue = forwardRef<
|
||||
HTMLDivElement,
|
||||
FormSummaryValueProps
|
||||
>(({ children, className, ...rest }, ref) => (
|
||||
<BodyLong
|
||||
ref={ref}
|
||||
{...rest}
|
||||
as="dd"
|
||||
className={cl("navds-form-summary__value", className)}
|
||||
>
|
||||
{children}
|
||||
</BodyLong>
|
||||
));
|
||||
|
||||
export default FormSummaryValue;
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
export { default as FormSummary, type FormSummaryProps } from "./FormSummary";
|
||||
export {
|
||||
default as FormSummaryAnswer,
|
||||
type FormSummaryAnswerProps,
|
||||
} from "./FormSummaryAnswer";
|
||||
export {
|
||||
default as FormSummaryAnswers,
|
||||
type FormSummaryAnswersProps,
|
||||
} from "./FormSummaryAnswers";
|
||||
export {
|
||||
default as FormSummaryEditLink,
|
||||
type FormSummaryEditProps,
|
||||
} from "./FormSummaryEditLink";
|
||||
export {
|
||||
default as FormSummaryHeader,
|
||||
type FormSummaryHeaderProps,
|
||||
} from "./FormSummaryHeader";
|
||||
export {
|
||||
default as FormSummaryHeading,
|
||||
type FormSummaryHeadingProps,
|
||||
} from "./FormSummaryHeading";
|
||||
export {
|
||||
default as FormSummaryLabel,
|
||||
type FormSummaryLabelProps,
|
||||
} from "./FormSummaryLabel";
|
||||
export {
|
||||
default as FormSummaryValue,
|
||||
type FormSummaryValueProps,
|
||||
} from "./FormSummaryValue";
|
||||
@@ -26,8 +26,8 @@ export { Dropdown, type DropdownProps } from "./dropdown";
|
||||
export { ExpansionCard, type ExpansionCardProps } from "./expansion-card";
|
||||
export {
|
||||
GuidePanel,
|
||||
type GuidePanelProps,
|
||||
GuidePanelDefaultIllustration,
|
||||
type GuidePanelProps,
|
||||
} from "./guide-panel";
|
||||
export { HelpText, type HelpTextProps } from "./help-text";
|
||||
export {
|
||||
@@ -59,6 +59,7 @@ export { Modal, type ModalProps } from "./modal";
|
||||
export { Pagination, type PaginationProps } from "./pagination";
|
||||
export { Popover, type PopoverProps } from "./popover";
|
||||
export { Portal, type PortalProps } from "./portal";
|
||||
export { ProgressBar, type ProgressBarProps } from "./progress-bar";
|
||||
export { Provider, type ProviderProps } from "./provider";
|
||||
export { ReadMore, type ReadMoreProps } from "./read-more";
|
||||
export { Skeleton, type SkeletonProps } from "./skeleton";
|
||||
@@ -125,6 +126,22 @@ export {
|
||||
} from "./form/confirmation-panel";
|
||||
export { ErrorSummary, type ErrorSummaryProps } from "./form/error-summary";
|
||||
export { Fieldset, type FieldsetProps } from "./form/fieldset";
|
||||
export {
|
||||
UNSAFE_FileUpload,
|
||||
type FileAccepted,
|
||||
type FileItem,
|
||||
type FileMetadata,
|
||||
type FileObject,
|
||||
type FileRejected,
|
||||
type FileRejectedPartitioned,
|
||||
type FileRejectionReason,
|
||||
type FileUploadDropzoneProps,
|
||||
type FileUploadItemProps,
|
||||
type FileUploadTriggerProps,
|
||||
type FilesPartitioned,
|
||||
} from "./form/file-upload";
|
||||
export { FormSummary, type FormSummaryProps } from "./form/form-summary";
|
||||
export { FormProgress, type FormProgressProps } from "./form/form-progress";
|
||||
export {
|
||||
Radio,
|
||||
RadioGroup,
|
||||
@@ -136,20 +153,6 @@ export { Select, type SelectProps } from "./form/select";
|
||||
export { Switch, type SwitchProps } from "./form/switch";
|
||||
export { Textarea, type TextareaProps } from "./form/textarea";
|
||||
export { TextField, type TextFieldProps } from "./form/textfield";
|
||||
export {
|
||||
UNSAFE_FileUpload,
|
||||
type FileUploadDropzoneProps,
|
||||
type FileUploadTriggerProps,
|
||||
type FileObject,
|
||||
type FileRejected,
|
||||
type FileAccepted,
|
||||
type FileRejectedPartitioned,
|
||||
type FilesPartitioned,
|
||||
type FileRejectionReason,
|
||||
type FileUploadItemProps,
|
||||
type FileItem,
|
||||
type FileMetadata,
|
||||
} from "./form/file-upload";
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta } from "@storybook/react";
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
LeaveIcon,
|
||||
MenuGridIcon,
|
||||
} from "@navikt/aksel-icons";
|
||||
import { BodyLong, BodyShort, Detail, Spacer } from "@navikt/ds-react";
|
||||
import { BodyLong, BodyShort, Detail, Spacer, VStack } from "@navikt/ds-react";
|
||||
import { Dropdown } from "../dropdown";
|
||||
import InternalHeader from "./InternalHeader";
|
||||
|
||||
@@ -22,8 +22,13 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
type Story = StoryObj<typeof InternalHeader>;
|
||||
|
||||
export const Default = {
|
||||
render: (props) => {
|
||||
return (
|
||||
@@ -227,3 +232,37 @@ export const UserWithMenuGridIconMenu = () => (
|
||||
<InternalHeader.User name="Ola Normann" description="id: 123456" />
|
||||
</InternalHeader>
|
||||
);
|
||||
|
||||
export const Chromatic: Story = {
|
||||
render: () => (
|
||||
<VStack gap="4">
|
||||
<div>
|
||||
<h2>TitleAsHeading</h2>
|
||||
<TitleAsHeading />
|
||||
</div>
|
||||
<div>
|
||||
<h2>TitleAsLink</h2>
|
||||
<TitleAsLink />
|
||||
</div>
|
||||
<div>
|
||||
<h2>User</h2>
|
||||
<User />
|
||||
</div>
|
||||
<div>
|
||||
<h2>UserWithDescription</h2>
|
||||
<UserWithDescription />
|
||||
</div>
|
||||
<div>
|
||||
<h2>UserWithMenu</h2>
|
||||
<UserWithMenu />
|
||||
</div>
|
||||
<div>
|
||||
<h2>UserWithMenuGridIconMenu</h2>
|
||||
<UserWithMenuGridIconMenu />
|
||||
</div>
|
||||
</VStack>
|
||||
),
|
||||
parameters: {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Meta } from "@storybook/react";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import React, { ReactNode } from "react";
|
||||
import { ChevronRightIcon } from "@navikt/aksel-icons";
|
||||
import { BodyLong, Detail, Heading } from "../../typography";
|
||||
@@ -10,9 +10,15 @@ import { Box } from "./Box";
|
||||
const meta: Meta<typeof Box> = {
|
||||
title: "ds-react/Primitives/Box",
|
||||
component: Box,
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Box>;
|
||||
|
||||
export const Default = () => (
|
||||
<Box>
|
||||
<BodyLong>
|
||||
@@ -378,3 +384,77 @@ export const PaddingDemo = () => (
|
||||
</Box>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
export const Chromatic: Story = {
|
||||
render: () => (
|
||||
<VStack gap="2">
|
||||
<div>
|
||||
<h2>Default</h2>
|
||||
<Default />
|
||||
</div>
|
||||
<div>
|
||||
<h2>As card</h2>
|
||||
<AsCard />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Theming demo</h2>
|
||||
<ThemingDemo />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Padding breakpoints</h2>
|
||||
<PaddingBreakpoints.render />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Padding breakpoints inherit 1</h2>
|
||||
<PaddingBreakpointsInherit1.render />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Padding breakpoints inherit 2</h2>
|
||||
<PaddingBreakpointsInherit2 />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Padding</h2>
|
||||
<Padding />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Box in box</h2>
|
||||
<BoxInBox />
|
||||
</div>
|
||||
<div>
|
||||
<h2>With HGrid</h2>
|
||||
<WithHGrid />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Border width</h2>
|
||||
<BorderWidth />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Border radius</h2>
|
||||
<BorderRadius />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Padding demo</h2>
|
||||
<PaddingDemo />
|
||||
</div>
|
||||
</VStack>
|
||||
),
|
||||
parameters: {
|
||||
chromatic: {
|
||||
disable: false,
|
||||
modes: {
|
||||
mobile_portrait: {
|
||||
viewport: {
|
||||
width: 400,
|
||||
height: 850,
|
||||
},
|
||||
},
|
||||
desktop: {
|
||||
viewport: {
|
||||
width: 1280,
|
||||
height: 960,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { HGrid } from ".";
|
||||
import { VStack } from "../stack";
|
||||
@@ -13,6 +14,7 @@ export default {
|
||||
component: HGrid,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
argTypes: {
|
||||
columnsType: {
|
||||
@@ -21,7 +23,9 @@ export default {
|
||||
control: { type: "radio" },
|
||||
},
|
||||
},
|
||||
};
|
||||
} as Meta;
|
||||
|
||||
type Story = StoryObj<typeof HGrid>;
|
||||
|
||||
/* const getColumnsProp = () */
|
||||
|
||||
@@ -106,6 +110,21 @@ export const AlignItems = {
|
||||
),
|
||||
};
|
||||
|
||||
export const Nested = {
|
||||
render: () => (
|
||||
<HGrid columns={{ xs: 1, sm: 2 }} gap="8">
|
||||
<HGrid columns={2} gap="2">
|
||||
<Placeholder text="1" />
|
||||
<Placeholder text="2" />
|
||||
</HGrid>
|
||||
<HGrid columns={2} gap="2">
|
||||
<Placeholder text="3" />
|
||||
<Placeholder text="4" />
|
||||
</HGrid>
|
||||
</HGrid>
|
||||
),
|
||||
};
|
||||
|
||||
function Placeholder({ text, height }: { text: string; height?: string }) {
|
||||
return (
|
||||
<div
|
||||
@@ -120,3 +139,54 @@ function Placeholder({ text, height }: { text: string; height?: string }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Chromatic: Story = {
|
||||
render: () => (
|
||||
<VStack gap="4">
|
||||
<div>
|
||||
<h2>Gap</h2>
|
||||
<Gap.render />
|
||||
</div>
|
||||
<div>
|
||||
<h2>DynamicGap</h2>
|
||||
<DynamicGap.render />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Columns</h2>
|
||||
<Columns.render />
|
||||
</div>
|
||||
<div>
|
||||
<h2>DynamicColumns</h2>
|
||||
<DynamicColumns.render />
|
||||
</div>
|
||||
<div>
|
||||
<h2>AlignItems</h2>
|
||||
<AlignItems.render />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Nested</h2>
|
||||
<Nested.render />
|
||||
</div>
|
||||
</VStack>
|
||||
),
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
chromatic: {
|
||||
disable: false,
|
||||
modes: {
|
||||
mobile_portrait: {
|
||||
viewport: {
|
||||
width: 400,
|
||||
height: 850,
|
||||
},
|
||||
},
|
||||
desktop: {
|
||||
viewport: {
|
||||
width: 1280,
|
||||
height: 960,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -37,7 +37,7 @@ export const Default: StoryFn = ({
|
||||
Default.argTypes = {
|
||||
width: {
|
||||
control: "radio",
|
||||
options: widths,
|
||||
options: [...widths],
|
||||
},
|
||||
background: {
|
||||
control: "radio",
|
||||
|
||||
@@ -12,6 +12,6 @@ import React from "react";
|
||||
* <MyComponent />
|
||||
* </HStack>
|
||||
*/
|
||||
export const Spacer = () => <div className="navds-stack__spacer" />;
|
||||
export const Spacer = () => <span className="navds-stack__spacer" />;
|
||||
|
||||
export default Spacer;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta } from "@storybook/react";
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import {
|
||||
BabyWrappedIcon,
|
||||
@@ -12,8 +12,13 @@ import List from "./List";
|
||||
export default {
|
||||
title: "ds-react/List",
|
||||
component: List,
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
type Story = StoryObj<typeof List>;
|
||||
|
||||
export const Default = {
|
||||
render: () => {
|
||||
return (
|
||||
@@ -278,3 +283,45 @@ export const Nested = {
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Chromatic: Story = {
|
||||
render: () => (
|
||||
<VStack gap="2">
|
||||
<div>
|
||||
<h2>Default</h2>
|
||||
<Default.render />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Ordered</h2>
|
||||
<Ordered.render />
|
||||
</div>
|
||||
<div>
|
||||
<h2>WithHeading</h2>
|
||||
<WithHeading.render />
|
||||
</div>
|
||||
<div>
|
||||
<h2>SizesUl</h2>
|
||||
<SizesUl.render />
|
||||
</div>
|
||||
<div>
|
||||
<h2>SizesOl</h2>
|
||||
<SizesOl.render />
|
||||
</div>
|
||||
<div>
|
||||
<h2>SizesIcons</h2>
|
||||
<SizesIcons.render />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Icons</h2>
|
||||
<Icons.render />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Nested</h2>
|
||||
<Nested.render />
|
||||
</div>
|
||||
</VStack>
|
||||
),
|
||||
parameters: {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Meta } from "@storybook/react";
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { VStack } from "../layout/stack";
|
||||
import Loader, { LoaderProps } from "./Loader";
|
||||
|
||||
export default {
|
||||
@@ -27,8 +28,13 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
type Story = StoryObj<typeof Loader>;
|
||||
|
||||
export const Default = {
|
||||
render: (props: LoaderProps) => {
|
||||
return <Loader {...props} />;
|
||||
@@ -80,3 +86,25 @@ export const Transparent = () => (
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Chromatic: Story = {
|
||||
render: () => (
|
||||
<VStack gap="2">
|
||||
<div>
|
||||
<h2>Size</h2>
|
||||
<Size />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Variant</h2>
|
||||
<Variant />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Transparent</h2>
|
||||
<Transparent />
|
||||
</div>
|
||||
</VStack>
|
||||
),
|
||||
parameters: {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
};
|
||||
@@ -146,6 +146,7 @@ export const WithUseState: StoryFn = () => {
|
||||
);
|
||||
};
|
||||
WithUseState.storyName = "With useState";
|
||||
WithUseState.parameters = { chromatic: { disable: true } };
|
||||
|
||||
export const EmptyHeader: StoryFn = () => (
|
||||
<Modal open onClose={() => null} aria-label="Modal with empty header">
|
||||
@@ -225,6 +226,7 @@ export const WithTooltip: StoryFn = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
WithTooltip.parameters = { chromatic: { disable: true } }; // The tooltip behaves unpredictably in Chromatic
|
||||
|
||||
export const WithSrOnlyElement: StoryFn = () => (
|
||||
<Modal
|
||||
@@ -330,12 +332,6 @@ ChromaticViewportTesting.parameters = {
|
||||
height: 400,
|
||||
},
|
||||
},
|
||||
desktop: {
|
||||
viewport: {
|
||||
width: 1280,
|
||||
height: 960,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# DismissableLayer API
|
||||
|
||||
`DismissableLayer`-API provides a robust handler for layers that can be dismissed by interacting outside of it.
|
||||
|
||||
One of the benefits is its ability to handle nested elements, relevant when creating nested dropdowns and similar solutions.
|
||||
@@ -0,0 +1,201 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import { DismissableLayer } from "./DismissableLayer";
|
||||
|
||||
export default {
|
||||
title: "Utilities/DismissableLayer",
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const openButtonRef = useRef(null);
|
||||
|
||||
const [dismissOnEscape, setDismissOnEscape] = useState(false);
|
||||
const [dismissOnPointerDownOutside, setDismissOnPointerDownOutside] =
|
||||
useState(false);
|
||||
const [dismissOnFocusOutside, setDismissOnFocusOutside] = useState(false);
|
||||
const [disabledOutsidePointerEvents, setDisableOutsidePointerEvents] =
|
||||
useState(false);
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: "sans-serif", textAlign: "center" }}>
|
||||
<h1>DismissableLayer</h1>
|
||||
|
||||
<div
|
||||
style={{ display: "inline-block", textAlign: "left", marginBottom: 20 }}
|
||||
>
|
||||
<label style={{ display: "block" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dismissOnEscape}
|
||||
onChange={(event) => setDismissOnEscape(event.target.checked)}
|
||||
/>{" "}
|
||||
Dismiss on escape?
|
||||
</label>
|
||||
|
||||
<label style={{ display: "block" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dismissOnPointerDownOutside}
|
||||
onChange={(event) =>
|
||||
setDismissOnPointerDownOutside(event.target.checked)
|
||||
}
|
||||
/>{" "}
|
||||
Dismiss on pointer down outside?
|
||||
</label>
|
||||
|
||||
<label style={{ display: "block" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dismissOnFocusOutside}
|
||||
onChange={(event) => setDismissOnFocusOutside(event.target.checked)}
|
||||
/>{" "}
|
||||
Dismiss on focus outside?
|
||||
</label>
|
||||
|
||||
<hr />
|
||||
|
||||
<label style={{ display: "block" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={disabledOutsidePointerEvents}
|
||||
onChange={(event) =>
|
||||
setDisableOutsidePointerEvents(event.target.checked)
|
||||
}
|
||||
/>{" "}
|
||||
Disable outside pointer events?
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<button
|
||||
ref={openButtonRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((x) => !x)}
|
||||
>
|
||||
{open ? "Close" : "Open"} layer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<DismissableLayer
|
||||
asChild
|
||||
onEscapeKeyDown={(event) => {
|
||||
console.log("Escape key down");
|
||||
if (dismissOnEscape === false) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
console.log("Pointer down outside");
|
||||
if (
|
||||
dismissOnPointerDownOutside === false ||
|
||||
event.target === openButtonRef.current
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onFocusOutside={(event) => {
|
||||
console.log("Focus outside");
|
||||
if (dismissOnFocusOutside === false) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
disableOutsidePointerEvents={disabledOutsidePointerEvents}
|
||||
onDismiss={() => setOpen(false)}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
verticalAlign: "middle",
|
||||
width: 400,
|
||||
height: 300,
|
||||
backgroundColor: "black",
|
||||
borderRadius: 10,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<input type="text" />
|
||||
</DismissableLayer>
|
||||
) : null}
|
||||
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<input type="text" defaultValue="hello" style={{ marginRight: 20 }} />
|
||||
<button type="button" onMouseDown={() => alert("hey!")}>
|
||||
hey!
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Nested = () => {
|
||||
return (
|
||||
<div style={{ fontFamily: "sans-serif", textAlign: "center" }}>
|
||||
<h1>DismissableLayer (nested)</h1>
|
||||
<DismissableBox index={0} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function DismissableBox(props) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const openButtonRef = React.useRef(null);
|
||||
|
||||
return (
|
||||
<DismissableLayer
|
||||
{...props}
|
||||
onEscapeKeyDown={() => {
|
||||
console.log("Escape key down");
|
||||
}}
|
||||
disableOutsidePointerEvents={props.index % 3 === 0}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
verticalAlign: "middle",
|
||||
padding: 20,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.2)",
|
||||
borderRadius: 10,
|
||||
marginTop: 20,
|
||||
...props.style,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
ref={openButtonRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((x) => !x)}
|
||||
>
|
||||
{open ? "Close" : "Open"} new layer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<DismissableBox
|
||||
index={props.index + 1}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (event.target === openButtonRef.current) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onFocusOutside={(event) => event.preventDefault()}
|
||||
onDismiss={() => setOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</DismissableLayer>
|
||||
);
|
||||
}
|
||||
|
||||
export const DisableOutsidePointerEventsWhileHidden = () => (
|
||||
<div>
|
||||
<DismissableLayer disableOutsidePointerEvents={true} enabled={false}>
|
||||
<div style={{ height: 100, width: 100, background: "red" }}>
|
||||
<DismissableLayer disableOutsidePointerEvents={true} enabled={false}>
|
||||
<div style={{ height: 50, width: 50, background: "blue" }} />
|
||||
</DismissableLayer>
|
||||
</div>
|
||||
</DismissableLayer>
|
||||
<button onClick={console.log}>Should be clickable</button>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,368 @@
|
||||
import React, {
|
||||
CSSProperties,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Slot } from "../../util/Slot";
|
||||
import { useMergeRefs } from "../../util/hooks";
|
||||
import { createDescendantContext } from "../../util/hooks/descendants/useDescendant";
|
||||
import { AsChild } from "../../util/types/AsChild";
|
||||
import {
|
||||
CustomFocusEvent,
|
||||
CustomPointerDownEvent,
|
||||
} from "./util/dispatchCustomEvent";
|
||||
import { useEscapeKeydown } from "./util/useEscapeKeydown";
|
||||
import { useFocusOutside } from "./util/useFocusOutside";
|
||||
import { usePointerDownOutside } from "./util/usePointerDownOutside";
|
||||
|
||||
interface DismissableLayerBaseProps {
|
||||
/**
|
||||
* When `true`, hover/focus/click interactions will be disabled on elements outside
|
||||
* the `DismissableLayer`. Users will need to click twice on outside elements to
|
||||
* interact with them: once to close the `DismissableLayer`, and again to trigger the element.
|
||||
*/
|
||||
disableOutsidePointerEvents?: boolean;
|
||||
/**
|
||||
* Event handler called when the escape key is down.
|
||||
* Can be prevented.
|
||||
*/
|
||||
onEscapeKeyDown?: (event: KeyboardEvent) => void;
|
||||
/**
|
||||
* Event handler called when the a `pointerdown` event happens outside of the `DismissableLayer`.
|
||||
* Can be prevented.
|
||||
*/
|
||||
onPointerDownOutside?: (event: CustomPointerDownEvent) => void;
|
||||
/**
|
||||
* Event handler called when the focus moves outside of the `DismissableLayer`.
|
||||
* Can be prevented.
|
||||
*/
|
||||
onFocusOutside?: (event: CustomFocusEvent) => void;
|
||||
/**
|
||||
* Event handler called when an interaction happens outside the `DismissableLayer`.
|
||||
* Specifically, when a `pointerdown` event happens outside or focus moves outside of it.
|
||||
* Can be prevented.
|
||||
*/
|
||||
onInteractOutside?: (
|
||||
event: CustomPointerDownEvent | CustomFocusEvent,
|
||||
) => void;
|
||||
/**
|
||||
* Handler called when the `DismissableLayer` should be dismissed
|
||||
*/
|
||||
onDismiss?: () => void;
|
||||
/**
|
||||
* Stops `onDismiss` from beeing called when interacting with the `safeZone` elements.
|
||||
* `safeZone.dismissable` is only needed when its element does not have a `tabIndex` since it will not receive focus-events.
|
||||
*/
|
||||
safeZone?: {
|
||||
anchor?: Element | null;
|
||||
dismissable?: Element | null;
|
||||
};
|
||||
|
||||
style?: CSSProperties;
|
||||
/**
|
||||
* Disables layer from beeing counted in context for nested `DismissableLayer`.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
type DismissableLayerProps = DismissableLayerBaseProps & AsChild;
|
||||
|
||||
export const [
|
||||
DismissableDescendantsProvider,
|
||||
useDismissableDescendantsContext,
|
||||
useDismissableDescendants,
|
||||
useDismissableDescendant,
|
||||
] = createDescendantContext<
|
||||
HTMLDivElement,
|
||||
{ disableOutsidePointerEvents: boolean }
|
||||
>();
|
||||
|
||||
let originalBodyPointerEvents: string;
|
||||
|
||||
const DismissableLayer = forwardRef<HTMLDivElement, DismissableLayerProps>(
|
||||
(props: DismissableLayerProps, ref) => {
|
||||
const context = useDismissableDescendantsContext(false);
|
||||
|
||||
/**
|
||||
* To correctly handle nested DismissableLayer,
|
||||
* we only initialize the `Descendants`-API for the root layer to aboid resetting context
|
||||
*/
|
||||
return context ? (
|
||||
<DismissableLayerNode ref={ref} {...props} />
|
||||
) : (
|
||||
<DismissableRoot>
|
||||
<DismissableLayerNode ref={ref} {...props} />
|
||||
</DismissableRoot>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* DismissableRoot
|
||||
*
|
||||
* Used to initialize the `Descendants`-API at the root layer.
|
||||
* All subsequent layers will use the same context.
|
||||
*/
|
||||
const DismissableRoot = ({ children }: { children: React.ReactNode }) => {
|
||||
const descendants = useDismissableDescendants();
|
||||
|
||||
return (
|
||||
<DismissableDescendantsProvider value={descendants}>
|
||||
{children}
|
||||
</DismissableDescendantsProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const DismissableLayerNode = forwardRef<HTMLDivElement, DismissableLayerProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
asChild,
|
||||
onEscapeKeyDown,
|
||||
onPointerDownOutside,
|
||||
onFocusOutside,
|
||||
onInteractOutside,
|
||||
onDismiss,
|
||||
safeZone,
|
||||
disableOutsidePointerEvents = false,
|
||||
enabled = true,
|
||||
...rest
|
||||
}: DismissableLayerProps,
|
||||
ref,
|
||||
) => {
|
||||
const { register, index, descendants } = useDismissableDescendant({
|
||||
disableOutsidePointerEvents,
|
||||
disabled: !enabled,
|
||||
});
|
||||
|
||||
/**
|
||||
* `node` will be set to the ref of the component or nested component
|
||||
* Ex: If
|
||||
* ```
|
||||
* <DismissableLayer asChild>
|
||||
* <Popover />
|
||||
* </DismissableLayer>
|
||||
* ```
|
||||
* `node` will in this case be the Popover-element.
|
||||
* We use State her and not ref since we want to trigger a rerender when the node changes.
|
||||
*/
|
||||
const [node, setNode] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const mergedRefs = useMergeRefs(setNode, register, ref);
|
||||
|
||||
/**
|
||||
* In some cases the `node.ownerDocument` can differ from global document.
|
||||
* This can happend when portaling elements or using web-components
|
||||
*/
|
||||
const ownerDocument = node?.ownerDocument ?? globalThis?.document;
|
||||
|
||||
const hasInteractedOutsideRef = useRef(false);
|
||||
const hasPointerDownOutsideRef = useRef(false);
|
||||
|
||||
const pointerEnabled = useMemo(() => {
|
||||
let lastIndex = -1;
|
||||
|
||||
const descendantNodes = descendants.enabledValues();
|
||||
descendantNodes.forEach((obj, _index) => {
|
||||
if (obj.disableOutsidePointerEvents) {
|
||||
lastIndex = _index;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
/**
|
||||
* Makes sure we stop events at the highest layer with pointer events disabled.
|
||||
* If not checked, we risk closing every layer when clicking outside the layer.
|
||||
*/
|
||||
isPointerEventsEnabled: index >= lastIndex,
|
||||
/**
|
||||
* If we find a node with `disableOutsidePointerEvents` we want to disable pointer events on the body.
|
||||
*/
|
||||
isBodyPointerEventsDisabled: lastIndex > -1,
|
||||
};
|
||||
}, [descendants, index]);
|
||||
|
||||
/**
|
||||
* We want to prevent the Layer from closing when the trigger, anchor element, or its child elements are interacted with.
|
||||
*
|
||||
* To achieve this, we check if the event target is the trigger, anchor or a child. If it is, we prevent default event behavior.
|
||||
*
|
||||
* The `pointerDownOutside` and `focusOutside` handlers already check if the event target is within the DismissableLayer (`node`).
|
||||
* However, since we don't add a `tabIndex` to the Popover/Tooltip, the `focusOutside` handler doesn't correctly handle focus events.
|
||||
* Therefore, we also need to check that neither the trigger (`anchor`) nor the DismissableLayer (`dismissable`) are the event targets.
|
||||
*/
|
||||
function handleOutsideEvent(
|
||||
event: CustomFocusEvent | CustomPointerDownEvent,
|
||||
) {
|
||||
if ((!safeZone?.anchor && !safeZone?.dismissable) || !enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.defaultPrevented) {
|
||||
hasInteractedOutsideRef.current = true;
|
||||
if (event.detail.originalEvent.type === "pointerdown") {
|
||||
hasPointerDownOutsideRef.current = true;
|
||||
}
|
||||
}
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
/**
|
||||
* pointerdown-events works as expected, but focus-events does not.
|
||||
* For focus-event we need to also check `safeZone.dismissable` (the Popover/Tooltip itself) since it does not have a tabIndex.
|
||||
*/
|
||||
if (event.detail.originalEvent.type === "pointerdown") {
|
||||
const targetIsTrigger =
|
||||
safeZone?.anchor?.contains(target) || target === safeZone?.anchor;
|
||||
targetIsTrigger && event.preventDefault();
|
||||
} else {
|
||||
const targetIsNotTrigger =
|
||||
target instanceof HTMLElement &&
|
||||
![safeZone?.anchor, safeZone?.dismissable].some(
|
||||
(element) => element?.contains(target as Node),
|
||||
) &&
|
||||
!target.contains(safeZone?.dismissable ?? null);
|
||||
|
||||
!targetIsNotTrigger && event.preventDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* In Safari, if the trigger element is inside a container with tabIndex={0}, a click on the trigger
|
||||
* will first fire a 'pointerdownoutside' event on the trigger itself. However, it will then fire a
|
||||
* 'focusoutside' event on the container.
|
||||
*
|
||||
* To handle this, we ignore any 'focusoutside' events if a 'pointerdownoutside' event has already occurred.
|
||||
* 'pointerdownoutside' event is sufficient to indicate interaction outside the DismissableLayer.
|
||||
*/
|
||||
if (
|
||||
event.detail.originalEvent.type === "focusin" &&
|
||||
hasPointerDownOutsideRef.current
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
hasPointerDownOutsideRef.current = false;
|
||||
hasInteractedOutsideRef.current = false;
|
||||
}
|
||||
|
||||
const pointerDownOutside = usePointerDownOutside((event) => {
|
||||
if (!pointerEnabled.isPointerEventsEnabled || !enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* We call these before letting `handleOutsideEvent` do its checks to give consumer a chance to preventDefault based certain cases.
|
||||
*/
|
||||
onPointerDownOutside?.(event);
|
||||
onInteractOutside?.(event);
|
||||
|
||||
/**
|
||||
* Add safeZone to prevent closing when interacting with trigger/anchor or its children.
|
||||
*/
|
||||
safeZone && handleOutsideEvent(event);
|
||||
|
||||
/**
|
||||
* Both `onPointerDownOutside` and `onInteractOutside` are able to preventDefault the event, thus stopping call for `onDismiss`.
|
||||
*/
|
||||
if (!event.defaultPrevented && onDismiss) {
|
||||
onDismiss();
|
||||
}
|
||||
}, ownerDocument);
|
||||
|
||||
const focusOutside = useFocusOutside((event) => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* We call these before letting `handleOutsideEvent` do its checks to give consumer a chance to preventDefault based certain cases.
|
||||
*/
|
||||
onFocusOutside?.(event);
|
||||
onInteractOutside?.(event);
|
||||
|
||||
/**
|
||||
* Add safeZone to prevent closing when interacting with trigger/anchor or its children.
|
||||
*/
|
||||
safeZone && handleOutsideEvent(event);
|
||||
|
||||
/**
|
||||
* Both `onFocusOutside` and `onInteractOutside` are able to preventDefault the event, thus stopping call for `onDismiss`.
|
||||
*/
|
||||
if (!event.defaultPrevented && onDismiss) {
|
||||
onDismiss();
|
||||
}
|
||||
}, ownerDocument);
|
||||
|
||||
useEscapeKeydown((event) => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* The deepest nested element will always be last in the descendants list.
|
||||
* This allows us to only close the highest layer when pressing escape.
|
||||
*
|
||||
* In some cases a layer might still exist, but be disabled. We want to ignore these layers.
|
||||
*/
|
||||
const isHighestLayer = index === descendants.enabledCount() - 1;
|
||||
if (!isHighestLayer) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* We call this before letting `handleOutsideEvent` do its checks to give consumer a chance to preventDefault based certain cases.
|
||||
*/
|
||||
onEscapeKeyDown?.(event);
|
||||
/**
|
||||
* `onEscapeKeyDown` is able to preventDefault the event, thus stopping call for `onDismiss`.
|
||||
* We want to `preventDefault` the escape-event to avoid sideeffect from other elements on screen
|
||||
*/
|
||||
if (!event.defaultPrevented && onDismiss) {
|
||||
event.preventDefault();
|
||||
onDismiss();
|
||||
}
|
||||
}, ownerDocument);
|
||||
|
||||
/**
|
||||
* If `disableOutsidePointerEvents` is true,
|
||||
* we want to disable pointer events on the body when the first layer is opened.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!node || !disableOutsidePointerEvents || index !== 0 || !enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
originalBodyPointerEvents = ownerDocument.body.style.pointerEvents;
|
||||
ownerDocument.body.style.pointerEvents = "none";
|
||||
|
||||
return () => {
|
||||
ownerDocument.body.style.pointerEvents = originalBodyPointerEvents;
|
||||
};
|
||||
}, [node, ownerDocument, disableOutsidePointerEvents, index, enabled]);
|
||||
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={mergedRefs}
|
||||
{...rest}
|
||||
onFocusCapture={focusOutside.onFocusCapture}
|
||||
onBlurCapture={focusOutside.onBlurCapture}
|
||||
onPointerDownCapture={pointerDownOutside.onPointerDownCapture}
|
||||
style={{
|
||||
pointerEvents: pointerEnabled.isBodyPointerEventsDisabled
|
||||
? pointerEnabled.isPointerEventsEnabled
|
||||
? "auto"
|
||||
: "none"
|
||||
: undefined,
|
||||
...rest.style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export { DismissableLayer, type DismissableLayerProps };
|
||||
@@ -0,0 +1,77 @@
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
type CustomFocusEvent = CustomEvent<{ originalEvent: FocusEvent }>;
|
||||
type CustomPointerDownEvent = CustomEvent<{
|
||||
originalEvent: PointerEvent;
|
||||
}>;
|
||||
|
||||
export { CustomFocusEvent, CustomPointerDownEvent };
|
||||
|
||||
export const CUSTOM_EVENTS = {
|
||||
FOCUS_OUTSIDE: "AKSEL_FOCUS_OUTSIDE",
|
||||
POINTER_DOWN_OUTSIDE: "AKSEL_POINTER_DOWN_OUTSIDE",
|
||||
};
|
||||
|
||||
/**
|
||||
* Use of `discrete` flushes custom event dispatch. This is to mimic the behavior React has for `discrete` events.
|
||||
* https://github.com/facebook/react/blob/a8a4742f1c54493df00da648a3f9d26e3db9c8b5/packages/react-dom/src/events/ReactDOMEventListener.js#L318
|
||||
*
|
||||
* React batches *all* event handlers since version 18, this introduces certain considerations when using custom event types.
|
||||
*
|
||||
* Internally, React prioritises events in the following order:
|
||||
* - discrete
|
||||
* - continuous
|
||||
* - default
|
||||
*
|
||||
* `discrete` is an important distinction as updates within these events are applied immediately.
|
||||
* React however, is not able to infer the priority of custom event types due to how they are detected internally.
|
||||
* Because of this, it's possible for updates from custom events to be unexpectedly batched when
|
||||
* dispatched by another `discrete` event.
|
||||
*
|
||||
* In order to ensure that updates from custom events are applied predictably, we need to manually flush the batch.
|
||||
* This utility should be used when dispatching a custom event from within another `discrete` event, this utility
|
||||
* is not nessesary when dispatching known event types, or if dispatching a custom type inside a non-discrete event.
|
||||
* For example:
|
||||
*
|
||||
* dispatching a known click 👎
|
||||
* target.dispatchEvent(new Event(‘click’))
|
||||
*
|
||||
* dispatching a custom type within a non-discrete event 👎
|
||||
* onScroll={(event) => event.target.dispatchEvent(new CustomEvent(‘customType’))}
|
||||
*
|
||||
* dispatching a custom type within a `discrete` event 👍
|
||||
* onPointerDown={(event) => dispatchDiscreteCustomEvent(event.target, new CustomEvent(‘customType’))}
|
||||
*
|
||||
* Note: though React classifies `focus`, `focusin` and `focusout` events as `discrete`, it's not recommended to use
|
||||
* this utility with them. This is because it's possible for those handlers to be called implicitly during render
|
||||
* e.g. when focus is within a component as it is unmounted, or when managing focus on mount.
|
||||
*/
|
||||
export function dispatchCustomEvent<
|
||||
E extends CustomEvent,
|
||||
OriginalEvent extends Event,
|
||||
>(
|
||||
name: string,
|
||||
handler: ((event: E) => void) | undefined,
|
||||
detail: { originalEvent: OriginalEvent } & (E extends CustomEvent<infer D>
|
||||
? D
|
||||
: never),
|
||||
{ discrete }: { discrete: boolean } = { discrete: false },
|
||||
) {
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
const target = detail.originalEvent.target;
|
||||
const event = new CustomEvent(name, {
|
||||
bubbles: false,
|
||||
cancelable: true,
|
||||
detail,
|
||||
});
|
||||
|
||||
target.addEventListener(name, handler as EventListener, { once: true });
|
||||
|
||||
if (discrete && target) {
|
||||
ReactDOM.flushSync(() => target.dispatchEvent(event));
|
||||
} else {
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect } from "react";
|
||||
import { useCallbackRef } from "../../../util/hooks";
|
||||
|
||||
export function useEscapeKeydown(
|
||||
callback?: (event: KeyboardEvent) => void,
|
||||
ownerDocument: Document = globalThis?.document,
|
||||
) {
|
||||
const onEscapeKeyDown = useCallbackRef(callback);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onEscapeKeyDown(event);
|
||||
}
|
||||
};
|
||||
|
||||
ownerDocument.addEventListener("keydown", handleKeyDown, true);
|
||||
return () =>
|
||||
ownerDocument.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [onEscapeKeyDown, ownerDocument]);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCallbackRef } from "../../../util/hooks";
|
||||
import {
|
||||
CUSTOM_EVENTS,
|
||||
CustomFocusEvent,
|
||||
dispatchCustomEvent,
|
||||
} from "./dispatchCustomEvent";
|
||||
|
||||
/**
|
||||
* Tracks focus outside a React subtree. Returns props for the subtree root.
|
||||
*/
|
||||
export function useFocusOutside(
|
||||
callback?: (event: CustomFocusEvent) => void,
|
||||
ownerDocument: Document = globalThis?.document,
|
||||
) {
|
||||
const handleFocusOutside = useCallbackRef(callback) as EventListener;
|
||||
const isFocusInsideReactTreeRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleFocus = (event: FocusEvent) => {
|
||||
if (event.target && !isFocusInsideReactTreeRef.current) {
|
||||
const eventDetail = { originalEvent: event };
|
||||
|
||||
/**
|
||||
* The `DismisableLayer`-API is based on the ability to stop events from propagating and in the end calling `onDismiss`
|
||||
* if `useFocusOutside`-callback runs `event.preventDefault()`.
|
||||
*
|
||||
* Because of the `focusin`-event not being cancelable,
|
||||
* we need to use a custom event to ensure that the event is cancelable.
|
||||
* https://developer.mozilla.org/en-US/docs/Web/API/Element/focusin_event
|
||||
* > The focusin event is not cancelable.
|
||||
*/
|
||||
dispatchCustomEvent(
|
||||
CUSTOM_EVENTS.FOCUS_OUTSIDE,
|
||||
handleFocusOutside,
|
||||
eventDetail,
|
||||
);
|
||||
}
|
||||
};
|
||||
ownerDocument.addEventListener("focusin", handleFocus);
|
||||
return () => ownerDocument.removeEventListener("focusin", handleFocus);
|
||||
}, [ownerDocument, handleFocusOutside]);
|
||||
|
||||
/**
|
||||
* By directly setting isFocusInsideReactTreeRef on focus-events at the root of the "dismissable" element,
|
||||
* we can eliminate the need for DOM traversal to verify if the focused element is within the react tree.
|
||||
*/
|
||||
return {
|
||||
onFocusCapture: () => (isFocusInsideReactTreeRef.current = true),
|
||||
onBlurCapture: () => (isFocusInsideReactTreeRef.current = false),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCallbackRef } from "../../../util/hooks";
|
||||
import {
|
||||
CUSTOM_EVENTS,
|
||||
CustomPointerDownEvent,
|
||||
dispatchCustomEvent,
|
||||
} from "./dispatchCustomEvent";
|
||||
|
||||
/**
|
||||
* Listens for `pointerdown` outside a react subtree. We use `pointerdown` rather than `pointerup`
|
||||
* to mimic layer dismissing behaviour present in OS.
|
||||
* Returns props to pass to the node we want to check for outside events.
|
||||
* By checking `isPointerInsideReactTreeRef` we can determine if the event happened outside the subtree of the node, saving some element-comparisons.
|
||||
*/
|
||||
export function usePointerDownOutside(
|
||||
callback?: (event: CustomPointerDownEvent) => void,
|
||||
ownerDocument: Document = globalThis?.document,
|
||||
) {
|
||||
const handlePointerDownOutside = useCallbackRef(callback) as EventListener;
|
||||
const isPointerInsideReactTreeRef = useRef(false);
|
||||
const handleClickRef = useRef<typeof handlePointerDownOutside>(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
/**
|
||||
* The `DismisableLayer`-API is based on the ability to stop events from propagating and in the end calling `onDismiss`
|
||||
* if `usePointerDownOutside` runs `event.preventDefault()`.
|
||||
*
|
||||
* Altrough `pointerdown` is already a cancelable event,
|
||||
* to to make sure the batching of events works corretly with `focusIn` in `useFocusOutside`,
|
||||
* we still use a custom event like in `useFocusOutside`.
|
||||
*
|
||||
* Since pointer-events are `discrete` events in React: https://github.com/facebook/react/blob/a8a4742f1c54493df00da648a3f9d26e3db9c8b5/packages/react-dom/src/events/ReactDOMEventListener.js#L318
|
||||
* we need to to use flushSync to ensure that the event is dispatched before the next event is raised.
|
||||
*/
|
||||
function dispatchPointerEvent() {
|
||||
dispatchCustomEvent(
|
||||
CUSTOM_EVENTS.POINTER_DOWN_OUTSIDE,
|
||||
handlePointerDownOutside,
|
||||
{ originalEvent: event },
|
||||
{ discrete: true },
|
||||
);
|
||||
}
|
||||
|
||||
if (event.target && !isPointerInsideReactTreeRef.current) {
|
||||
/**
|
||||
* On touch devices, we delay reactivating pointer-events to account for the browser's delay in executing events after touch ends.
|
||||
* This also handles cancellations when no click event is raised due to scrolling or long-pressing.
|
||||
* We continuously remove the previous listener as we can't be sure it was raised and cleaned up.
|
||||
*/
|
||||
if (event.pointerType === "touch") {
|
||||
ownerDocument.removeEventListener("click", handleClickRef.current);
|
||||
handleClickRef.current = dispatchPointerEvent;
|
||||
ownerDocument.addEventListener("click", handleClickRef.current, {
|
||||
once: true,
|
||||
});
|
||||
} else {
|
||||
dispatchPointerEvent();
|
||||
}
|
||||
} else {
|
||||
// We need to remove the event listener in case the outside click has been canceled.
|
||||
ownerDocument.removeEventListener("click", handleClickRef.current);
|
||||
}
|
||||
isPointerInsideReactTreeRef.current = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* If this hook executes in a component that mounts via a `pointerdown` event, the event
|
||||
* would bubble up to the document and trigger a `pointerDownOutside` event. We avoid
|
||||
* this by delaying the event listener registration on the document.
|
||||
* This is not React specific, but rather how the DOM works, ie:
|
||||
* ```
|
||||
* button.addEventListener('pointerdown', () => {
|
||||
* console.log('I will log');
|
||||
* document.addEventListener('pointerdown', () => {
|
||||
* console.log('I will also log');
|
||||
* })
|
||||
* });
|
||||
*/
|
||||
const timerId = window.setTimeout(() => {
|
||||
ownerDocument.addEventListener("pointerdown", handlePointerDown);
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timerId);
|
||||
ownerDocument.removeEventListener("pointerdown", handlePointerDown);
|
||||
ownerDocument.removeEventListener("click", handleClickRef.current);
|
||||
};
|
||||
}, [ownerDocument, handlePointerDownOutside]);
|
||||
|
||||
return {
|
||||
// ensures we check React component tree (not just DOM tree)
|
||||
onPointerDownCapture: () => (isPointerInsideReactTreeRef.current = true),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { Portal } from "../../portal";
|
||||
import { Floating } from "./Floating";
|
||||
|
||||
export default {
|
||||
title: "Utilities/Floating",
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
};
|
||||
|
||||
export const StyledFloatingElement = () => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
return (
|
||||
<Scrollable>
|
||||
<Floating>
|
||||
<Floating.Anchor className="anchor" onClick={() => setOpen(true)}>
|
||||
open
|
||||
</Floating.Anchor>
|
||||
|
||||
{open && (
|
||||
<Floating.Content
|
||||
className="content"
|
||||
sideOffset={5}
|
||||
arrow={{ height: 10, width: 10, className: "arrow" }}
|
||||
>
|
||||
<button onClick={() => setOpen(false)}>close</button>
|
||||
</Floating.Content>
|
||||
)}
|
||||
</Floating>
|
||||
</Scrollable>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithPortal = () => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
return (
|
||||
<Scrollable>
|
||||
<Floating>
|
||||
<Floating.Anchor className="anchor" onClick={() => setOpen(true)}>
|
||||
open
|
||||
</Floating.Anchor>
|
||||
|
||||
{open && (
|
||||
<Portal asChild>
|
||||
<Floating.Content
|
||||
className="content"
|
||||
sideOffset={5}
|
||||
arrow={{ height: 10, width: 10, className: "arrow" }}
|
||||
>
|
||||
<button onClick={() => setOpen(false)}>close</button>
|
||||
</Floating.Content>
|
||||
</Portal>
|
||||
)}
|
||||
</Floating>
|
||||
</Scrollable>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithUpdatePositionStrategyAlways = () => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [left, setLeft] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
setLeft((prev) => (prev + 50) % 300);
|
||||
}, 500);
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
return (
|
||||
<Scrollable>
|
||||
<Floating>
|
||||
<Floating.Anchor
|
||||
className="anchor"
|
||||
onClick={() => setOpen(true)}
|
||||
style={{ marginLeft: left }}
|
||||
>
|
||||
open
|
||||
</Floating.Anchor>
|
||||
|
||||
{open && (
|
||||
<Floating.Content
|
||||
className="content"
|
||||
sideOffset={5}
|
||||
updatePositionStrategy="always"
|
||||
arrow={{ height: 20, width: 10, className: "arrow" }}
|
||||
>
|
||||
<button onClick={() => setOpen(false)}>close</button>
|
||||
</Floating.Content>
|
||||
)}
|
||||
</Floating>
|
||||
</Scrollable>
|
||||
);
|
||||
};
|
||||
|
||||
export const PropsCheck = () => {
|
||||
const [scrollContainer1, setScrollContainer1] =
|
||||
useState<HTMLDivElement | null>(null);
|
||||
const [scrollContainer2, setScrollContainer2] =
|
||||
useState<HTMLDivElement | null>(null);
|
||||
|
||||
return (
|
||||
<div style={{ paddingBottom: 500 }}>
|
||||
<style>{`
|
||||
.anchor-small{
|
||||
background: red;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.content-small {
|
||||
transform-origin: var(--ac-floating-transform-origin);
|
||||
background-color: var(--a-gray-100);
|
||||
border-radius: var(--a-border-radius-large);
|
||||
width: 100px;
|
||||
height: 50px;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
}
|
||||
|
||||
.arrow{
|
||||
background: transparent;
|
||||
fill: var(--a-gray-100);
|
||||
}
|
||||
`}</style>
|
||||
<header
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 150,
|
||||
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
|
||||
backgroundColor: "grey",
|
||||
border: "1px solid black",
|
||||
}}
|
||||
>
|
||||
<h1>In fixed header</h1>
|
||||
<Floating>
|
||||
<Floating.Anchor className="anchor-small">1</Floating.Anchor>
|
||||
<Floating.Content
|
||||
className="content-small"
|
||||
sideOffset={5}
|
||||
arrow={{ height: 5, width: 10, className: "arrow" }}
|
||||
>
|
||||
1
|
||||
</Floating.Content>
|
||||
</Floating>
|
||||
|
||||
<Floating>
|
||||
<Floating.Anchor className="anchor-small">2</Floating.Anchor>
|
||||
<Portal asChild>
|
||||
<Floating.Content
|
||||
className="content-small"
|
||||
sideOffset={5}
|
||||
arrow={{ height: 5, width: 10, className: "arrow" }}
|
||||
>
|
||||
(portalled)
|
||||
</Floating.Content>
|
||||
</Portal>
|
||||
</Floating>
|
||||
</header>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 100,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 150,
|
||||
border: "1px solid black",
|
||||
}}
|
||||
>
|
||||
<h1>In normal page flow</h1>
|
||||
<Floating>
|
||||
<Floating.Anchor className="anchor-small">3</Floating.Anchor>
|
||||
<Floating.Content
|
||||
className="content-small"
|
||||
sideOffset={5}
|
||||
arrow={{ height: 5, width: 10, className: "arrow" }}
|
||||
>
|
||||
3
|
||||
</Floating.Content>
|
||||
</Floating>
|
||||
|
||||
<Floating>
|
||||
<Floating.Anchor className="anchor-small">4</Floating.Anchor>
|
||||
<Portal asChild>
|
||||
<Floating.Content
|
||||
className="content-small"
|
||||
sideOffset={5}
|
||||
arrow={{ height: 5, width: 10, className: "arrow" }}
|
||||
>
|
||||
4 (portalled)
|
||||
</Floating.Content>
|
||||
</Portal>
|
||||
</Floating>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
marginTop: 50,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 150,
|
||||
border: "1px solid black",
|
||||
}}
|
||||
>
|
||||
<h1>In relative parent</h1>
|
||||
<Floating>
|
||||
<Floating.Anchor className="anchor-small">5</Floating.Anchor>
|
||||
<Floating.Content
|
||||
className="content-small"
|
||||
sideOffset={5}
|
||||
arrow={{ height: 5, width: 10, className: "arrow" }}
|
||||
>
|
||||
5
|
||||
</Floating.Content>
|
||||
</Floating>
|
||||
|
||||
<Floating>
|
||||
<Floating.Anchor className="anchor-small">6</Floating.Anchor>
|
||||
<Portal asChild>
|
||||
<Floating.Content
|
||||
className="content-small"
|
||||
sideOffset={5}
|
||||
arrow={{ height: 5, width: 10, className: "arrow" }}
|
||||
>
|
||||
6 (portalled)
|
||||
</Floating.Content>
|
||||
</Portal>
|
||||
</Floating>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 50,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 150,
|
||||
border: "1px solid black",
|
||||
transform: "translate3d(100px, 0, 0)",
|
||||
}}
|
||||
>
|
||||
<h1>In translated parent</h1>
|
||||
<Floating>
|
||||
<Floating.Anchor className="anchor-small">7</Floating.Anchor>
|
||||
<Floating.Content
|
||||
className="content-small"
|
||||
sideOffset={5}
|
||||
arrow={{ height: 5, width: 10, className: "arrow" }}
|
||||
>
|
||||
7
|
||||
</Floating.Content>
|
||||
</Floating>
|
||||
|
||||
<Floating>
|
||||
<Floating.Anchor className="anchor-small">8</Floating.Anchor>
|
||||
<Portal asChild>
|
||||
<Floating.Content
|
||||
className="content-small"
|
||||
sideOffset={5}
|
||||
arrow={{ height: 5, width: 10, className: "arrow" }}
|
||||
>
|
||||
8 (portalled)
|
||||
</Floating.Content>
|
||||
</Portal>
|
||||
</Floating>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 100 }}>
|
||||
<div>
|
||||
<h1>In scrolling container</h1>
|
||||
<div
|
||||
ref={setScrollContainer1}
|
||||
style={{
|
||||
width: 400,
|
||||
height: 600,
|
||||
overflow: "auto",
|
||||
border: "1px solid black",
|
||||
}}
|
||||
>
|
||||
<div style={{ height: 2000 }}>
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 150,
|
||||
paddingBottom: 100,
|
||||
}}
|
||||
>
|
||||
<Floating>
|
||||
<Floating.Anchor className="anchor-small">
|
||||
9.{i + 1}
|
||||
</Floating.Anchor>
|
||||
<Floating.Content
|
||||
className="content-small"
|
||||
sideOffset={5}
|
||||
hideWhenDetached
|
||||
collisionBoundary={scrollContainer1}
|
||||
arrow={{ height: 5, width: 10, className: "arrow" }}
|
||||
>
|
||||
9.{i + 1}
|
||||
</Floating.Content>
|
||||
</Floating>
|
||||
|
||||
<Floating>
|
||||
<Floating.Anchor className="anchor-small">
|
||||
10.{i + 1}
|
||||
</Floating.Anchor>
|
||||
<Portal asChild>
|
||||
<Floating.Content
|
||||
className="content-small"
|
||||
sideOffset={5}
|
||||
hideWhenDetached
|
||||
collisionBoundary={scrollContainer1}
|
||||
arrow={{ height: 5, width: 10, className: "arrow" }}
|
||||
>
|
||||
10.{i + 1} (portalled)
|
||||
</Floating.Content>
|
||||
</Portal>
|
||||
</Floating>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1>With position sticky</h1>
|
||||
<div
|
||||
ref={setScrollContainer2}
|
||||
style={{
|
||||
width: 400,
|
||||
height: 600,
|
||||
overflow: "auto",
|
||||
border: "1px solid black",
|
||||
}}
|
||||
>
|
||||
<div style={{ height: 2000 }}>
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 150,
|
||||
paddingBottom: 100,
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
}}
|
||||
>
|
||||
<Floating>
|
||||
<Floating.Anchor className="anchor-small">
|
||||
9.{i + 1}
|
||||
</Floating.Anchor>
|
||||
<Floating.Content
|
||||
className="content-small"
|
||||
sideOffset={5}
|
||||
hideWhenDetached
|
||||
collisionBoundary={scrollContainer2}
|
||||
arrow={{ height: 5, width: 10, className: "arrow" }}
|
||||
>
|
||||
9.{i + 1}
|
||||
</Floating.Content>
|
||||
</Floating>
|
||||
|
||||
<Floating>
|
||||
<Floating.Anchor className="anchor-small">
|
||||
10.{i + 1}
|
||||
</Floating.Anchor>
|
||||
<Portal asChild>
|
||||
<Floating.Content
|
||||
className="content-small"
|
||||
sideOffset={5}
|
||||
hideWhenDetached
|
||||
collisionBoundary={scrollContainer2}
|
||||
arrow={{ height: 5, width: 10, className: "arrow" }}
|
||||
>
|
||||
10.{i + 1} (portalled)
|
||||
</Floating.Content>
|
||||
</Portal>
|
||||
</Floating>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function Scrollable(props: any) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "200vh",
|
||||
width: "200vw",
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<style>{`
|
||||
.anchor{
|
||||
background: red;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.content {
|
||||
transform-origin: var(--ac-floating-transform-origin);
|
||||
background-color: var(--a-gray-100);
|
||||
border-radius: var(--a-border-radius-large);
|
||||
width: 300px;
|
||||
height: 150px;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
}
|
||||
|
||||
.arrow{
|
||||
background: transparent;
|
||||
fill: var(--a-gray-100);
|
||||
}
|
||||
`}</style>
|
||||
{props?.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import {
|
||||
Placement,
|
||||
autoUpdate,
|
||||
flip,
|
||||
arrow as floatingArrow,
|
||||
hide,
|
||||
limitShift,
|
||||
offset,
|
||||
shift,
|
||||
size,
|
||||
useFloating,
|
||||
} from "@floating-ui/react-dom";
|
||||
import React, {
|
||||
HTMLAttributes,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Slot } from "../../util/Slot";
|
||||
import { createContext } from "../../util/create-context";
|
||||
import {
|
||||
useCallbackRef,
|
||||
useClientLayoutEffect,
|
||||
useMergeRefs,
|
||||
} from "../../util/hooks";
|
||||
import { AsChildProps } from "../../util/types";
|
||||
import {
|
||||
type Align,
|
||||
type Measurable,
|
||||
type Side,
|
||||
getSideAndAlignFromPlacement,
|
||||
transformOrigin,
|
||||
} from "./Floating.utils";
|
||||
|
||||
/**
|
||||
* Floating Root
|
||||
*/
|
||||
type FloatingContextValue = {
|
||||
anchor: Measurable | null;
|
||||
onAnchorChange: (anchor: Measurable | null) => void;
|
||||
};
|
||||
|
||||
export const [FloatingProvider, useFloatingContext] =
|
||||
createContext<FloatingContextValue>({
|
||||
name: "FloatingContext",
|
||||
hookName: "useFloating",
|
||||
providerName: "FloatingProvider",
|
||||
});
|
||||
|
||||
interface FloatingProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface FloatingComponent extends React.FC<FloatingProps> {
|
||||
Anchor: typeof FloatingAnchor;
|
||||
Content: typeof FloatingContent;
|
||||
}
|
||||
|
||||
const Floating: FloatingComponent = ({ children }: FloatingProps) => {
|
||||
const [anchor, setAnchor] = useState<Measurable | null>(null);
|
||||
|
||||
return (
|
||||
<FloatingProvider anchor={anchor} onAnchorChange={setAnchor}>
|
||||
{children}
|
||||
</FloatingProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Floating Anchor
|
||||
*/
|
||||
type FloatingAnchorProps = HTMLAttributes<HTMLDivElement> &
|
||||
AsChildProps & {
|
||||
virtualRef?: React.RefObject<Measurable>;
|
||||
};
|
||||
|
||||
/**
|
||||
* `FloatingAnchor` provides an anchor for a Floating instance.
|
||||
* Allows anchoring to non-DOM nodes like a cursor position when used with `virtualRef`.
|
||||
*/
|
||||
const FloatingAnchor = forwardRef<HTMLDivElement, FloatingAnchorProps>(
|
||||
({ virtualRef, asChild, ...rest }: FloatingAnchorProps, forwardedRef) => {
|
||||
const context = useFloatingContext();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const mergedRef = useMergeRefs(forwardedRef, ref);
|
||||
|
||||
useEffect(() => {
|
||||
// Allows anchoring the floating to non-DOM nodes like a cursor position.
|
||||
// We replace `anchorRef` with a virtual ref in such cases.
|
||||
context.onAnchorChange(virtualRef?.current || ref.current);
|
||||
});
|
||||
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return virtualRef ? null : <Comp ref={mergedRef} {...rest} />;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Floating Arrow
|
||||
*/
|
||||
const OPPOSITE_SIDE: Record<Side, Side> = {
|
||||
top: "bottom",
|
||||
right: "left",
|
||||
bottom: "top",
|
||||
left: "right",
|
||||
};
|
||||
|
||||
interface FloatingArrowProps {
|
||||
className?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const FloatingArrow = ({ width, height, className }: FloatingArrowProps) => {
|
||||
const context = useFloatingContentContext();
|
||||
|
||||
const side = OPPOSITE_SIDE[context.placedSide];
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={context.onArrowChange}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: context.arrowX,
|
||||
top: context.arrowY,
|
||||
[side]: 0,
|
||||
transformOrigin: {
|
||||
top: "",
|
||||
right: "0 0",
|
||||
bottom: "center 0",
|
||||
left: "100% 0",
|
||||
}[context.placedSide],
|
||||
transform: {
|
||||
top: "translateY(100%)",
|
||||
right: "translateY(50%) rotate(90deg) translateX(-50%)",
|
||||
bottom: `rotate(180deg)`,
|
||||
left: "translateY(50%) rotate(-90deg) translateX(50%)",
|
||||
}[context.placedSide],
|
||||
visibility: context.hideArrow ? "hidden" : undefined,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
<svg
|
||||
className={className}
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 30 10"
|
||||
preserveAspectRatio="none"
|
||||
style={{ display: "block" }}
|
||||
>
|
||||
<polygon points="0,0 30,0 15,10" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Floating Content
|
||||
*/
|
||||
type FloatingContentContextValue = {
|
||||
placedSide: Side;
|
||||
onArrowChange: (arrow: HTMLSpanElement | null) => void;
|
||||
arrowX?: number;
|
||||
arrowY?: number;
|
||||
hideArrow: boolean;
|
||||
};
|
||||
|
||||
const [FloatingContentProvider, useFloatingContentContext] =
|
||||
createContext<FloatingContentContextValue>({
|
||||
name: "FloatingContentContext",
|
||||
hookName: "useFloatingContentContext",
|
||||
providerName: "FloatingContentProvider",
|
||||
});
|
||||
|
||||
type Boundary = Element | null;
|
||||
|
||||
interface FloatingContentProps extends HTMLAttributes<HTMLDivElement> {
|
||||
side?: Side;
|
||||
sideOffset?: number;
|
||||
align?: Align;
|
||||
alignOffset?: number;
|
||||
avoidCollisions?: boolean;
|
||||
collisionBoundary?: Boundary | Boundary[];
|
||||
collisionPadding?: number | Partial<Record<Side, number>>;
|
||||
hideWhenDetached?: boolean;
|
||||
updatePositionStrategy?: "optimized" | "always";
|
||||
onPlaced?: () => void;
|
||||
arrow?: {
|
||||
className?: string;
|
||||
padding?: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
const FloatingContent = forwardRef<HTMLDivElement, FloatingContentProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 0,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
avoidCollisions = true,
|
||||
collisionBoundary = [],
|
||||
collisionPadding: collisionPaddingProp = 0,
|
||||
hideWhenDetached = false,
|
||||
updatePositionStrategy = "optimized",
|
||||
onPlaced,
|
||||
arrow: _arrow,
|
||||
...contentProps
|
||||
}: FloatingContentProps,
|
||||
forwardedRef,
|
||||
) => {
|
||||
const context = useFloatingContext();
|
||||
|
||||
const [content, setContent] = useState<HTMLDivElement | null>(null);
|
||||
const mergeRefs = useMergeRefs(forwardedRef, setContent);
|
||||
|
||||
const arrowDefaults = {
|
||||
padding: 5,
|
||||
width: 0,
|
||||
height: 0,
|
||||
..._arrow,
|
||||
};
|
||||
const [arrow, setArrow] = useState<HTMLSpanElement | null>(null);
|
||||
const arrowWidth = arrowDefaults.width;
|
||||
const arrowHeight = arrowDefaults.height;
|
||||
|
||||
const desiredPlacement = (side +
|
||||
(align !== "center" ? "-" + align : "")) as Placement;
|
||||
|
||||
const collisionPadding =
|
||||
typeof collisionPaddingProp === "number"
|
||||
? collisionPaddingProp
|
||||
: { top: 0, right: 0, bottom: 0, left: 0, ...collisionPaddingProp };
|
||||
|
||||
const boundary = Array.isArray(collisionBoundary)
|
||||
? collisionBoundary
|
||||
: [collisionBoundary];
|
||||
|
||||
const hasExplicitBoundaries = boundary.length > 0;
|
||||
|
||||
/**
|
||||
* .filter(x => x !== null) does not narrow the type of the array enough.
|
||||
*/
|
||||
function isNotNull<T>(value: T | null): value is T {
|
||||
return value !== null;
|
||||
}
|
||||
|
||||
const detectOverflowOptions = {
|
||||
padding: collisionPadding,
|
||||
boundary: boundary.filter(isNotNull),
|
||||
// with `strategy: 'fixed'`, this is the only way to get it to respect boundaries
|
||||
altBoundary: hasExplicitBoundaries,
|
||||
};
|
||||
|
||||
const { refs, floatingStyles, placement, isPositioned, middlewareData } =
|
||||
useFloating({
|
||||
// default to `fixed` strategy so users don't have to pick and we also avoid focus scroll issues
|
||||
strategy: "fixed",
|
||||
placement: desiredPlacement,
|
||||
whileElementsMounted: (...args) => {
|
||||
const cleanup = autoUpdate(...args, {
|
||||
animationFrame: updatePositionStrategy === "always",
|
||||
});
|
||||
return cleanup;
|
||||
},
|
||||
elements: {
|
||||
reference: context.anchor,
|
||||
},
|
||||
middleware: [
|
||||
offset({
|
||||
mainAxis: sideOffset + arrowHeight,
|
||||
alignmentAxis: alignOffset,
|
||||
}),
|
||||
avoidCollisions &&
|
||||
shift({
|
||||
mainAxis: true,
|
||||
crossAxis: false,
|
||||
limiter: limitShift(),
|
||||
...detectOverflowOptions,
|
||||
}),
|
||||
avoidCollisions && flip({ ...detectOverflowOptions }),
|
||||
size({
|
||||
...detectOverflowOptions,
|
||||
apply: ({ elements, rects, availableWidth, availableHeight }) => {
|
||||
const { width: anchorWidth, height: anchorHeight } =
|
||||
rects.reference;
|
||||
const contentStyle = elements.floating.style;
|
||||
/**
|
||||
* Allows styling and animations based on the available space.
|
||||
*/
|
||||
contentStyle.setProperty(
|
||||
"--ac-floating-available-width",
|
||||
`${availableWidth}px`,
|
||||
);
|
||||
contentStyle.setProperty(
|
||||
"--ac-floating-available-height",
|
||||
`${availableHeight}px`,
|
||||
);
|
||||
contentStyle.setProperty(
|
||||
"--ac-floating-anchor-width",
|
||||
`${anchorWidth}px`,
|
||||
);
|
||||
contentStyle.setProperty(
|
||||
"--ac-floating-anchor-height",
|
||||
`${anchorHeight}px`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
arrow &&
|
||||
floatingArrow({ element: arrow, padding: arrowDefaults.padding }),
|
||||
transformOrigin({ arrowWidth, arrowHeight }),
|
||||
hideWhenDetached &&
|
||||
hide({ strategy: "referenceHidden", ...detectOverflowOptions }),
|
||||
],
|
||||
});
|
||||
|
||||
const [placedSide, placedAlign] = getSideAndAlignFromPlacement(placement);
|
||||
|
||||
const handlePlaced = useCallbackRef(onPlaced);
|
||||
|
||||
useClientLayoutEffect(() => {
|
||||
isPositioned && handlePlaced?.();
|
||||
}, [isPositioned, handlePlaced]);
|
||||
|
||||
const arrowX = middlewareData.arrow?.x;
|
||||
const arrowY = middlewareData.arrow?.y;
|
||||
const cannotCenterArrow = middlewareData.arrow?.centerOffset !== 0;
|
||||
|
||||
const [contentZIndex, setContentZIndex] = useState<string>();
|
||||
useClientLayoutEffect(() => {
|
||||
if (content) setContentZIndex(window.getComputedStyle(content).zIndex);
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
data-aksel-floating-content-wrapper=""
|
||||
style={{
|
||||
...floatingStyles,
|
||||
transform: isPositioned
|
||||
? floatingStyles.transform
|
||||
: "translate(0, -200%)", // keep off the page when measuring
|
||||
minWidth: "max-content",
|
||||
zIndex: contentZIndex,
|
||||
["--ac-floating-transform-origin" as any]: [
|
||||
middlewareData.transformOrigin?.x,
|
||||
middlewareData.transformOrigin?.y,
|
||||
].join(" "),
|
||||
}}
|
||||
// Floating UI uses the `dir` attribute on the reference/floating node for logical alignment.
|
||||
// This attribute is necessary for both portalled and inline calculations.
|
||||
dir="ltr"
|
||||
>
|
||||
<FloatingContentProvider
|
||||
placedSide={placedSide}
|
||||
onArrowChange={setArrow}
|
||||
arrowX={arrowX}
|
||||
arrowY={arrowY}
|
||||
hideArrow={cannotCenterArrow}
|
||||
>
|
||||
<div
|
||||
ref={mergeRefs}
|
||||
data-side={placedSide}
|
||||
data-align={placedAlign}
|
||||
{...contentProps}
|
||||
style={{
|
||||
...contentProps.style,
|
||||
// if the FloatingContent hasn't been placed yet (not all measurements done)
|
||||
// we prevent animations so that users's animation don't kick in too early referring wrong sides
|
||||
animation: !isPositioned ? "none" : undefined,
|
||||
// hide the content if using the hide middleware and should be hidden
|
||||
opacity: middlewareData.hide?.referenceHidden ? 0 : undefined,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{_arrow?.height && _arrow?.width && (
|
||||
<FloatingArrow
|
||||
width={_arrow.width}
|
||||
height={_arrow.height}
|
||||
className={_arrow.className}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FloatingContentProvider>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Floating.Anchor = FloatingAnchor;
|
||||
Floating.Content = FloatingContent;
|
||||
|
||||
export { Floating };
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Middleware, Placement } from "@floating-ui/react-dom";
|
||||
|
||||
const SIDE_OPTIONS = ["top", "right", "bottom", "left"] as const;
|
||||
const ALIGN_OPTIONS = ["start", "center", "end"] as const;
|
||||
|
||||
type Side = (typeof SIDE_OPTIONS)[number];
|
||||
type Align = (typeof ALIGN_OPTIONS)[number];
|
||||
type Measurable = { getBoundingClientRect(): DOMRect };
|
||||
|
||||
/**
|
||||
* `transformOrigin` is a custom middleware for floating-ui that calculates the transform origin of the floating-element.
|
||||
*/
|
||||
function transformOrigin(options: {
|
||||
arrowWidth: number;
|
||||
arrowHeight: number;
|
||||
}): Middleware {
|
||||
return {
|
||||
name: "transformOrigin",
|
||||
options,
|
||||
fn(data) {
|
||||
const { placement, rects, middlewareData } = data;
|
||||
|
||||
const cannotCenterArrow = middlewareData.arrow?.centerOffset !== 0;
|
||||
const isArrowHidden = cannotCenterArrow;
|
||||
const arrowWidth = isArrowHidden ? 0 : options.arrowWidth;
|
||||
const arrowHeight = isArrowHidden ? 0 : options.arrowHeight;
|
||||
|
||||
const [placedSide, placedAlign] = getSideAndAlignFromPlacement(placement);
|
||||
const noArrowAlign = { start: "0%", center: "50%", end: "100%" }[
|
||||
placedAlign
|
||||
];
|
||||
|
||||
const arrowXCenter = (middlewareData.arrow?.x ?? 0) + arrowWidth / 2;
|
||||
const arrowYCenter = (middlewareData.arrow?.y ?? 0) + arrowHeight / 2;
|
||||
|
||||
let x = "";
|
||||
let y = "";
|
||||
|
||||
if (placedSide === "bottom") {
|
||||
x = isArrowHidden ? noArrowAlign : `${arrowXCenter}px`;
|
||||
y = `${-arrowHeight}px`;
|
||||
} else if (placedSide === "top") {
|
||||
x = isArrowHidden ? noArrowAlign : `${arrowXCenter}px`;
|
||||
y = `${rects.floating.height + arrowHeight}px`;
|
||||
} else if (placedSide === "right") {
|
||||
x = `${-arrowHeight}px`;
|
||||
y = isArrowHidden ? noArrowAlign : `${arrowYCenter}px`;
|
||||
} else if (placedSide === "left") {
|
||||
x = `${rects.floating.width + arrowHeight}px`;
|
||||
y = isArrowHidden ? noArrowAlign : `${arrowYCenter}px`;
|
||||
}
|
||||
return { data: { x, y } };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getSideAndAlignFromPlacement(placement: Placement) {
|
||||
const [side, align = "center"] = placement.split("-");
|
||||
return [side as Side, align as Align] as const;
|
||||
}
|
||||
|
||||
export { getSideAndAlignFromPlacement, transformOrigin };
|
||||
export type { Side, Align, Measurable };
|
||||
Loaded 100 of 395 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user