diff --git a/.changeset/tall-guests-arrive.md b/.changeset/tall-guests-arrive.md new file mode 100644 index 0000000000..a9b79b95eb --- /dev/null +++ b/.changeset/tall-guests-arrive.md @@ -0,0 +1,9 @@ +--- +"@digdir/designsystemet-css": patch +"@digdir/designsystemet-react": patch +--- + +Chip: +- Add `Chip.Button` +- Rename `Chip.Toggle` to `Chip.Radio` and `Chip.Checkbox` +- Remove `Chip.Group` diff --git a/apps/theme/components/Previews/Components/Components.tsx b/apps/theme/components/Previews/Components/Components.tsx index affa277bcd..60e81fb22c 100644 --- a/apps/theme/components/Previews/Components/Components.tsx +++ b/apps/theme/components/Previews/Components/Components.tsx @@ -275,11 +275,15 @@ export const Components = () => { Filtrer på språk
- + Bokmål - - Nynorsk - Engelsk + + + Nynorsk + + + Engelsk +
diff --git a/packages/css/chip.css b/packages/css/chip.css index d05a825f76..ab74432e6f 100644 --- a/packages/css/chip.css +++ b/packages/css/chip.css @@ -1,173 +1,134 @@ -/* - The class is unused. There is no root chip component? - .chip { - display: flex; - } - */ - -.ds-chip--button { - --dsc-chip-height: var(--ds-sizing-7); - --dsc-chip-padding: 0 var(--ds-spacing-3); +.ds-chip { --dsc-chip-background: var(--ds-color-accent-surface-default); - --dsc-chip-text-color: var(--ds-color-accent-text-default); - --dsc-chip-border: var(--ds-color-accent-border-subtle); + --dsc-chip-border-color: var(--ds-color-accent-border-subtle); --dsc-chip-border-radius: var(--ds-border-radius-full); + --dsc-chip-color: var(--ds-color-accent-text-default); + --dsc-chip-height: var(--ds-sizing-8); + --dsc-chip-icon-size: var(--ds-spacing-6); + --dsc-chip-input-color: var(--ds-color-accent-border-default); + --dsc-chip-input-size: var(--ds-spacing-5); + --dsc-chip-padding: 0 var(--ds-spacing-3); + + @composes ds-focus from './utilities.css'; + @composes ds-paragraph-short from './baseline/typography.css'; + align-items: center; background: var(--dsc-chip-background); - padding: var(--dsc-chip-padding); - min-height: var(--dsc-chip-height); border-radius: var(--dsc-chip-border-radius); - border: 1px solid var(--dsc-chip-border); - color: var(--dsc-chip-text-color); - text-decoration: none; - font-family: inherit; + border: 1px solid var(--dsc-chip-border-color); + box-sizing: border-box; + color: var(--dsc-chip-color); + cursor: pointer; display: inline-flex; - align-items: center; -} - -.ds-chip--button:disabled, -.ds-chip--button[aria-disabled='true'] { - cursor: not-allowed; - opacity: var(--ds-disabled-opacity); -} - -.ds-chip--button .ds-chip__label { - color: inherit; - line-height: normal; - display: flex; - align-items: center; - flex-direction: row; - gap: var(--ds-spacing-2); -} - -.ds-chip--removable { - --dsc-removable-background: var(--ds-color-accent-base-default); - --dsc-removable-text-color: var(--ds-color-neutral-contrast-default); - --dsc-removable-chip-size: var(--ds-sizing-7); - --dsc-removable-chip-xmark-color: var(--ds-color-neutral-contrast-default); - --dsc-removable-chip-xmark-padding_right: var(--ds-spacing-1); - --dsc-removable-chip-xmark-size: var(--ds-sizing-6); - --dsc-removable-chip-xmark-wrapper-width: calc(var(--dsc-removable-chip-xmark-size) + var(--dsc-removable-chip-xmark-padding_right)); - - color: var(--dsc-removable-text-color); - background: var(--dsc-removable-background); - border: 0; - padding-right: var(--ds-spacing-2); + font-family: inherit; min-height: var(--dsc-chip-height); -} - -.ds-chip--removable.ds-chip--sm { - padding-right: var(--ds-spacing-1); -} - -.ds-chip--removable.ds-chip--lg { - padding-right: var(--ds-spacing-2); -} - -.ds-chip__x-mark { - color: var(--dsc-removable-chip-xmark-color); - height: var(--dsc-removable-chip-xmark-size); - width: var(--dsc-removable-chip-xmark-size); -} - -.ds-chip__x-mark .ds-chip__icon { - height: var(--dsc-removable-chip-xmark-size); - width: var(--dsc-removable-chip-xmark-size); -} - -.ds-chip--spacing { - padding-left: var(--ds-spacing-2) !important; -} - -.ds-chip--sm .ds-chip__checkmark-icon { - height: var(--ds-sizing-5); - width: auto; -} - -.ds-chip--md .ds-chip__checkmark-icon { - height: 24px; - width: auto; -} - -.ds-chip--lg .ds-chip__checkmark-icon { - height: 26px; - width: auto; -} - -.ds-chip--group-container { - --dsc-chip-group-gap: var(--ds-spacing-2); - - align-items: center; - display: flex; - flex-wrap: wrap; - gap: var(--dsc-chip-group-gap); - list-style: none; - margin: 0; - padding: 0; -} + padding: var(--dsc-chip-padding); + text-decoration: none; -.ds-chip--group-container.ds-chip--sm { - --dsc-chip-group-gap: var(--ds-spacing-2); -} + /* Make focus ring also when input inside is focused */ + &:has(:focus-visible) { + box-shadow: var(--dsc-focus-boxShadow); + } -.ds-chip--group-container.ds-chip--md { - --dsc-chip-group-gap: var(--ds-spacing-2); -} + &:disabled, + &:has(input:disabled), + &[aria-disabled='true'] { + cursor: not-allowed; + opacity: var(--ds-disabled-opacity); + } -.ds-chip--group-container.ds-chip--lg { - --dsc-chip-group-gap: var(--ds-spacing-2); -} + &:has(input[type='checkbox']) { + --dsc-chip-border-radius: var(--ds-border-radius-lg); + --dsc-chip-padding: 0 var(--ds-spacing-2) 0 var(--ds-spacing-3); + } -/* Only use hover for non-touch devices to prevent sticky-hovering */ -@media (hover: hover) and (pointer: fine) { - .ds-chip--button:not(:disabled, [aria-disabled='true']):hover { - --dsc-chip-background: var(--ds-color-accent-surface-hover); - --dsc-chip-text-color: var(--ds-color-accent-text-default); - --dsc-chip-border: var(--ds-color-accent-border-default); + &[data-removable]::after { + --gap: calc((var(--dsc-chip-height) - var(--dsc-chip-icon-size)) / 2); /* Gap is based on remaining space between icon and edge */ - cursor: pointer; + background: currentcolor; + content: ''; + height: var(--dsc-chip-icon-size); + margin: 0 calc(var(--gap) * -1) 0 var(--gap); + mask: center/contain no-repeat + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' fill='none' viewBox='0 0 24 24' focusable='false' role='img'%3E%3Cpath fill='currentColor' d='M6.53 5.47a.75.75 0 0 0-1.06 1.06L10.94 12l-5.47 5.47a.75.75 0 1 0 1.06 1.06L12 13.06l5.47 5.47a.75.75 0 1 0 1.06-1.06L13.06 12l5.47-5.47a.75.75 0 0 0-1.06-1.06L12 10.94z'%3E%3C/path%3E%3C/svg%3E"); + width: var(--dsc-chip-icon-size); } - .ds-chip--button:not(:disabled, [aria-disabled='true']):is([aria-pressed='true']):hover { - --dsc-chip-background: var(--ds-color-accent-surface-hover); - --dsc-chip-text-color: var(--ds-color-accent-text-default); + &[data-size='sm'] { + --dsc-chip-height: var(--ds-sizing-7); + --dsc-chip-icon-size: var(--ds-sizing-5); + --dsc-chip-input-size: var(--ds-spacing-4); } - .ds-chip--removable:not(:disabled, [aria-disabled='true']):hover, - .ds-chip--removable:not(:disabled, [aria-disabled='true']):focus { - --dsc-removable-background: var(--ds-color-accent-base-hover); - --dsc-removable-chip-xmark-color: var(--ds-color-accent-contrast-default); + &[data-size='lg'] { + --dsc-chip-height: var(--ds-sizing-9); + --dsc-chip-icon-size: var(--ds-sizing-7); + --dsc-chip-input-size: var(--ds-spacing-6); } -} - -.ds-chip--button:is([aria-pressed='true']), -.ds-chip--button:not(:disabled, [aria-disabled='true']):active, -.ds-chip--removable:is([aria-pressed='true']), -.ds-chip--removable:not(:disabled, [aria-disabled='true']):active { - --dsc-chip-background: var(--ds-color-accent-base-active); - --dsc-chip-text-color: var(--ds-color-accent-contrast-default); - --dsc-chip-border: var(--ds-color-accent-base-active); - --dsc-removable-background: var(--ds-color-accent-base-active); - --dsc-removable-text-color: var(--ds-color-neutral-contrast-default); -} -.ds-chip--sm { - --dsc-chip-height: var(--ds-sizing-7); - --dsc-chip-padding: 0 var(--ds-spacing-3); - --dsc-removable-chip-xmark-size: var(--ds-sizing-5); - --dsc-removable-chip-xmark-padding_right: var(--ds-spacing-1); -} + & > input { + --gap: calc((var(--dsc-chip-height) - var(--dsc-chip-input-size)) / 2); /* Gap is based on remaining space between input and edge */ + + appearance: none; + background: none; + border-radius: calc(var(--dsc-chip-border-radius) - (var(--gap) / 2)); /* Use same radius as */ + border: 2px solid; + box-sizing: border-box; + color: var(--dsc-chip-input-color); + height: var(--dsc-chip-input-size); + margin: 0 var(--gap) 0 calc(var(--gap) * -1); + outline: none; + width: var(--dsc-chip-input-size); + + &[type='radio']:checked { + border-width: calc(var(--dsc-chip-input-size) / 3.5); /* Matches Figma */ + } + + &[type='checkbox']:checked { + background: currentcolor; + mask: center/cover no-repeat + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill-rule='evenodd' d='M24 0H0v24h24V0Zm-4.44 8.56a1.5 1.5 0 0 0-2.12-2.12L10 13.88l-3.44-3.44a1.5 1.5 0 0 0-2.12 2.12l4.5 4.5a1.5 1.5 0 0 0 2.12 0l8.5-8.5Z'/%3E%3C/svg%3E"); + } + } -.ds-chip--md { - --dsc-chip-height: var(--ds-sizing-8); - --dsc-chip-padding: 0 var(--ds-spacing-3); - --dsc-removable-chip-xmark-size: var(--ds-sizing-6); - --dsc-removable-chip-xmark-padding_right: var(--ds-spacing-2); -} + &:has(input:checked), + &[data-removable] { + --dsc-chip-background: var(--ds-color-accent-base-default); + --dsc-chip-border-color: transparent; + --dsc-chip-color: var(--ds-color-accent-contrast-default); + --dsc-chip-input-color: currentcolor; + } -.ds-chip--lg { - --dsc-chip-height: var(--ds-sizing-9); - --dsc-chip-padding: 0 var(--ds-spacing-4); - --dsc-removable-chip-xmark-size: var(--ds-sizing-7); - --dsc-removable-chip-xmark-padding_right: var(--ds-spacing-3); + /* Only use hover for non-touch devices to prevent sticky-hovering */ + @media (hover: hover) and (pointer: fine) { + &:where(:not(:disabled, :has(input:disabled), [aria-disabled='true'])) { + &:hover { + --dsc-chip-background: var(--ds-color-accent-surface-hover); + --dsc-chip-border-color: var(--ds-color-accent-border-default); + --dsc-chip-color: var(--ds-color-accent-text-default); + --dsc-chip-input-color: var(--ds-color-accent-border-strong); + } + + &:active { + --dsc-chip-background: var(--ds-color-accent-surface-active); + --dsc-chip-border-color: var(--ds-color-accent-border-strong); + --dsc-chip-input-color: var(--ds-color-accent-border-strong); + } + + &[data-removable]:hover, + &:has(input:checked):hover { + --dsc-chip-background: var(--ds-color-accent-base-hover); + --dsc-chip-border-color: transparent; + --dsc-chip-color: var(--ds-color-accent-contrast-default); + --dsc-chip-input-color: currentcolor; + } + + &[data-removable]:active, + &:has(input:checked):active { + --dsc-chip-background: var(--ds-color-accent-base-active); + --dsc-chip-border-color: transparent; + --dsc-chip-color: var(--ds-color-accent-contrast-default); + } + } + } } diff --git a/packages/react/src/components/Chip/Chip.mdx b/packages/react/src/components/Chip/Chip.mdx index 2cdd662e49..b355320f86 100644 --- a/packages/react/src/components/Chip/Chip.mdx +++ b/packages/react/src/components/Chip/Chip.mdx @@ -1,9 +1,6 @@ import { Meta, Canvas, Controls, Primary } from '@storybook/blocks'; import * as ChipStories from './Chip.stories'; -import * as ToggleChipStories from './Toggle/Toggle.stories'; -import * as RemovableChipStories from './Removable/Removable.stories'; -import * as GroupChipStories from './Group/Group.stories'; @@ -23,33 +20,28 @@ import * as GroupChipStories from './Group/Group.stories'; ```tsx import { Chip } from '@digdir/designsystemet-react'; -You are using the Chip component!; -``` - -
- -## Eksempler - -
-### `Chip.Toggle` -`Chip.Toggle` kan brukes som et alternativ til `Button`. Den brukes når handlingen(e) er direkte relatert til hovedinnholdet. - -
+// som radio +You are using the Chip component!; +You are using the Chip component!; -### `Chip.Group` +// som checkbox +You are using the Chip component!; -Denne kodeblokken viser hvordan du grupperer `Chip` ved hjelp av `Chip.Group`. +// som removable +You are using the Chip component!; - - +// som button +You are using the Chip component!; +```
-### `Chip.Toggle` med `selected=true` -Vi bruker `Chip` med hake som alternativer til vanlige radiobuttons og checkboxer. Brukes til filtrering av innhold og data. Innholdet tilpasser seg kategoriene som blir valgt. - - +## Eksempler +
+### `Chip.Checkbox` +`Chip.Checkbox` kan brukes som et alternativ til `Checkbox`. +
### `Chip.Removable` @@ -58,12 +50,12 @@ Vi bruker `Chip` med kryss når vi vil at brukerne skal kunne fjerne valgte verd Denne komponenten inneholder et kryss som indikerer at filteret kan fjernes. Det er viktig å merke seg at `aria-label` må legges til dersom innholdet i Chip ikke forklarer at den kan fjernes. I dette eksempelet er det lagt til `aria-label="Slett {land}"`. - - + +
+### `Chip.Button` +`Chip.Button` kan brukes i gruppe, for å vise valgmuligheter - ikke handlinger. +
- - -
## Retningslinjer for `Chip` @@ -89,4 +81,4 @@ Chips bør ha så få ord som mulig, helst bare ett eller to.
## Tilgjengelighet -Hvis deler av en side blir oppdatert samtidig som brukerne gjør noe på siden, må vi passe på at brukere med skjermleser får beskjed om det. Vi kan bruke en `ARIA live region` til det, som kan vise antall treff eller andre endringer. \ No newline at end of file +Hvis deler av en side blir oppdatert samtidig som brukerne gjør noe på siden, må vi passe på at brukere med skjermleser får beskjed om det. Vi kan bruke en `ARIA live region` til det, som kan vise antall treff eller andre endringer. diff --git a/packages/react/src/components/Chip/Chip.stories.tsx b/packages/react/src/components/Chip/Chip.stories.tsx index b4753a1271..5742506c37 100644 --- a/packages/react/src/components/Chip/Chip.stories.tsx +++ b/packages/react/src/components/Chip/Chip.stories.tsx @@ -1,25 +1,46 @@ -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryFn, StoryObj } from '@storybook/react'; import { Chip } from '.'; -type Story = StoryObj; - export default { title: 'Komponenter/Chip', - component: Chip.Toggle, - argTypes: { - size: { - options: ['sm', 'md', 'lg'], - control: { type: 'radio' }, - }, - }, + component: Chip.Radio, + decorators: [ + (Story) => ( +
+ +
+ ), + ], } as Meta; -export const Preview: Story = { - args: { - children: 'Nynorsk', - size: 'md', - selected: false, - checkmark: false, - }, +export const Preview: StoryFn = (args) => ( + <> + + Nynorsk + + + Bokmål + + +); + +export const Checkbox: StoryFn = (args) => ( + Nynorsk +); + +export const Removable: StoryFn = (args) => ( + Norge +); + +Removable.args = { + 'aria-label': 'Slett Norge', }; + +export const Button: StoryFn = (args) => ( + <> + Søk etter nynorsk + Søk etter bokmål + Søk etter engelsk + +); diff --git a/packages/react/src/components/Chip/Chips.test.tsx b/packages/react/src/components/Chip/Chips.test.tsx new file mode 100644 index 0000000000..70ba6d2382 --- /dev/null +++ b/packages/react/src/components/Chip/Chips.test.tsx @@ -0,0 +1,117 @@ +import { render, screen } from '@testing-library/react'; +import { Chip } from '..'; + +describe('Chip.Button', () => { + it('should render button as native element', () => { + render(Norwegian); + const chip = screen.getByRole('button', { name: 'Norwegian' }); + + expect(chip).toBeInTheDocument(); + }); + + it('rest props should be supported', () => { + render(Norwegian); + + const chip = screen.getByRole('button', { name: 'Norwegian' }); + expect(chip).toHaveClass('testClass'); + + // Ensure that the last class is the one added by rest-props. + const lastClassNameIndex = chip.classList.length - 1; + expect(chip.classList[lastClassNameIndex]).toBe('testClass'); + }); +}); + +describe('Chip.Removable', () => { + it('should render button as native element', () => { + render(Norwegian); + + const chip = screen.getByRole('button', { name: 'Norwegian' }); + expect(chip).toBeInTheDocument(); + expect(chip).toHaveAttribute('data-removable'); + }); + + it('rest props should be supported', () => { + render(Norwegian); + + const chip = screen.getByRole('button', { name: 'Norwegian' }); + expect(chip).toHaveClass('testClass'); + + // Ensure that the last class is the one added by rest-props. + const lastClassNameIndex = chip.classList.length - 1; + expect(chip.classList[lastClassNameIndex]).toBe('testClass'); + }); +}); + +describe('Chip.Checkbox', () => { + it('should render label and input as native element', () => { + render(Norwegian); + + const chip = screen.getByLabelText('Norwegian'); + const input = screen.getByRole('checkbox'); + expect(chip).toBeInTheDocument(); + expect(input).toBeInTheDocument(); + }); + + it('rest props should be supported', () => { + render( + + Norwegian + , + ); + + const chip = screen.getByText('Norwegian', { selector: 'label' }); + const input = screen.getByRole('checkbox'); + expect(chip).toHaveClass('testClass'); + expect(input).toHaveAttribute('name', 'language'); + expect(input).toHaveAttribute('value', 'norwegian'); + expect(input).toBeChecked(); + expect(input).toBeDisabled(); + + // Ensure that the last class is the one added by rest-props. + const lastClassNameIndex = chip.classList.length - 1; + expect(chip.classList[lastClassNameIndex]).toBe('testClass'); + }); +}); + +describe('Chip.Radio', () => { + it('should render label and input as native element', () => { + render(Norwegian); + + const chip = screen.getByLabelText('Norwegian'); + const input = screen.getByRole('radio'); + expect(chip).toBeInTheDocument(); + expect(input).toBeInTheDocument(); + }); + + it('rest props should be supported', () => { + render( + + Norwegian + , + ); + + const chip = screen.getByText('Norwegian', { selector: 'label' }); + const input = screen.getByRole('radio'); + expect(chip).toHaveClass('testClass'); + expect(input).toHaveAttribute('name', 'language'); + expect(input).toHaveAttribute('value', 'norwegian'); + expect(input).toBeChecked(); + expect(input).toBeDisabled(); + + // Ensure that the last class is the one added by rest-props. + const lastClassNameIndex = chip.classList.length - 1; + expect(chip.classList[lastClassNameIndex]).toBe('testClass'); + }); +}); diff --git a/packages/react/src/components/Chip/Chips.tsx b/packages/react/src/components/Chip/Chips.tsx new file mode 100644 index 0000000000..8f633f852d --- /dev/null +++ b/packages/react/src/components/Chip/Chips.tsx @@ -0,0 +1,92 @@ +import { Slot, Slottable } from '@radix-ui/react-slot'; +import cl from 'clsx/lite'; +import { forwardRef } from 'react'; +import type { ButtonHTMLAttributes, InputHTMLAttributes } from 'react'; + +type ChipBaseProps = { + /** + * Size + * @default md + */ + size?: 'sm' | 'md' | 'lg'; + /** + * Change the default rendered element for the one passed as a child, merging their props and behavior. + * @default false + */ + asChild?: boolean; +}; + +export type ChipRemovableProps = ChipButtonProps; +export type ChipRadioProps = ChipCheckboxProps; +export type ChipButtonProps = ChipBaseProps & + ButtonHTMLAttributes; +export type ChipCheckboxProps = ChipBaseProps & + Omit, 'type' | 'size'>; + +/** + * Chip.Button used for interaction + * @example + * Click me + */ +export const ChipButton = forwardRef( + function ChipButton({ asChild, className, size, ...rest }, ref) { + const Component = asChild ? Slot : 'button'; + + return ( + + ); + }, +); + +/** + * Chip.Removable used for interaction + * @example + * Click to remove me + */ +export const ChipRemovable = forwardRef( + function ChipRemovable(props, ref) { + return ; + }, +); + +/** + * Chip.Checkbox used for multiselection + * @example + * Nynorsk + * Bokmål + */ +export const ChipCheckbox = forwardRef( + function ChipCheckbox({ asChild, children, className, size, ...rest }, ref) { + const inputType = (rest as { type?: string }).type ?? 'checkbox'; + const Component = asChild ? Slot : 'label'; + + return ( + + + {children} + + ); + }, +); + +/** + * Chip.Radio used for single selection + * @example + * Nynorsk + * Bokmål + */ +export const ChipRadio = forwardRef( + function ChipRadio(props, ref) { + return ; + }, +); diff --git a/packages/react/src/components/Chip/Group/Group.stories.tsx b/packages/react/src/components/Chip/Group/Group.stories.tsx deleted file mode 100644 index d5f70b14aa..0000000000 --- a/packages/react/src/components/Chip/Group/Group.stories.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type { Meta, StoryFn } from '@storybook/react'; - -import { Chip } from '..'; - -import { Group } from './Group'; - -const meta: Meta = { - title: 'Komponenter/Chip/Group', - component: Group, - argTypes: { - size: { - options: ['sm', 'md', 'lg'], - control: { type: 'radio' }, - }, - }, -}; - -export default meta; - -type Story = StoryFn; - -export const Preview: Story = (args) => ( - - Nynorsk - Bokmål - -); - -Preview.args = { - size: 'sm', -}; - -export const CheckGroup: Story = (args) => ( - - - Utsikt - - Heis - - Strandlinje - - Vaskemaskin - Dyrevennlig - -); - -export const RemoveGroup: Story = (args) => ( - - Norge - Danmark - Sverige - Finland - -); diff --git a/packages/react/src/components/Chip/Group/Group.test.tsx b/packages/react/src/components/Chip/Group/Group.test.tsx deleted file mode 100644 index f62f3342bf..0000000000 --- a/packages/react/src/components/Chip/Group/Group.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { render, screen } from '@testing-library/react'; - -import { Chip } from '..'; - -describe('Chip.Group', () => { - it('should be possible to render a group', () => { - render( - - First item - Second item - Third item - , - ); - - const [fistChip, secondChip, thirdChip] = screen.getAllByRole('listitem'); - expect(screen.getByRole('list')); - expect(fistChip).toHaveTextContent('First item'); - expect(secondChip).toHaveTextContent('Second item'); - expect(thirdChip).toHaveTextContent('Third item'); - }); -}); diff --git a/packages/react/src/components/Chip/Group/Group.tsx b/packages/react/src/components/Chip/Group/Group.tsx deleted file mode 100644 index 90e609d909..0000000000 --- a/packages/react/src/components/Chip/Group/Group.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import cl from 'clsx/lite'; -import type { HTMLAttributes } from 'react'; -import { Children, createContext, forwardRef, isValidElement } from 'react'; - -export type ChipGroupContext = { - size?: 'sm' | 'md' | 'lg'; -}; - -export const ChipGroupContext = createContext(null); - -export type ChipGroupProps = { - /** - * Changes Chip size and gap between chips. - * @default md - */ - size?: ChipGroupContext['size']; -} & HTMLAttributes; - -export const Group = forwardRef( - ({ size = 'md', children, className, ...rest }: ChipGroupProps, ref) => { - return ( -
    - - {Children.toArray(children).map((child, index) => - isValidElement(child) ? ( -
  • {child}
  • - ) : null, - )} -
    -
- ); - }, -); - -Group.displayName = 'ChipGroup'; diff --git a/packages/react/src/components/Chip/Removable/Removable.stories.tsx b/packages/react/src/components/Chip/Removable/Removable.stories.tsx deleted file mode 100644 index 302c8802fd..0000000000 --- a/packages/react/src/components/Chip/Removable/Removable.stories.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react'; - -import { RemovableChip } from './Removable'; - -export default { - title: 'Komponenter/Chip/Removable', - component: RemovableChip, - argTypes: { - size: { - options: ['sm', 'md', 'lg'], - control: { type: 'radio' }, - }, - }, -} as Meta; - -type Story = StoryObj; - -export const Preview: Story = { - args: { - children: 'Nynorsk', - size: 'md', - 'aria-label': 'Slett nynorsk', - disabled: false, - }, -}; diff --git a/packages/react/src/components/Chip/Removable/Removable.test.tsx b/packages/react/src/components/Chip/Removable/Removable.test.tsx deleted file mode 100644 index a9e157346c..0000000000 --- a/packages/react/src/components/Chip/Removable/Removable.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { act, useState } from 'react'; - -import { Chip, type RemovableChipProps } from '..'; - -const user = userEvent.setup(); - -const TestComponent = ({ - children, - ...rest -}: RemovableChipProps): JSX.Element => { - const [removed, setRemoved] = useState(false); - - return ( - <> - {!removed && ( - setRemoved(true)}> - {children} - - )} - - ); -}; - -describe('RemovableChip', () => { - it('should render button as native element', () => { - render(Norwegian); - - expect(screen.getByRole('button', { name: 'Norwegian' })); - }); - - it('should be removed based on user interaction', async () => { - render(Norwegian); - const chip = screen.getByRole('button', { name: 'Norwegian' }); - - expect(chip); - await act(async () => await user.click(chip)); - expect( - screen.queryByRole('button', { name: 'Norwegian' }), - ).not.toBeInTheDocument(); - }); - - it('rest props should be supported', () => { - render(Norwegian); - - const chip = screen.getByRole('button', { name: 'Norwegian' }); - expect(chip).toHaveClass('testClass'); - - // Ensure that the last class is the one added by rest-props. - const lastClassNameIndex = chip.classList.length - 1; - expect(chip.classList[lastClassNameIndex]).toBe('testClass'); - }); -}); diff --git a/packages/react/src/components/Chip/Removable/Removable.tsx b/packages/react/src/components/Chip/Removable/Removable.tsx deleted file mode 100644 index ea67f3f0ed..0000000000 --- a/packages/react/src/components/Chip/Removable/Removable.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { XMarkIcon } from '@navikt/aksel-icons'; -import cl from 'clsx/lite'; -import type { ButtonHTMLAttributes } from 'react'; -import { forwardRef, useContext } from 'react'; - -import { Paragraph } from '../../Typography'; -import { ChipGroupContext } from '../Group/Group'; - -export type RemovableChipProps = { - /** - * Changes Chip size and gap between chips. - * @default 'md' - */ - size?: ChipGroupContext['size']; -} & ButtonHTMLAttributes; - -export const RemovableChip = forwardRef( - ({ size = 'md', children, className, ...rest }, ref) => { - const group = useContext(ChipGroupContext); - - return ( - - ); - }, -); - -RemovableChip.displayName = 'ChipRemovable'; diff --git a/packages/react/src/components/Chip/Toggle/Toggle.stories.tsx b/packages/react/src/components/Chip/Toggle/Toggle.stories.tsx deleted file mode 100644 index 715aa3f15e..0000000000 --- a/packages/react/src/components/Chip/Toggle/Toggle.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react'; - -import { ToggleChip } from './Toggle'; - -const meta: Meta = { - title: 'Komponenter/Chip/Toggle', - component: ToggleChip, - argTypes: { - size: { - options: ['sm', 'md', 'lg'], - control: { type: 'radio' }, - }, - }, -}; - -export default meta; - -type Story = StoryObj; - -export const Preview: Story = { - args: { - children: 'Nynorsk', - size: 'md', - selected: false, - checkmark: false, - disabled: false, - }, -}; diff --git a/packages/react/src/components/Chip/Toggle/Toggle.test.tsx b/packages/react/src/components/Chip/Toggle/Toggle.test.tsx deleted file mode 100644 index 5edfe7d2ff..0000000000 --- a/packages/react/src/components/Chip/Toggle/Toggle.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { act, useState } from 'react'; - -import { Chip, type ToggleChipProps } from '..'; - -const user = userEvent.setup(); - -const TestComponent = ({ children, ...rest }: ToggleChipProps): JSX.Element => { - const [selected, setSelected] = useState(false); - - return ( - setSelected(!selected)} - > - {children} - - ); -}; - -describe('ToggleChip', () => { - it('should render button as native element', () => { - render(Norwegian); - - expect(screen.getByRole('button', { name: 'Norwegian' })); - }); - - it('should be possible to render as selected', () => { - render(Norwegian); - - expect(screen.getByRole('button', { name: 'Norwegian' })).toHaveAttribute( - 'aria-pressed', - 'true', - ); - }); - - it('should toggle aria-pressed based on user interaction', async () => { - render(Norwegian); - const chip = screen.getByRole('button', { name: 'Norwegian' }); - - expect(chip).toHaveAttribute('aria-pressed', 'false'); - await act(async () => await user.click(chip)); - expect(chip).toHaveAttribute('aria-pressed', 'true'); - }); - - it('rest props should be supported', () => { - render(Norwegian); - - const chip = screen.getByRole('button', { name: 'Norwegian' }); - expect(chip).toHaveClass('testClass'); - - // Ensure that the last class is the one added by rest-props. - const lastClassNameIndex = chip.classList.length - 1; - expect(chip.classList[lastClassNameIndex]).toBe('testClass'); - }); -}); diff --git a/packages/react/src/components/Chip/Toggle/Toggle.tsx b/packages/react/src/components/Chip/Toggle/Toggle.tsx deleted file mode 100644 index 7232fed9e9..0000000000 --- a/packages/react/src/components/Chip/Toggle/Toggle.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { CheckmarkIcon } from '@navikt/aksel-icons'; -import cl from 'clsx/lite'; -import type { ButtonHTMLAttributes } from 'react'; -import { forwardRef, useContext } from 'react'; - -import { Paragraph } from '../../Typography'; -import { ChipGroupContext } from '../Group/Group'; - -export type ToggleChipProps = { - /** - * Enables check mark icon - */ - checkmark?: boolean; - /** - * Changes Chip size and gap between chips. - * @default 'md' - */ - size?: ChipGroupContext['size']; - /** - * Toggles `aria-pressed` and visual-changes - * */ - selected?: boolean; -} & ButtonHTMLAttributes; - -export const ToggleChip = forwardRef( - ( - { - children, - selected = false, - checkmark = true, - size = 'md', - className, - ...rest - }: ToggleChipProps, - ref, - ) => { - const shouldDisplayCheckmark = checkmark && selected; - const group = useContext(ChipGroupContext); - - return ( - - ); - }, -); - -ToggleChip.displayName = 'ChipToggle'; diff --git a/packages/react/src/components/Chip/index.ts b/packages/react/src/components/Chip/index.ts index 604a04858c..694b62f2ec 100644 --- a/packages/react/src/components/Chip/index.ts +++ b/packages/react/src/components/Chip/index.ts @@ -1,33 +1,21 @@ -import { Group as ChipGroup } from './Group/Group'; -import type { ChipGroupProps } from './Group/Group'; -import { RemovableChip as ChipRemovable } from './Removable/Removable'; -import type { RemovableChipProps } from './Removable/Removable'; -import { ToggleChip as ChipToggle } from './Toggle/Toggle'; -import type { ToggleChipProps } from './Toggle/Toggle'; +import { ChipButton, ChipCheckbox, ChipRadio, ChipRemovable } from './Chips'; -type ChipComponent = { - /** - * Grouping multiple `Chip` together. Avoid mixing different kind of chips. - * @example - * - * Tekst - * Tekst - * - */ - Group: typeof ChipGroup; - Removable: typeof ChipRemovable; - Toggle: typeof ChipToggle; -}; - -const Chip: ChipComponent = { - Group: ChipGroup, +const Chip = { + Button: ChipButton, + Checkbox: ChipCheckbox, + Radio: ChipRadio, Removable: ChipRemovable, - Toggle: ChipToggle, }; -Chip.Group.displayName = 'Chip.Group'; +Chip.Button.displayName = 'Chip.Button'; +Chip.Checkbox.displayName = 'Chip.Checkbox'; +Chip.Radio.displayName = 'Chip.Radio'; Chip.Removable.displayName = 'Chip.Removable'; -Chip.Toggle.displayName = 'Chip.Toggle'; -export type { RemovableChipProps, ToggleChipProps, ChipGroupProps }; -export { Chip, ChipGroup, ChipRemovable, ChipToggle }; +export type { + ChipButtonProps, + ChipCheckboxProps, + ChipRadioProps, + ChipRemovableProps, +} from './Chips'; +export { Chip, ChipButton, ChipCheckbox, ChipRadio, ChipRemovable }; diff --git a/packages/react/src/components/form/Combobox/Combobox.stories.tsx b/packages/react/src/components/form/Combobox/Combobox.stories.tsx index 20ca6389f3..171beb53dc 100644 --- a/packages/react/src/components/form/Combobox/Combobox.stories.tsx +++ b/packages/react/src/components/form/Combobox/Combobox.stories.tsx @@ -3,7 +3,7 @@ import { useRef, useState } from 'react'; import type { FormEvent } from 'react'; import { Button } from '../../Button'; -import { ChipRemovable } from '../../Chip'; +import { Chip } from '../../Chip'; import { Modal } from '../../Modal'; import { Heading, Paragraph } from '../../Typography'; import { Switch } from '../Switch'; @@ -316,14 +316,14 @@ export const WithChipsOutside: StoryFn = (args) => { }} > {value.map((item, index) => ( - { setValue(value.filter((v) => v !== item)); }} > {item} - + ))}
diff --git a/packages/react/stories/testing.stories.tsx b/packages/react/stories/testing.stories.tsx index 20ac36b5e5..016eb3083b 100644 --- a/packages/react/stories/testing.stories.tsx +++ b/packages/react/stories/testing.stories.tsx @@ -83,7 +83,7 @@ export const MediumRow: StoryFn<{ > Switch - Toggle + Toggle Removable Tag