From 581863afca9fdf3d9af9021dd81e2e7660fd2979 Mon Sep 17 00:00:00 2001 From: Eirik Backer Date: Thu, 14 Nov 2024 10:46:24 +0100 Subject: [PATCH] fix(Switch): use field for consistent component api (#2773) - Convert Switch to use Field component - Design adjustments will be done in #2664 --- .changeset/hungry-moles-repeat.md | 5 + .../Previews/Components/Components.tsx | 10 +- packages/css/field.css | 11 +- packages/css/fieldset.css | 2 +- packages/css/index.css | 1 - packages/css/switch.css | 246 ------------------ .../form/Combobox/Combobox.stories.tsx | 26 +- .../react/src/components/form/Field/Field.tsx | 9 +- .../src/components/form/Switch/Switch.mdx | 14 +- .../components/form/Switch/Switch.stories.tsx | 88 +++---- .../components/form/Switch/Switch.test.tsx | 27 +- .../src/components/form/Switch/Switch.tsx | 139 ++++------ .../src/components/form/Switch/useSwitch.ts | 59 ----- .../components/form/Textfield/Textfield.tsx | 2 +- packages/react/stories/showcase.stories.tsx | 10 +- packages/react/stories/testing.stories.tsx | 4 +- 16 files changed, 153 insertions(+), 500 deletions(-) create mode 100644 .changeset/hungry-moles-repeat.md delete mode 100644 packages/css/switch.css delete mode 100644 packages/react/src/components/form/Switch/useSwitch.ts diff --git a/.changeset/hungry-moles-repeat.md b/.changeset/hungry-moles-repeat.md new file mode 100644 index 0000000000..5fecfa119c --- /dev/null +++ b/.changeset/hungry-moles-repeat.md @@ -0,0 +1,5 @@ +--- +"@digdir/designsystemet-react": major +--- + +Switch: Use `label` prop instead of `children` to render label diff --git a/apps/theme/components/Previews/Components/Components.tsx b/apps/theme/components/Previews/Components/Components.tsx index a37c25b5ea..de904bd7f8 100644 --- a/apps/theme/components/Previews/Components/Components.tsx +++ b/apps/theme/components/Previews/Components/Components.tsx @@ -249,12 +249,10 @@ export const Components = () => { Her kan du justere på innstillingene dine - TV-visning - Desktopvisning - - Tabletvisning - - Mobilvisning + + + +
diff --git a/packages/css/field.css b/packages/css/field.css index 351bb66957..b58769547d 100644 --- a/packages/css/field.css +++ b/packages/css/field.css @@ -26,12 +26,13 @@ &:has(input:is([type='radio'], [type='checkbox'])) { border-radius: var(--ds-border-radius-md); display: grid; + grid-template-areas: 'input content'; 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 */ + grid-column: content; /* Only allow input in column 1 */ } & label { @@ -40,7 +41,7 @@ } & input { - grid-column: 1; /* Always place input in column 1 */ + grid-column: input; /* Always place input in column 1 */ grid-row: 1; /* Always place input in row 1 */ } @@ -55,6 +56,12 @@ &:has(input:only-child) { gap: 0; /* No gap only with aria-label/aria-labelledby */ } + + &[data-position='end'] { + grid-template-areas: 'content input'; + grid-template-columns: 1fr auto; + width: auto; + } } } diff --git a/packages/css/fieldset.css b/packages/css/fieldset.css index 8566e7ceb6..ee003e54c0 100644 --- a/packages/css/fieldset.css +++ b/packages/css/fieldset.css @@ -5,7 +5,7 @@ padding: 0; /* Add lock icon to legend when only containing readonly inputs */ - &:has([readonly]):not(:has(:read-write)) > legend { + &:has(input[readonly]):not(:has(input:not([readonly]))) > legend { --dsc-label--readonly: ; /* Using technique https://css-tricks.com/the-css-custom-property-toggle-trick/ */ } diff --git a/packages/css/index.css b/packages/css/index.css index 5f564e3461..92e84e3ae9 100644 --- a/packages/css/index.css +++ b/packages/css/index.css @@ -15,7 +15,6 @@ @import url('./popover.css') layer(ds.components); @import url('./skiplink.css') layer(ds.components); @import url('./accordion.css') layer(ds.components); -@import url('./switch.css') layer(ds.components); @import url('./search.css') layer(ds.components); @import url('./textfield.css') layer(ds.components); @import url('./helptext.css') layer(ds.components); diff --git a/packages/css/switch.css b/packages/css/switch.css deleted file mode 100644 index 117d0be2c2..0000000000 --- a/packages/css/switch.css +++ /dev/null @@ -1,246 +0,0 @@ -.ds-switch { - --dsc-switch--transition: 200ms; - --dsc-switch-height: 1.75rem; - --dsc-switch-focus-border-width: 3px; - --dsc-switch-check_color: transparent; - --dsc-switch-thumb-background-color: var(--ds-color-neutral-background-default); - - position: relative; -} - -@media (prefers-reduced-motion) { - .switch { - --dsc-switch--transition: 0; - } -} - -.ds-switch__label { - min-height: var(--ds-sizing-10); - min-width: min-content; - display: grid; - grid-template-columns: auto 1fr; - gap: var(--ds-spacing-1); - align-items: center; - cursor: pointer; -} - -.ds-switch__track { - position: relative; - display: inline-block; - pointer-events: none; - width: var(--dsc-switch-width); - height: var(--dsc-switch-height); - margin: auto; - overflow: visible; - border-radius: var(--ds-border-radius-full); - background-color: var(--ds-color-neutral-border-default); - transition: background-color var(--dsc-switch--transition) ease; - margin-right: var(--ds-spacing-1); -} - -.ds-switch__description { - padding-left: calc(var(--dsc-switch-width) + var(--ds-spacing-2)); - margin-top: var(--ds-spacing-1); - color: var(--ds-color-neutral-text-subtle); - width: fit-content; -} - -.ds-switch__readonly__icon { - height: 1.2em; - width: 1.2em; -} - -.ds-switch__label--right { - grid-template-columns: 1fr auto; - grid-auto-flow: dense; -} - -.ds-switch__label--right .ds-switch__track { - order: 1; - margin-right: 0; -} - -.ds-switch__label--right + .ds-switch__description { - padding-left: 0; -} - -.ds-switch__input { - position: absolute; - width: 2.75rem; - height: 2.75rem; - z-index: 1; - opacity: 0; - cursor: pointer; - margin: 0; -} - -.ds-switch--readonly > .ds-switch__label { - grid-template-columns: auto min-content 1fr; -} - -.ds-switch--readonly > .ds-switch__label:where(.ds-switch__label--right) { - grid-template-columns: min-content 1fr auto; -} - -.ds-switch--readonly > .ds-switch__input, -.ds-switch--readonly > .ds-switch__label { - cursor: default; -} - -.ds-switch--readonly > .ds-switch__description { - margin-left: var(--ds-spacing-1); -} - -.ds-switch--sm, -.ds-switch--sm .ds-switch__label { - min-height: var(--ds-sizing-6); -} - -.ds-switch--md, -.ds-switch--md .ds-switch__label { - min-height: var(--ds-sizing-7); -} - -.ds-switch--lg, -.ds-switch--lg .ds-switch__label { - min-height: var(--ds-sizing-8); -} - -.ds-switch--sm { - --dsc-switch-height: var(--ds-sizing-6); - --dsc-switch-width: var(--ds-sizing-11); -} - -.ds-switch--sm .ds-switch__input { - left: -0.25rem; - top: -0.25rem; -} - -.ds-switch--md { - --dsc-switch-height: var(--ds-sizing-7); - --dsc-switch-width: var(--ds-sizing-13); -} - -.ds-switch--md .ds-switch__input { - left: 0; - top: 0; -} - -.ds-switch--lg { - --dsc-switch-height: var(--ds-sizing-8); - --dsc-switch-width: var(--ds-sizing-15); -} - -.ds-switch--lg .ds-switch__input { - left: 0; - top: 0.25rem; -} - -.ds-switch__label:has(.ds-switch__track:only-child) { - grid-template-columns: auto; -} - -.ds-switch__label:has(.ds-switch__track:only-child) .ds-switch__track { - margin-right: 0; -} - -.ds-switch__input:disabled, -.ds-switch:has(.ds-switch__input:disabled) > .ds-switch__label { - cursor: not-allowed; -} - -.ds-switch:has(.ds-switch__input:disabled) > .ds-switch__label, -.ds-switch:has(.ds-switch__input:disabled) > .ds-switch__description { - opacity: var(--ds-disabled-opacity); -} - -/* .ds-switch__input:focus-visible + .ds-switch__label .ds-switch__track { - outline: var(--dsc-switch-focus-border-width) solid var(--ds-color-focus-outer); - outline-offset: var(--dsc-switch-focus-border-width); - box-shadow: 0 0 0 var(--dsc-switch-focus-border-width) var(--ds-color-focus-inner); - } */ - -/** - * Apply a focus outline on an element when it is focused with keyboard - */ -.ds-switch:has(.ds-switch__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-md); -} - -.ds-switch__input:not([readonly]):checked + .ds-switch__label .ds-switch__track { - background-color: var(--ds-color-accent-base-default); -} - -.ds-switch__thumb { - scale: 0.8; - position: absolute; - height: var(--dsc-switch-height); - width: var(--dsc-switch-height); - border-radius: var(--ds-border-radius-full); - background-color: var(--dsc-switch-thumb-background-color); - transition: transform var(--dsc-switch--transition) ease; -} - -.ds-switch__input:checked + .ds-switch__label .ds-switch__track .ds-switch__thumb { - --dsc-switch-check_color: var(--ds-color-accent-base-default); - --dsc-switch-thumb-background-color: var(--ds-color-accent-contrast-default); - - transform: translateX(calc((var(--dsc-switch-width) - var(--dsc-switch-height)) * 1.2)); -} - -.ds-switch__thumb::after { - content: ''; - width: 100%; - height: 100%; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - background-color: var(--dsc-switch-check_color); - mask-image: url("data:image/svg+xml,%3Csvg viewBox='-3 -3 17 17' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M10.1339 2.86612C10.622 3.35427 10.622 4.14573 10.1339 4.63388L5.88388 8.88388C5.39573 9.37204 4.60427 9.37204 4.11612 8.88388L1.86612 6.63388C1.37796 6.14573 1.37796 5.35427 1.86612 4.86612C2.35427 4.37796 3.14573 4.37796 3.63388 4.86612L5 6.23223L8.36612 2.86612C8.85427 2.37796 9.64573 2.37796 10.1339 2.86612Z' fill='%23118849' /%3E%3C/svg%3E"); - transition: background-color var(--dsc-switch--transition) ease; -} - -.ds-switch--readonly .ds-switch__input[readonly] + .ds-switch__label .ds-switch__track { - box-shadow: inset 0 0 0 2px var(--ds-color-neutral-border-subtle); - background-color: var(--ds-color-neutral-background-subtle); -} - -.ds-switch--readonly .ds-switch__input[readonly] + .ds-switch__label .ds-switch__track > .ds-switch__thumb { - background-color: var(--ds-color-neutral-border-strong); -} - -.ds-switch--readonly .ds-switch__input[readonly]:checked + .ds-switch__label .ds-switch__track > .ds-switch__thumb { - --dsc-switch-check_color: var(--ds-color-neutral-background-subtle); - - background-color: var(--ds-color-neutral-border-strong); -} - -@media (hover: hover) and (pointer: fine) { - .ds-switch__input:not([readonly], :disabled):hover + .ds-switch__label .ds-switch__track > .ds-switch__thumb { - transform: translateX(calc((var(--dsc-switch-width) - var(--dsc-switch-height)) * 0.2)); - } - - .ds-switch__input:not([readonly], :disabled):hover + .ds-switch__label { - color: var(--ds-color-accent-text-subtle); - } - - .ds-switch__input:not(:disabled, [readonly]):checked:hover + .ds-switch__label .ds-switch__track > .ds-switch__thumb { - --dsc-switch-check_color: var(--ds-color-accent-base-hover); - - transform: translateX(calc((var(--dsc-switch-width) - var(--dsc-switch-height)))); - } - - .ds-switch__input:not(:checked, :disabled, [readonly]):hover + .ds-switch__label .ds-switch__track { - background-color: var(--ds-color-neutral-border-strong); - } - - .ds-switch__input:not(:disabled, [readonly]):checked:hover + .ds-switch__label .ds-switch__track { - background-color: var(--ds-color-accent-base-hover); - } -} diff --git a/packages/react/src/components/form/Combobox/Combobox.stories.tsx b/packages/react/src/components/form/Combobox/Combobox.stories.tsx index 0a1ae41f65..457d2acb33 100644 --- a/packages/react/src/components/form/Combobox/Combobox.stories.tsx +++ b/packages/react/src/components/form/Combobox/Combobox.stories.tsx @@ -169,14 +169,13 @@ export const WithDescription: StoryFn = (args) => { return ( <> { setMultiple(e.target.checked); setValue([]); }} - > - Multiple - + /> = (args) => { { setMultiple(e.target.checked); setValue([]); }} - > - Multiple - + /> Value er: {value.join(', ')} @@ -602,13 +600,15 @@ export const RemoveAllOptions: StoryFn = (args) => { ); })} - changeAllValues(event.target.checked)}> - Remove Values (Selected values remain unchanged as the combobox does not - update when options are empty.) - - changeSomeValues(event.target.checked)}> - Remove test2 (this works) - + changeAllValues(event.target.checked)} + /> + changeSomeValues(event.target.checked)} + /> ); }; diff --git a/packages/react/src/components/form/Field/Field.tsx b/packages/react/src/components/form/Field/Field.tsx index 3b2ab68ff0..e6c181d431 100644 --- a/packages/react/src/components/form/Field/Field.tsx +++ b/packages/react/src/components/form/Field/Field.tsx @@ -5,7 +5,14 @@ import { forwardRef, useEffect, useRef } from 'react'; import type { DefaultProps } from '../../../types'; import { fieldObserver } from './fieldObserver'; -export type FieldProps = HTMLAttributes & DefaultProps; +export type FieldProps = { + /** Position of toggle inputs (radio, checkbox, switch) in field + * @default start + */ + position?: 'start' | 'end'; +} & HTMLAttributes & + DefaultProps; + export const Field = forwardRef(function Field( { className, ...rest }, ref, diff --git a/packages/react/src/components/form/Switch/Switch.mdx b/packages/react/src/components/form/Switch/Switch.mdx index a4ed2df92a..4e1ce4e02a 100644 --- a/packages/react/src/components/form/Switch/Switch.mdx +++ b/packages/react/src/components/form/Switch/Switch.mdx @@ -1,6 +1,6 @@ import { Meta, Canvas, Controls, Primary } from '@storybook/blocks'; import { CssVariables } from '@doc-components'; -import css from '@digdir/designsystemet-css/switch.css?inline'; +import css from '@digdir/designsystemet-css/input.css?inline'; import * as SwitchStories from './Switch.stories'; @@ -15,25 +15,23 @@ Vi bruker `Switch` til å gi brukerne et valg mellom to alternativer. Bryteren k ## Slik bruker du `Switch` ```tsx -import { Switch, Fieldset } from '@digdir/designsystemet-react'; +import { Switch } from '@digdir/designsystemet-react'; -
- Mørk modus -
; + ``` ## Gruppering Bruker [Fieldset](/docs/komponenter-fieldset--docs) til gruppering. - + ### Høyrejustert -Bruk `position="right"` til å plassere `Switch` på høyre side av ledeteksten hvis +Bruk `position="end"` til å plassere `Switch` på høyre side av ledeteksten hvis du trenger det. - + ## Retningslinjer for `Switch` diff --git a/packages/react/src/components/form/Switch/Switch.stories.tsx b/packages/react/src/components/form/Switch/Switch.stories.tsx index 203d6ac8f3..7de09b38be 100644 --- a/packages/react/src/components/form/Switch/Switch.stories.tsx +++ b/packages/react/src/components/form/Switch/Switch.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryFn, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react'; import { Fieldset } from '../Fieldset'; @@ -13,69 +13,47 @@ export default { export const Preview: Story = { args: { - children: 'Switch', + 'data-size': 'md', + label: 'Switch', + description: '', disabled: false, readOnly: false, - size: 'md', - position: 'left', - description: '', - }, -}; - -export const Hovered: Story = { - ...Preview, - parameters: { - pseudo: { - hover: true, - }, + position: 'start', }, }; -export const Enabled: Story = { +export const Checked: Story = { ...Preview, args: { ...Preview.args, checked: true }, }; -export const EnabledHovered: Story = { - ...Hovered, - args: { ...Hovered.args, checked: true }, +export const Group: Story = { + render: ({ 'aria-label': a, 'aria-labelledby': b, ...args }) => ( +
+ Skru av/på en eller flere innstillinger + + + + +
+ ), }; -export const FullWidth: StoryFn = (args) => ( -
- Skru av/på en eller flere innstillinger - - Innstilling 1 - - - Innstilling 2 - - - Innstilling 3 - - - Innstilling 4 - -
-); - -export const FullWidthRight = FullWidth.bind({}); - -FullWidthRight.args = { - position: 'right', +export const GroupEnd: Story = { + ...Group, + args: { + position: 'end', + }, }; diff --git a/packages/react/src/components/form/Switch/Switch.test.tsx b/packages/react/src/components/form/Switch/Switch.test.tsx index 56b4801bef..a19dd970a9 100644 --- a/packages/react/src/components/form/Switch/Switch.test.tsx +++ b/packages/react/src/components/form/Switch/Switch.test.tsx @@ -6,17 +6,13 @@ import { Switch } from './Switch'; describe('Switch', () => { 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('switch', { description: 'description' }), ).toBeDefined(); @@ -29,9 +25,12 @@ describe('Switch', () => { const value = 'test'; render( - - label - , + , ); const switch_ = screen.getByRole('switch'); @@ -51,9 +50,13 @@ describe('Switch', () => { const onClick = vi.fn(); render( - - disabled switch_ - , + , ); const switch_ = screen.getByRole('switch'); diff --git a/packages/react/src/components/form/Switch/Switch.tsx b/packages/react/src/components/form/Switch/Switch.tsx index 945cec9572..6c1241fc6a 100644 --- a/packages/react/src/components/form/Switch/Switch.tsx +++ b/packages/react/src/components/form/Switch/Switch.tsx @@ -1,97 +1,62 @@ -import { PadlockLockedFillIcon } from '@navikt/aksel-icons'; -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 { useSwitch } from './useSwitch'; +import { Field, type FieldProps } from '../Field'; +import { Input, type InputProps } from '../Input'; export type SwitchProps = { - /** Switch label */ - children?: ReactNode; + /** Optional aria-label */ + 'aria-label'?: string; + /** Radio label */ + label?: ReactNode; + /** Description for field */ + description?: ReactNode; /** Value of the `input` element */ - value?: string; - /** Position of switch around the label - * @default left + value?: InputProps['value']; + /** Position of switch + * @default start */ - position?: 'left' | 'right'; -} & Omit & - Omit, 'size' | 'value'>; - -export const Switch = forwardRef( - (props, ref) => { - const { - children, - description, - position = 'left', - className, - ...rest - } = props; - const { - inputProps, - descriptionId, - size = 'md', - readOnly, - } = useSwitch(props); - - return ( - -
- + position?: FieldProps['position']; + /** + * Changes field size and paddings + */ + 'data-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 } + ); - - {description && ( - -
- {description} -
-
- )} -
-
- ); +/** + * Switch used to toggle options. + * @example + * + */ +export const Switch = forwardRef(function Switch( + { + 'data-size': size, + children, + className, + description, + label, + position, + style, + ...rest }, -); - -Switch.displayName = 'Switch'; + ref, +) { + return ( + + + {!!label && } + {!!description &&
{description}
} +
+ ); +}); diff --git a/packages/react/src/components/form/Switch/useSwitch.ts b/packages/react/src/components/form/Switch/useSwitch.ts deleted file mode 100644 index 2f7d7db736..0000000000 --- a/packages/react/src/components/form/Switch/useSwitch.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { InputHTMLAttributes } from 'react'; - -// import { CheckboxGroupContext } from '../Checkbox/CheckboxGroup'; -import type { FormField } from '../useFormField'; -import { useFormField } from '../useFormField'; - -import type { SwitchProps } from './Switch'; - -type UseCheckbox = (props: SwitchProps) => FormField & { - inputProps?: Pick< - InputHTMLAttributes, - | 'readOnly' - | 'type' - | 'name' - | 'required' - | 'defaultChecked' - | 'checked' - | 'onClick' - | 'onChange' - >; -}; -/** Handles props for `Switch` in context with `Checkbox.Group` (and `Fieldset`) */ -export const useSwitch: UseCheckbox = (props) => { - // const checkboxGroup = useContext(CheckboxGroupContext); - const { inputProps, readOnly, ...rest } = useFormField(props, 'switch'); - - const propsValue = props.value || ''; - return { - ...rest, - readOnly, - inputProps: { - ...inputProps, - readOnly, - type: 'checkbox', - role: 'switch', - // defaultChecked: checkboxGroup?.defaultValue - // ? checkboxGroup?.defaultValue.includes(propsValue) - // : props.defaultChecked, - // checked: checkboxGroup?.value - // ? checkboxGroup?.value.includes(propsValue) - // : props.checked, - onClick: (e) => { - if (readOnly) { - e.preventDefault(); - return; - } - props?.onClick?.(e); - }, - onChange: (e) => { - if (readOnly) { - e.preventDefault(); - return; - } - props?.onChange?.(e); - // checkboxGroup?.toggleValue(propsValue); - }, - }, - }; -}; diff --git a/packages/react/src/components/form/Textfield/Textfield.tsx b/packages/react/src/components/form/Textfield/Textfield.tsx index 7dfa771b56..b965fee2c9 100644 --- a/packages/react/src/components/form/Textfield/Textfield.tsx +++ b/packages/react/src/components/form/Textfield/Textfield.tsx @@ -81,7 +81,7 @@ export const Textfield = forwardRef< ref, ) { return ( - + {!!label && } {!!description && {description}} diff --git a/packages/react/stories/showcase.stories.tsx b/packages/react/stories/showcase.stories.tsx index d4d669ef6f..2864dd4185 100644 --- a/packages/react/stories/showcase.stories.tsx +++ b/packages/react/stories/showcase.stories.tsx @@ -273,12 +273,10 @@ export const Showcase: StoryFn = () => { Her kan du justere på innstillingene dine
- TV-visning - Desktopvisning - - Tabletvisning - - Mobilvisning + + + +
diff --git a/packages/react/stories/testing.stories.tsx b/packages/react/stories/testing.stories.tsx index 2cb5a415d0..f6fcb61eb3 100644 --- a/packages/react/stories/testing.stories.tsx +++ b/packages/react/stories/testing.stories.tsx @@ -101,8 +101,8 @@ export const MediumRow: StoryFn<{ flexDirection: direction, }} > - Switch - + + Toggle Removable Tag