diff --git a/.changeset/shiny-dryers-count.md b/.changeset/shiny-dryers-count.md
new file mode 100644
index 0000000000..727a9af1e1
--- /dev/null
+++ b/.changeset/shiny-dryers-count.md
@@ -0,0 +1,8 @@
+---
+"@digdir/designsystemet-css": major
+"@digdir/designsystemet-react": major
+---
+
+Radio + Checkbox:
+- Use `label` prop instead of `children` as label text
+- Remove `Radio.Group` and `Checkbox.Group` and use `Fieldset` instead
diff --git a/apps/theme/components/Previews/Components/Components.tsx b/apps/theme/components/Previews/Components/Components.tsx
index 5cb80a806c..faa5e397a4 100644
--- a/apps/theme/components/Previews/Components/Components.tsx
+++ b/apps/theme/components/Previews/Components/Components.tsx
@@ -35,7 +35,6 @@ import { useState } from 'react';
import classes from './Components.module.css';
export const Components = () => {
- const [radioValue, setRadioValue] = useState('vanilje');
const [currentPage, setCurrentPage] = useState(1);
const pagination = usePagination({
currentPage,
@@ -47,21 +46,15 @@ export const Components = () => {
return (
-
- En kilo poteter
- To liter Farris
-
- Blomkål
-
-
- Pizza
-
-
- Tre liter lettmelk
-
- 2kg smågodt
- 10 poser med Smash
-
+
@@ -205,19 +198,16 @@ export const Components = () => {
- setRadioValue(e)}
>
- Vanilje
- Jordbær
- Sjokolade
- Jeg spiser ikke iskrem
-
+
+
+
+
+
diff --git a/packages/css/checkbox.css b/packages/css/checkbox.css
deleted file mode 100644
index dec68939ee..0000000000
--- a/packages/css/checkbox.css
+++ /dev/null
@@ -1,221 +0,0 @@
-.ds-checkbox {
- --dsc-checkbox-size: 1.75rem;
- --dsc-checkbox-focus-border-width: 3px;
- --dsc-checkbox-background: var(--ds-color-neutral-background-default);
- --dsc-checkbox-border-color: var(--ds-color-neutral-border-default);
- --dsc-checkbox-border__hover--size: calc(var(--ds-spacing-3) / 2);
- --dsc-checkbox-border__hover: 0 0 0 var(--dsc-checkbox-border__hover--size) var(--ds-color-accent-surface-hover);
- --dsc-checkbox-check_color: transparent;
-
- display: grid;
-}
-
-.ds-checkbox:has(.ds-checkbox__label) {
- grid-template-columns: var(--dsc-checkbox-size) auto;
- gap: var(--ds-spacing-2);
-}
-
-/* Checkbox */
-.ds-checkbox__input {
- position: relative;
- width: var(--dsc-checkbox-size);
- height: var(--dsc-checkbox-size);
- z-index: 1;
- appearance: none;
- margin: 0;
- align-self: center;
- outline: none;
- cursor: pointer;
- box-shadow: inset 0 0 0 2px var(--dsc-checkbox-border-color);
- background: var(--dsc-checkbox-background);
- border-radius: var(--ds-border-radius-sm);
-}
-
-.ds-checkbox__input::before {
- position: absolute;
- content: '';
- display: block;
- width: 2.75rem;
- height: 2.75rem;
- transform: translate(-50%, -50%);
- top: 50%;
- left: 50%;
- cursor: pointer;
- border-radius: var(--ds-border-radius-sm);
-}
-
-.ds-checkbox__label {
- /* min-height: var(--ds-sizing-10); */
- min-width: min-content;
- display: inline-flex;
- flex-direction: row;
- gap: var(--ds-spacing-1);
- align-items: center;
- cursor: pointer;
-}
-
-.ds-checkbox__description {
- margin-top: calc(var(--ds-spacing-2) * -1);
- color: var(--ds-color-neutral-text-subtle);
- grid-column: 2;
-}
-
-.ds-checkbox--readonly > .ds-checkbox__label,
-.ds-checkbox--readonly > .ds-checkbox__input,
-.ds-checkbox--readonly > .ds-checkbox__input::before {
- cursor: default;
-}
-
-.ds-checkbox__input:disabled,
-.ds-checkbox__input:disabled ~ .ds-checkbox__label,
-.ds-checkbox__input:disabled::before {
- cursor: not-allowed;
-}
-
-/* .ds-checkbox__input:focus-visible {
- outline-offset: 3px;
- outline: var(--dsc-checkbox-focus-border-width) solid var(--ds-color-focus-outer);
- box-shadow:
- 0 0 0 var(--dsc-checkbox-focus-border-width) var(--ds-color-focus-inner),
- inset 0 0 0 2px var(--dsc-checkbox-border-color);
- } */
-
-.ds-checkbox__input::after {
- content: '';
- width: 100%;
- height: 100%;
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- background-color: var(--dsc-checkbox-check_color);
- mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 23 23' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M18.5509 6.32414C18.9414 6.71467 18.9414 7.34783 18.5509 7.73836L10.5821 15.7071C10.1916 16.0976 9.55842 16.0976 9.16789 15.7071L4.94914 11.4884C4.55862 11.0978 4.55862 10.4647 4.94914 10.0741C5.33967 9.68362 5.97283 9.68362 6.36336 10.0741L9.875 13.5858L17.1366 6.32414C17.5272 5.93362 18.1603 5.93362 18.5509 6.32414Z' fill='white'/%3E%3C/svg%3E%0A");
-}
-
-.ds-checkbox__input:checked {
- --dsc-checkbox-border-color: var(--ds-color-accent-base-default);
- --dsc-checkbox-background: var(--ds-color-accent-base-default);
- --dsc-checkbox-check_color: var(--ds-color-accent-contrast-default);
-
- background: var(--dsc-checkbox-background);
-}
-
-.ds-checkbox__input:indeterminate {
- --dsc-checkbox-border-color: var(--ds-color-accent-base-default);
- --dsc-checkbox-background: var(--ds-color-accent-base-default);
- --dsc-checkbox-check_color: var(--ds-color-accent-contrast-default);
-}
-
-.ds-checkbox__input:indeterminate::after {
- mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 23 23' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4.25 11.25C4.25 10.4216 4.92157 9.75 5.75 9.75H16.75C17.5784 9.75 18.25 10.4216 18.25 11.25C18.25 12.0784 17.5784 12.75 16.75 12.75H5.75C4.92157 12.75 4.25 12.0784 4.25 11.25Z' fill='white' /%3E%3C/svg%3E%0A");
-}
-
-.ds-checkbox--readonly > .ds-checkbox__input {
- --dsc-checkbox-border-color: var(--ds-color-neutral-border-subtle);
- --dsc-checkbox-background: var(--ds-color-neutral-background-subtle);
-}
-
-.ds-checkbox__input:disabled,
-.ds-checkbox__input:disabled ~ .ds-checkbox__label,
-.ds-checkbox__input:disabled ~ .ds-checkbox__description {
- opacity: var(--ds-disabled-opacity);
-}
-
-/* .ds-checkbox__input:checked:not(:focus-visible) {
- box-shadow: inset 0 0 0 2px var(--dsc-checkbox-border-color);
- } */
-
-.ds-checkbox:has(.ds-checkbox__input:focus-visible) {
- --dsc-focus-border-width: 3px;
-
- outline: var(--dsc-focus-border-width) solid var(--ds-color-focus-outer);
- outline-offset: var(--dsc-focus-border-width);
- box-shadow: 0 0 0 var(--dsc-focus-border-width) var(--ds-color-focus-inner);
- border-radius: var(--ds-border-radius-sm);
-}
-
-.ds-checkbox--readonly > .ds-checkbox__input:checked {
- --dsc-checkbox-check_color: var(--ds-color-neutral-text-subtle);
-
- background: var(--dsc-checkbox-background);
-}
-
-.ds-checkbox--readonly > .ds-checkbox__input:indeterminate {
- --dsc-checkbox-check_color: var(--ds-color-neutral-text-subtle);
-
- background: var(--dsc-checkbox-background);
-}
-
-.ds-checkbox--error > .ds-checkbox__input:not(:disabled, :focus-visible) {
- --dsc-checkbox-border-color: var(--ds-color-danger-border-default);
-}
-
-/* Only use hover for non-touch devices to prevent sticky-hovering
- "input:not(:read-only)" does not work so using ".container:not(.readonly) >" instead */
-@media (hover: hover) and (pointer: fine) {
- .ds-checkbox:not(.ds-checkbox--readonly) .ds-checkbox__input:not(:disabled) ~ .ds-checkbox__label:hover,
- .ds-checkbox:not(.ds-checkbox--readonly) .ds-checkbox__input:hover:not(:disabled) ~ .ds-checkbox__label {
- color: var(--ds-color-accent-text-subtle);
- }
-
- .ds-checkbox:not(.ds-checkbox--readonly) .ds-checkbox__input:hover:not(:checked, :disabled, :focus-visible) {
- --dsc-checkbox-border-color: var(--ds-color-accent-border-strong);
-
- box-shadow: var(--dsc-checkbox-border__hover), inset 0 0 0 2px var(--dsc-checkbox-border-color);
- }
-
- .ds-checkbox:not(.ds-checkbox--readonly) .ds-checkbox__input:indeterminate:hover:not(:focus-visible) {
- --dsc-checkbox-border-color: var(--ds-color-accent-border-strong);
-
- box-shadow: var(--dsc-checkbox-border__hover);
- }
-
- .ds-checkbox:not(.ds-checkbox--readonly) .ds-checkbox__input:hover:checked:not(:disabled, :focus-visible) {
- box-shadow: var(--dsc-checkbox-border__hover);
- }
-}
-
-/** Sizing */
-
-.ds-checkbox--sm {
- --dsc-checkbox-size: var(--ds-sizing-5);
-
- /* min-height: var(--ds-sizing-10); */
-}
-
-.ds-checkbox--md {
- --dsc-checkbox-size: var(--ds-sizing-6);
-
- /* min-height: var(--ds-sizing-11); */
-}
-
-.ds-checkbox--lg {
- --dsc-checkbox-size: var(--ds-sizing-7);
-
- /* min-height: var(--ds-sizing-12); */
-}
-
-.ds-checkbox__group {
- --dsc-checkbox-group-gap: var(--ds-spacing-4);
-
- display: flex;
- flex-direction: column;
- gap: var(--dsc-checkbox-group-gap);
- width: fit-content;
-}
-
-.ds-checkbox__group--sm {
- --dsc-checkbox-group-gap: var(--ds-spacing-3);
-
- margin-top: calc(var(--ds-spacing-1) * -1);
-}
-
-.ds-checkbox__group--md {
- --dsc-checkbox-group-gap: var(--ds-spacing-4);
-}
-
-.ds-checkbox__group--lg {
- --dsc-checkbox-group-gap: var(--ds-spacing-5);
-
- margin-top: var(--ds-spacing-1);
-}
diff --git a/packages/css/field.css b/packages/css/field.css
index 14d1368623..77252a1207 100644
--- a/packages/css/field.css
+++ b/packages/css/field.css
@@ -1,7 +1,71 @@
.ds-field {
- display: contents;
+ align-items: start;
+ display: flex;
+ flex-direction: column;
+ gap: var(--ds-spacing-2);
- & > * + * {
- margin-top: var(--ds-spacing-2);
+ @composes ds-body-text--md from './base/base.css';
+
+ &[data-size='sm'] {
+ @composes ds-body-text--sm from './base/base.css';
+ }
+
+ &[data-size='lg'] {
+ @composes ds-body-text--lg from './base/base.css';
+ }
+
+ & [data-field='description'] {
+ color: var(--ds-color-neutral-text-subtle); /* TODO: Change to opacity or color-mix(currentColor, trasparent) to ensure contrast when parent element color changes? */
+ }
+
+ /**
+ * States
+ */
+ &:has([aria-disabled='true'], :disabled) > * {
+ cursor: not-allowed;
+ opacity: var(--ds-disabled-opacity);
+ }
+
+ &:has([aria-readonly='true'], [readonly]) label {
+ --dsc-label--readonly: ; /* Activate lock icon for label when readonly */
+ }
+
+ /**
+ * Toggle inputs
+ */
+ &:has(input:is([type='radio'], [type='checkbox'])) {
+ border-radius: var(--ds-border-radius-md);
+ display: grid;
+ grid-template-columns: auto 1fr;
+ row-gap: 0;
+ width: fit-content; /* Rather do display: grid + width: fit-content than display: inline-grid to encourage stacked radios */
+
+ & > * {
+ grid-column: 2; /* Only allow input in column 1 */
+ }
+
+ & label {
+ --dsc-label--readonly: initial; /* Never show lock icon on toggle inputs */
+ font-weight: var(--ds-font-weight-regular);
+ }
+
+ & input {
+ grid-column: 1; /* Always place input in column 1 */
+ grid-row: 1; /* Always place input in row 1 */
+ outline: none;
+ box-shadow: none;
+ }
+
+ &:not(:has([readonly], [aria-disabled='true'], :disabled)) label {
+ cursor: pointer;
+ }
+
+ &:has(input:focus-visible) {
+ @composes ds-focus--visible from './base/base.css';
+ }
+
+ &:has(input:only-child) {
+ gap: 0; /* No gap only with aria-label/aria-labelledby */
+ }
}
}
diff --git a/packages/css/fieldset.css b/packages/css/fieldset.css
index 6413d4a5dd..27b704e518 100644
--- a/packages/css/fieldset.css
+++ b/packages/css/fieldset.css
@@ -1,44 +1,31 @@
.ds-fieldset {
- --dsc-fieldset-icon-size: 1.2em;
- --dsc-fieldset-gap: var(--ds-spacing-2);
-
- margin: 0;
- padding: 0;
border: 0;
+ margin: 0;
min-width: 0;
-
- & > :not(:last-child) {
- margin-bottom: var(--ds-spacing-2); /* Use margin as fieldset does not play nice with display: flex */
- }
-
- & > legend {
- display: inline-flex;
- }
-
- & > legend:empty {
- display: none;
- }
+ padding: 0;
&[data-hidelegend] > legend,
&[data-hidelegend] > legend + p {
@composes ds-sr-only from './base/base.css';
}
- &[data-readonly] > legend::before {
- content: '';
- background: currentcolor;
- height: var(--dsc-fieldset-icon-size);
- mask: center / contain no-repeat
- url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' aria-hidden='true' viewBox='0 0 24 24'%3E%3Cpath fill-rule='evenodd' d='M12 2.25A4.75 4.75 0 0 0 7.25 7v2.25H7A1.75 1.75 0 0 0 5.25 11v9c0 .41.34.75.75.75h12a.75.75 0 0 0 .75-.75v-9A1.75 1.75 0 0 0 17 9.25h-.25V7A4.75 4.75 0 0 0 12 2.25m3.25 7V7a3.25 3.25 0 0 0-6.5 0v2.25zM12 13a1.5 1.5 0 0 0-.75 2.8V17a.75.75 0 0 0 1.5 0v-1.2A1.5 1.5 0 0 0 12 13'/%3E%3C/svg%3E");
- width: var(--dsc-fieldset-icon-size);
+ /* Add lock icon to legend when only containing readonly inputs */
+ &:has([readonly]):not(:has(:read-write)) > legend {
+ --dsc-label--readonly: ; /* Using technique https://css-tricks.com/the-css-custom-property-toggle-trick/ */
+ }
+
+ /* Stack everything that is not directly after legend */
+ & > * + * {
+ margin-block-start: var(--ds-spacing-4);
}
& > legend + p {
color: var(--ds-color-neutral-text-subtle);
+ margin-block: 0;
}
&:disabled > legend,
&:disabled > legend + p {
- color: var(--ds-color-neutral-border-subtle);
+ opacity: var(--ds-disabled-opacity);
}
}
diff --git a/packages/css/index.css b/packages/css/index.css
index 77adbf78c7..ad4f21ace8 100644
--- a/packages/css/index.css
+++ b/packages/css/index.css
@@ -10,17 +10,15 @@
@import url('./paragraph.css') layer(ds.typography);
@import url('./validation-message.css') layer(ds.typography);
@import url('./button.css') layer(ds.components);
+@import url('./field.css') layer(ds.components);
+@import url('./input.css') layer(ds.components);
@import url('./alert.css') layer(ds.components);
@import url('./popover.css') layer(ds.components);
@import url('./skiplink.css') layer(ds.components);
@import url('./accordion.css') layer(ds.components);
-@import url('./field.css') layer(ds.components);
@import url('./switch.css') layer(ds.components);
-@import url('./checkbox.css') layer(ds.components);
-@import url('./radio.css') layer(ds.components);
@import url('./search.css') layer(ds.components);
@import url('./textfield.css') layer(ds.components);
-@import url('./input.css') layer(ds.components);
@import url('./helptext.css') layer(ds.components);
@import url('./modal.css') layer(ds.components);
@import url('./list.css') layer(ds.components);
diff --git a/packages/css/input.css b/packages/css/input.css
index fe0f25421b..832503208e 100644
--- a/packages/css/input.css
+++ b/packages/css/input.css
@@ -173,11 +173,6 @@
}
}
-/* Change cursor on wrapping
);
@@ -433,45 +379,38 @@ export const Checkbox: StoryFn = function Render(args) {
gap: '1rem',
}}
>
-
Small
-
+
- Default
-
-
+ Default
+
+
- Checked
-
-
+ Checked
+
+
- Indeterminate
-
-
+ Indeterminate
+
+
- Disabled
-
-
+ Disabled
+
+
- Disabled checked
-
-
-
- Disabled indeterminate
-
-
+ Disabled checked
+
+
+
+ Disabled indeterminate
+
+
- Invalid
-
-
+ Invalid
+
+
= function Render(args) {
aria-invalid='true'
defaultChecked
/>
- Invalid checked
-
-
+ Invalid checked
+
+
- Invalid indeterminate
-
-
+ Invalid indeterminate
+
+
- Read-only
-
-
+ Read-only
+
+
- Read-only checked
-
-
+ Read-only checked
+
+
- Read-only indeterminate
-
+ Read-only indeterminate
+
Medium
-
+
- Default
-
-
+ Default
+
+
- Checked
-
-
+ Checked
+
+
- Indeterminate
-
-
+ Indeterminate
+
+
- Disabled
-
-
+ Disabled
+
+
- Disabled checked
-
-
-
- Disabled indeterminate
-
-
+ Disabled checked
+
+
+
+ Disabled indeterminate
+
+
- Invalid
-
-
+ Invalid
+
+
= function Render(args) {
aria-invalid='true'
defaultChecked
/>
- Invalid checked
-
-
+ Invalid checked
+
+
- Invalid indeterminate
-
-
+ Invalid indeterminate
+
+
- Read-only
-
-
+ Read-only
+
+
- Read-only checked
-
-
+ Read-only checked
+
+
- Read-only indeterminate
-
+ Read-only indeterminate
+
Large
-
+
- Default
-
-
+ Default
+
+
- Checked
-
-
+ Checked
+
+
- Indeterminate
-
-
+ Indeterminate
+
+
- Disabled
-
-
+ Disabled
+
+
- Disabled checked
-
-
-
- Disabled indeterminate
-
-
+ Disabled checked
+
+
+
+ Disabled indeterminate
+
+
- Invalid
-
-
+ Invalid
+
+
= function Render(args) {
aria-invalid='true'
defaultChecked
/>
- Invalid checked
-
-
+ Invalid checked
+
+
- Invalid indeterminate
-
-
+ Invalid indeterminate
+
+
- Read-only
-
-
+ Read-only
+
+
- Read-only checked
-
-
+ Read-only checked
+
+
- Read-only indeterminate
-
+ Read-only indeterminate
+
);
};
@@ -676,31 +603,30 @@ export const Switch: StoryFn = (args) => (
maxWidth: '90vw',
}}
>
-
Small
-
+
- Default
-
-
+ Default
+
+
- Checked
-
-
+ Checked
+
+
- Disabled
-
-
+ Disabled
+
+
- Disabled checked
-
- {/*
+ Disabled checked
+
+ {/*
- Invalid
-
-
+ Invalid
+
+
= (args) => (
aria-invalid='true'
defaultChecked
/>
- Invalid checked
- */}
-
+ Invalid checked
+ */}
+
- Read-only
-
-
+ Read-only
+
+
- Read-only checked
-
+ Read-only checked
+
Medium
-
+
- Default
-
-
+ Default
+
+
- Checked
-
-
+ Checked
+
+
- Disabled
-
-
+ Disabled
+
+
- Disabled checked
-
- {/*
+ Disabled checked
+
+ {/*
- Invalid
-
-
+ Invalid
+
+
= (args) => (
aria-invalid='true'
defaultChecked
/>
- Invalid checked
- */}
-
+ Invalid checked
+ */}
+
- Read-only
-
-
+ Read-only
+
+
- Read-only checked
-
+ Read-only checked
+
Large
-
+
- Default
-
-
+ Default
+
+
- Checked
-
-
+ Checked
+
+
- Disabled
-
-
+ Disabled
+
+
- Disabled checked
-
- {/*
+ Disabled checked
+
+ {/*
- Invalid
-
-
+ Invalid
+
+
= (args) => (
aria-invalid='true'
defaultChecked
/>
- Invalid checked
- */}
-
+ Invalid checked
+ */}
+
- Read-only
-
-
+ Read-only
+
+
- Read-only checked
-
+ Read-only checked
+
);
diff --git a/packages/react/src/components/form/Input/Input.tsx b/packages/react/src/components/form/Input/Input.tsx
index 579a3f9456..a6c53da372 100644
--- a/packages/react/src/components/form/Input/Input.tsx
+++ b/packages/react/src/components/form/Input/Input.tsx
@@ -33,7 +33,7 @@ export type InputProps = {
* ```
*/
export const Input = forwardRef(function Input(
- { type = 'text', size, htmlSize, className, onClick, ...rest },
+ { type = 'text', size, htmlSize, className, onChange, onClick, ...rest },
ref,
) {
return (
@@ -43,6 +43,7 @@ export const Input = forwardRef(function Input(
ref={ref}
size={htmlSize}
type={type}
+ onChange={(event) => rest.readOnly || onChange?.(event)} // Make readonly work for checkbox / radio / switch
onClick={(event) => {
if (rest.readOnly) event.preventDefault(); // Make readonly work for checkbox / radio / switch
onClick?.(event);
diff --git a/packages/react/src/components/form/Radio/Radio.mdx b/packages/react/src/components/form/Radio/Radio.mdx
index 8fcdd03ffb..13a39f4201 100644
--- a/packages/react/src/components/form/Radio/Radio.mdx
+++ b/packages/react/src/components/form/Radio/Radio.mdx
@@ -1,14 +1,13 @@
import { Meta, Canvas, Controls, Primary } from '@storybook/blocks';
import { CssVariables } from '@doc-components';
-import css from '@digdir/designsystemet-css/radio.css?inline';
+import css from '@digdir/designsystemet-css/input.css?inline';
import * as RadioStories from './Radio.stories';
-import * as RadioGroupStories from './RadioGroup.stories';
# Radio
-`Radio` viser brukeren en liste med alternativer de kan velge mellom. Brukerne kan bytte mellom alternativene, men må velge ett. Bruk `Radio.Group` for å gruppere alternativknappene.
+`Radio` viser brukeren en liste med alternativer de kan velge mellom. Brukerne kan bytte mellom alternativene, men må velge ett. Bruk `Fieldset` for å gruppere alternativknappene.
@@ -18,10 +17,10 @@ import * as RadioGroupStories from './RadioGroup.stories';
```tsx
import { Radio } from '@digdir/designsystemet-react';
-
+;
+;
```
## Eksempler
@@ -32,28 +31,28 @@ import { Radio } from '@digdir/designsystemet-react';
`Radio` skal alltid ha en `label`. Når du bruker `label` fra en annen plass, husk å bruke `aria-label` eller `aria-labelledby`.
-
+
-## `Radio.Group`
+## `Fieldset`
-Bruk `Radio.Group` for gruppering av alternativknapper.
+Bruk `Fieldset` for gruppering av alternativknapper.
-
-
+
+
### Feilmelding
-Bruk `error` på `Radio.Group` for å vise feilmelding.
+Bruk `error` på `Fieldset` for å vise feilmelding.
-Her må vi bruke `Radio.Group`, fordi den aktiverer riktig stil og sørger for at innholdet har de riktige attributtene for tilgjengelighet.
+Her må vi bruke `Fieldset`, fordi den aktiverer riktig stil og sørger for at innholdet har de riktige attributtene for tilgjengelighet.
-
+
### Kontrollert
-Bruk `value` på `Radio.Group` for å kontrollere verdiene selv.
+Bruk `value` på `Fieldset` for å kontrollere verdiene selv.
-
+
### Readonly
@@ -61,19 +60,19 @@ Felter med `readonly`-attributtet er med i tabrekkefølgen. Brukerne kan kopiere
`readonly`-felter kan være forvirrende for noen brukere. Ikke alle vil skjønne hvorfor de ikke får til å endre innholdet i feltet. Vi anbefaler derfor å unngå `readonly` så langt det lar seg gjøre.
-
+
### Disabled
Unngå deaktiverte tilstander om du kan. De har lav fargekontrast som er problematisk for noen brukere. `disabled` kan ikke møte kontrastkravene, for da kan brukeren tro at elementet er aktivt, prøve å trykke på det, og ikke skjønne hvorfor det ikke går.
-
+
### Inline
`Radio` skal som hovedregel *ikke* plasseres på samme linje. Men, hvis du har kun to alternativer med korte tekster som "Ja" og "Nei", kan du vurdere om de bør plasseres ved siden av hverandre.
-
+
## Retningslinjer
diff --git a/packages/react/src/components/form/Radio/Radio.stories.tsx b/packages/react/src/components/form/Radio/Radio.stories.tsx
index f23da161d2..2126e6b15a 100644
--- a/packages/react/src/components/form/Radio/Radio.stories.tsx
+++ b/packages/react/src/components/form/Radio/Radio.stories.tsx
@@ -1,6 +1,8 @@
-import type { Meta, StoryObj } from '@storybook/react';
+import type { Meta, StoryFn, StoryObj } from '@storybook/react';
-import { Radio } from '.';
+import { useState } from 'react';
+import type { ChangeEvent } from 'react';
+import { Button, Divider, Fieldset, Paragraph, Radio } from '../..';
type Story = StoryObj;
@@ -11,7 +13,7 @@ export default {
export const Preview: Story = {
args: {
- children: 'Radio',
+ label: 'Radio',
description: 'Description',
disabled: false,
readOnly: false,
@@ -20,9 +22,126 @@ export const Preview: Story = {
},
};
-export const Single: Story = {
+export const AriaLabel: Story = {
args: {
value: 'value',
'aria-label': 'Radio',
},
};
+
+export const Group: StoryFn = (args) => {
+ const props = {
+ 'aria-invalid': !!args.error,
+ name: 'my-radio',
+ size: args.size,
+ };
+
+ return (
+
+ );
+};
+
+Group.args = {
+ legend: 'Hvilken iskremsmak er best?',
+ description: 'Velg din favorittsmak blant alternativene.',
+ disabled: false,
+ error: '',
+ size: 'md',
+};
+
+export const WithError = {
+ args: {
+ ...Group.args,
+ error: 'Du må velge jordbær fordi det smaker best',
+ },
+ render: Group,
+};
+
+export const Controlled: StoryFn = () => {
+ const [value, setValue] = useState();
+ const onChange = (event: ChangeEvent) =>
+ setValue(event.target.value);
+
+ return (
+ <>
+
+
+
+
+
+ Du har valgt: {value}
+
+
+
+
+
+ >
+ );
+};
+
+export const ReadOnly = {
+ args: { ...Group.args, readOnly: true },
+ render: Group,
+};
+
+export const Disabled = {
+ args: { ...Group.args, disabled: true },
+ render: Group,
+};
+
+export const Inline: StoryFn = () => (
+
+);
diff --git a/packages/react/src/components/form/Radio/Radio.test.tsx b/packages/react/src/components/form/Radio/Radio.test.tsx
index 6cdb45e61d..87d0a1294b 100644
--- a/packages/react/src/components/form/Radio/Radio.test.tsx
+++ b/packages/react/src/components/form/Radio/Radio.test.tsx
@@ -6,28 +6,20 @@ import { Radio } from './Radio';
describe('Radio', () => {
test('has correct value and label', () => {
- render(label);
+ render();
expect(screen.getByLabelText('label')).toBeDefined();
expect(screen.getByDisplayValue('test')).toBeDefined();
});
test('has correct description', () => {
- render(
-
- test
- ,
- );
+ render();
expect(
screen.getByRole('radio', { description: 'description' }),
).toBeDefined();
});
test('should pass down name attribute to input', () => {
- render(
-
- label
- ,
- );
+ render();
expect(screen.getByRole('radio', { name: 'label' })).toHaveAttribute(
'name',
'radio-group123',
@@ -59,9 +51,12 @@ describe('Radio', () => {
const value = 'test';
render(
-
- label
- ,
+ ,
);
const radio = screen.getByRole('radio');
@@ -81,9 +76,13 @@ describe('Radio', () => {
const onClick = vi.fn();
render(
-
- disabled radio
- ,
+ ,
);
const radio = screen.getByRole('radio');
@@ -94,24 +93,25 @@ describe('Radio', () => {
expect(onChange).not.toHaveBeenCalled();
});
- it('does not call onChange or onClick when user clicks and the radio is readOnly', async () => {
+ it('does not call onChange when user clicks and the radio is readOnly', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const onClick = vi.fn();
render(
-
- readonly radio
- ,
+ ,
);
const radio = screen.getByRole('radio');
await act(async () => await user.click(radio));
expect(radio).toHaveAttribute('readonly');
- expect(onClick).not.toHaveBeenCalled();
expect(onChange).not.toHaveBeenCalled();
});
-
- //TODO is there a good way to test size?
});
diff --git a/packages/react/src/components/form/Radio/Radio.tsx b/packages/react/src/components/form/Radio/Radio.tsx
index 57153e0afc..8d791c4c22 100644
--- a/packages/react/src/components/form/Radio/Radio.tsx
+++ b/packages/react/src/components/form/Radio/Radio.tsx
@@ -1,73 +1,44 @@
-import cl from 'clsx/lite';
import type { InputHTMLAttributes, ReactNode } from 'react';
import { forwardRef } from 'react';
-import { omit } from '../../../utilities';
import { Label } from '../../Label';
-import { Paragraph } from '../../Paragraph';
-import type { FormFieldProps } from '../useFormField';
-
-import { useRadio } from './useRadio';
+import { ValidationMessage } from '../../ValidationMessage';
+import { Field } from '../Field';
+import { Input } from '../Input';
export type RadioProps = {
+ /** Optional aria-label */
+ 'aria-label'?: string;
/** Radio label */
- children?: ReactNode;
+ label?: ReactNode;
+ /** Description for field */
+ description?: ReactNode;
/** Value of the `input` element */
value: string;
-} & Omit &
- Omit, 'size' | 'value'>;
-
-export const Radio = forwardRef((props, ref) => {
- const { children, description, className, style, ...rest } = props;
- const {
- inputProps,
- descriptionId,
- hasError,
- size = 'md',
- readOnly,
- } = useRadio(props);
+ /** Validation message for field */
+ validation?: ReactNode;
+ /**
+ * Changes field size and paddings
+ * @default md
+ */
+ size?: 'sm' | 'md' | 'lg';
+} & Omit, 'size'> &
+ (
+ | { 'aria-label': string; 'aria-labelledby'?: never; label?: never }
+ | { 'aria-label'?: never; 'aria-labelledby'?: never; label: ReactNode }
+ | { 'aria-label'?: never; 'aria-labelledby': string; label?: never }
+ );
+export const Radio = forwardRef(function Radio(
+ { children, label, description, validation, ...rest },
+ ref,
+) {
return (
-
-
-
- {children && (
- <>
-
- {children}
-
- {description && (
-
-
- {description}
-
-
- )}
- >
- )}
-
-
+
+
+ {!!label && {label}}
+ {!!description && {description}
}
+ {!!validation && {validation}}
+
);
});
-
-Radio.displayName = 'Radio';
diff --git a/packages/react/src/components/form/Radio/RadioGroup.stories.tsx b/packages/react/src/components/form/Radio/RadioGroup.stories.tsx
deleted file mode 100644
index 39429686b0..0000000000
--- a/packages/react/src/components/form/Radio/RadioGroup.stories.tsx
+++ /dev/null
@@ -1,111 +0,0 @@
-import type { Meta, StoryFn } from '@storybook/react';
-import { useState } from 'react';
-
-import { Radio } from '.';
-import { Button, Divider, Paragraph } from '../..';
-
-export default {
- title: 'Komponenter/Radio/Group',
- component: Radio.Group,
-} as Meta;
-
-export const Preview: StoryFn = (args) => (
-
- Vanilje
-
- Jordbær
-
- Sjokolade
- Jeg spiser ikke iskrem
-
-);
-
-Preview.args = {
- legend: 'Hvilken iskremsmak er best?',
- description: 'Velg din favorittsmak blant alternativene.',
- readOnly: false,
- disabled: false,
- error: '',
- size: 'md',
-};
-
-export const WithError: StoryFn = () => (
-
- Bare ost
-
- Dobbeldekker
-
- Flammen
- Snadder
-
-);
-
-export const Controlled: StoryFn = () => {
- const [value, setValue] = useState();
-
- return (
- <>
-
-
- Bare ost
-
- Dobbeldekker
-
- Flammen
- Snadder
-
-
-
-
-
- Du har valgt: {value}
-
-
-
-
-
- >
- );
-};
-
-export const ReadOnly = Preview.bind({});
-
-ReadOnly.args = {
- ...Preview.args,
- readOnly: true,
- value: 'jordbær',
-};
-
-export const Disabled = Preview.bind({});
-
-Disabled.args = {
- ...Preview.args,
- disabled: true,
- value: 'sjokolade',
-};
-
-export const Inline: StoryFn = () => (
-
- Ja
- Nei
-
-);
diff --git a/packages/react/src/components/form/Radio/RadioGroup.test.tsx b/packages/react/src/components/form/Radio/RadioGroup.test.tsx
deleted file mode 100644
index a47781d831..0000000000
--- a/packages/react/src/components/form/Radio/RadioGroup.test.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { act } from 'react';
-
-import { Radio } from '.';
-
-import { RadioGroup } from './RadioGroup';
-
-describe('RadioGroup', () => {
- test('has generated name for Radio children', () => {
- render(
-
- test
- ,
- );
-
- const radio = screen.getByRole('radio');
- expect(radio).toHaveAttribute('name');
- });
- test('has passed name to Radio children', (): void => {
- render(
-
- test
- ,
- );
-
- const radio = screen.getByRole('radio');
- expect(radio.name).toEqual('my name');
- });
- test('has passed required to Radio children', (): void => {
- render(
-
- test
- ,
- );
-
- const radio = screen.getByRole('radio');
- expect(radio).toHaveAttribute('required');
- });
- test('has correct Radio defaultChecked & checked when defaultValue is used', () => {
- render(
-
- test1
- test2
- test3
- ,
- );
-
- const radio = screen.getByDisplayValue('test2');
- expect(radio.defaultChecked).toBeTruthy();
- expect(radio.checked).toBeTruthy();
- });
- test('has passed clicked Radio element to onChange', async () => {
- const user = userEvent.setup();
- const onChangeMock = vi.fn();
-
- render(
-
- test1
- test2
- test3
- ,
- );
-
- const radio = screen.getByDisplayValue('test2');
-
- await act(async () => await user.click(radio));
-
- expect(onChangeMock).toHaveBeenCalledWith('test2');
- expect(radio.checked).toBeTruthy();
- });
-});
diff --git a/packages/react/src/components/form/Radio/RadioGroup.tsx b/packages/react/src/components/form/Radio/RadioGroup.tsx
deleted file mode 100644
index 8d06e8809b..0000000000
--- a/packages/react/src/components/form/Radio/RadioGroup.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import cl from 'clsx/lite';
-import type { ReactNode } from 'react';
-import { createContext, forwardRef, useId } from 'react';
-
-import type { FieldsetProps } from '../Fieldset';
-import { Fieldset } from '../Fieldset';
-
-export type RadioGroupContextProps = {
- name?: string;
- value?: string;
- defaultValue?: string;
- required?: boolean;
- onChange?: (value: string) => void;
-};
-
-export const RadioGroupContext = createContext(
- null,
-);
-
-export type RadioGroupProps = {
- /** Collection of `Radio` components */
- children?: ReactNode;
- /** Controlled state for `Radio` */
- value?: string;
- /** Default checked `Radio` */
- defaultValue?: string;
- /** Callback event with checked `Radio` value */
- onChange?: (value: string) => void;
- /** Toggle if collection of `Radio` are required */
- required?: boolean;
- /** Orientation of `Radio` components.
- * @note Only use `horizontal` for when you have two options and short labels
- */
- inline?: boolean;
-} & Omit;
-
-export const RadioGroup = forwardRef(
- (
- {
- onChange,
- children,
- value,
- readOnly,
- defaultValue,
- name,
- size = 'md',
- required,
- inline,
- className,
- ...rest
- },
- ref,
- ) => {
- const nameId = useId();
-
- return (
-
- );
- },
-);
-
-RadioGroup.displayName = 'RadioGroup';
diff --git a/packages/react/src/components/form/Radio/index.ts b/packages/react/src/components/form/Radio/index.ts
index a4ab489660..1fdae38b65 100644
--- a/packages/react/src/components/form/Radio/index.ts
+++ b/packages/react/src/components/form/Radio/index.ts
@@ -1,27 +1,4 @@
-import type { RadioProps } from './Radio';
-import { Radio as RadioParent } from './Radio';
-import type { RadioGroupProps } from './RadioGroup';
-import { RadioGroup } from './RadioGroup';
+export * from './Radio';
-type RadioComponent = typeof RadioParent & {
- /**
- * Grouping multiple `Radio` together.
- * @example
- *
- * Yes
- * No
- *
- */
- Group: typeof RadioGroup;
-};
-
-/** ` element with `type="radio"` used for selecting one option */
-const Radio = RadioParent as RadioComponent;
-
-Radio.Group = RadioGroup;
-
-Radio.Group.displayName = 'Radio.Group';
-
-export type { RadioProps, RadioGroupProps };
-
-export { Radio, RadioGroup };
+// TODO: Kept for later implementation
+// export { useRadio } from './useRadio';
diff --git a/packages/react/src/components/form/Radio/useRadio.ts b/packages/react/src/components/form/Radio/useRadio.ts
deleted file mode 100644
index ef4b71aa3d..0000000000
--- a/packages/react/src/components/form/Radio/useRadio.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import type { InputHTMLAttributes } from 'react';
-import { useContext } from 'react';
-
-import type { FormField } from '../useFormField';
-import { useFormField } from '../useFormField';
-
-import type { RadioProps } from './Radio';
-import { RadioGroupContext } from './RadioGroup';
-
-type UseRadio = (props: RadioProps) => FormField & {
- inputProps?: Pick<
- InputHTMLAttributes,
- | 'readOnly'
- | 'type'
- | 'name'
- | 'required'
- | 'defaultChecked'
- | 'checked'
- | 'onClick'
- | 'onChange'
- >;
-};
-/** Handles props for `Radio` in context with `Radio.Group` (and `Fieldset`) */
-export const useRadio: UseRadio = (props) => {
- const radioGroup = useContext(RadioGroupContext);
- const { inputProps, readOnly, ...rest } = useFormField(props, 'radio');
-
- return {
- ...rest,
- readOnly,
- inputProps: {
- ...inputProps,
- readOnly,
- type: 'radio',
- name: radioGroup?.name ?? props.name,
- required: radioGroup?.required,
- defaultChecked: radioGroup?.defaultValue
- ? radioGroup?.defaultValue === props.value
- : props.defaultChecked,
- checked: radioGroup?.value
- ? radioGroup?.value === props.value
- : props.checked,
- onClick: (e) => {
- if (readOnly) {
- e.preventDefault();
- return;
- }
- props?.onClick?.(e);
- },
- onChange: (e) => {
- if (readOnly) {
- e.preventDefault();
- return;
- }
- props?.onChange?.(e);
- radioGroup?.onChange?.(props.value);
- },
- },
- };
-};
diff --git a/packages/react/src/components/form/Switch/Switch.test.tsx b/packages/react/src/components/form/Switch/Switch.test.tsx
index 73ba83220d..56b4801bef 100644
--- a/packages/react/src/components/form/Switch/Switch.test.tsx
+++ b/packages/react/src/components/form/Switch/Switch.test.tsx
@@ -64,24 +64,25 @@ describe('Switch', () => {
expect(onChange).not.toHaveBeenCalled();
});
- it('does not call onChange or onClick when user clicks and the Switch is readOnly', async () => {
- const user = userEvent.setup();
- const onChange = vi.fn();
- const onClick = vi.fn();
+ // TODO: Re-enable when using component
+ // it('does not call onChange when user clicks and the Switch is readOnly', async () => {
+ // const user = userEvent.setup();
+ // const onChange = vi.fn();
- render(
-
- readonly switch_
- ,
- );
+ // render(
+ //
+ // readonly switch_
+ // ,
+ // );
- const switch_ = screen.getByRole('switch');
- await act(async () => await user.click(switch_));
+ // const switch_ = screen.getByRole('switch');
+ // await act(async () => await user.click(switch_));
- expect(switch_).toHaveAttribute('readonly');
- expect(onClick).not.toHaveBeenCalled();
- expect(onChange).not.toHaveBeenCalled();
- });
+ // console.log(switch_.outerHTML);
+
+ // expect(switch_).toHaveAttribute('readonly');
+ // expect(onChange).not.toHaveBeenCalled();
+ // });
//TODO is there a good way to test size?
});
diff --git a/packages/react/src/components/form/Switch/useSwitch.ts b/packages/react/src/components/form/Switch/useSwitch.ts
index c85309d8f3..2f7d7db736 100644
--- a/packages/react/src/components/form/Switch/useSwitch.ts
+++ b/packages/react/src/components/form/Switch/useSwitch.ts
@@ -1,7 +1,6 @@
import type { InputHTMLAttributes } from 'react';
-import { useContext } from 'react';
-import { CheckboxGroupContext } from '../Checkbox/CheckboxGroup';
+// import { CheckboxGroupContext } from '../Checkbox/CheckboxGroup';
import type { FormField } from '../useFormField';
import { useFormField } from '../useFormField';
@@ -22,7 +21,7 @@ type UseCheckbox = (props: SwitchProps) => FormField & {
};
/** Handles props for `Switch` in context with `Checkbox.Group` (and `Fieldset`) */
export const useSwitch: UseCheckbox = (props) => {
- const checkboxGroup = useContext(CheckboxGroupContext);
+ // const checkboxGroup = useContext(CheckboxGroupContext);
const { inputProps, readOnly, ...rest } = useFormField(props, 'switch');
const propsValue = props.value || '';
@@ -34,12 +33,12 @@ export const useSwitch: UseCheckbox = (props) => {
readOnly,
type: 'checkbox',
role: 'switch',
- defaultChecked: checkboxGroup?.defaultValue
- ? checkboxGroup?.defaultValue.includes(propsValue)
- : props.defaultChecked,
- checked: checkboxGroup?.value
- ? checkboxGroup?.value.includes(propsValue)
- : props.checked,
+ // defaultChecked: checkboxGroup?.defaultValue
+ // ? checkboxGroup?.defaultValue.includes(propsValue)
+ // : props.defaultChecked,
+ // checked: checkboxGroup?.value
+ // ? checkboxGroup?.value.includes(propsValue)
+ // : props.checked,
onClick: (e) => {
if (readOnly) {
e.preventDefault();
@@ -53,7 +52,7 @@ export const useSwitch: UseCheckbox = (props) => {
return;
}
props?.onChange?.(e);
- checkboxGroup?.toggleValue(propsValue);
+ // checkboxGroup?.toggleValue(propsValue);
},
},
};
diff --git a/packages/react/src/components/form/useFormField.test.tsx b/packages/react/src/components/form/useFormField.test.tsx
index 2469c3b6bb..b1b66f85e7 100644
--- a/packages/react/src/components/form/useFormField.test.tsx
+++ b/packages/react/src/components/form/useFormField.test.tsx
@@ -99,29 +99,6 @@ describe('useFormField', () => {
expect(field.inputProps.disabled).toBeTruthy();
});
- test('is readonly', () => {
- const { result } = renderHook(
- () => useFormField({ readOnly: true }, 'test'),
- { wrapper: createWrapper(Fieldset) },
- );
-
- const field = result.current;
-
- expect(field.readOnly).toBeTruthy();
- });
-
- test('has disabled take presedens over readonly', () => {
- const { result } = renderHook(
- () => useFormField({ readOnly: true, disabled: true }, 'test'),
- { wrapper: createWrapper(Fieldset) },
- );
-
- const field = result.current;
-
- expect(field.readOnly).toBeFalsy();
- expect(field.inputProps.disabled).toBeTruthy();
- });
-
test('has correct size', () => {
const { result } = renderHook(() => useFormField({ size: 'sm' }, 'test'), {
wrapper: createWrapper(Fieldset),
@@ -149,19 +126,6 @@ describe('useFormField', () => {
expect(field.inputProps.disabled).toBeTruthy();
});
- test('has readOnly inherited from Fieldset', () => {
- const { result } = renderHook(
- () => useFormField({}, 'test'),
- {
- wrapper: createWrapper(Fieldset, { readOnly: true, legend: 'Wrapper' }),
- },
- );
-
- const field = result.current;
-
- expect(field.readOnly).toBeTruthy();
- });
-
test('has undefined aria-describedby', () => {
const { result } = renderHook(() =>
useFormField({}, 'test'),
diff --git a/packages/react/src/components/form/useFormField.ts b/packages/react/src/components/form/useFormField.ts
index 7c4ab727c1..b072c6d862 100644
--- a/packages/react/src/components/form/useFormField.ts
+++ b/packages/react/src/components/form/useFormField.ts
@@ -56,13 +56,10 @@ export const useFormField = (
const size = props.size ?? fieldset?.size ?? 'md';
const disabled = fieldset?.disabled || props?.disabled;
- const readOnly =
- ((fieldset?.readOnly || props?.readOnly) && !disabled) || undefined;
- const hasError = !disabled && !readOnly && !!(props.error || fieldset?.error);
+ const hasError = !disabled && !!(props.error || fieldset?.error);
return {
- readOnly,
hasError,
errorId,
descriptionId,
diff --git a/packages/react/stories/showcase.stories.tsx b/packages/react/stories/showcase.stories.tsx
index be02ad26e8..064c58f0d3 100644
--- a/packages/react/stories/showcase.stories.tsx
+++ b/packages/react/stories/showcase.stories.tsx
@@ -9,6 +9,7 @@ import {
Checkbox,
Combobox,
Divider,
+ Fieldset,
Heading,
Link,
Pagination,
@@ -54,22 +55,16 @@ export const Showcase: StoryFn = () => {
return (
-
- En kilo poteter
- To liter Farris
-
- Blomkål
-
-
- Pizza
-
-
- Tre liter lettmelk
-
+
+
+
+
@@ -214,18 +209,12 @@ export const Showcase: StoryFn = () => {
- setRadioValue(e)}
- >
- Vanilje
- Jordbær
- Sjokolade
- Jeg spiser ikke iskrem
-
+
Emner
diff --git a/packages/react/stories/testing.stories.tsx b/packages/react/stories/testing.stories.tsx
index 373546a5da..9fe9f8d666 100644
--- a/packages/react/stories/testing.stories.tsx
+++ b/packages/react/stories/testing.stories.tsx
@@ -107,18 +107,14 @@ export const MediumRow: StoryFn<{
Removable
Tag
-
- Radio
-
+
-
- Checkbox
-
+
+ />
>
);