diff --git a/packages/css/src/components/index.scss b/packages/css/src/components/index.scss index 0ca1ff3e8e..063a49713e 100644 --- a/packages/css/src/components/index.scss +++ b/packages/css/src/components/index.scss @@ -4,6 +4,7 @@ */ /* Append here */ +@import "./select/select"; @import "./time-input/time-input"; @import "./date-input/date-input"; @import "./document/document"; diff --git a/packages/css/src/components/select/README.md b/packages/css/src/components/select/README.md new file mode 100644 index 0000000000..5b09daf963 --- /dev/null +++ b/packages/css/src/components/select/README.md @@ -0,0 +1,9 @@ + + +# Select + +A form control that allows users to select one or more options from a list. + +## References + +- [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select) diff --git a/packages/css/src/components/select/select.scss b/packages/css/src/components/select/select.scss new file mode 100644 index 0000000000..72db3c7e95 --- /dev/null +++ b/packages/css/src/components/select/select.scss @@ -0,0 +1,60 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +@mixin reset { + appearance: none; + border: 0; + border-radius: 0; // Reset rounded borders for Safari on MacOS +} + +.ams-select { + background-color: var(--ams-select-background-color); + box-shadow: var(--ams-select-box-shadow); + color: var(--ams-select-color); + font-family: var(--ams-select-font-family); + font-size: var(--ams-select-font-size); + font-weight: var(--ams-select-font-weight); + line-height: var(--ams-select-line-height); + max-inline-size: 100%; + outline-offset: var(--ams-select-outline-offset); + padding-block: var(--ams-select-padding-block); + padding-inline: var(--ams-select-padding-inline); + touch-action: manipulation; + + &:not([multiple]) { + background-image: var(--ams-select-background-image); + background-position: var(--ams-select-background-position); + background-repeat: no-repeat; + background-size: 1em 1em; + } + + &:hover { + box-shadow: var(--ams-select-hover-box-shadow); + } + + @include reset; +} + +.ams-select[aria-invalid="true"] { + box-shadow: var(--ams-select-invalid-box-shadow); + + &:hover { + box-shadow: var(--ams-select-invalid-hover-box-shadow); + } +} + +.ams-select:disabled { + box-shadow: var(--ams-select-disabled-box-shadow); + color: var(--ams-select-disabled-color); + cursor: not-allowed; + + &:not([multiple]) { + background-image: var(--ams-select-disabled-background-image); + } +} + +.ams-select__option:disabled { + color: var(--ams-select-option-disabled-color); +} diff --git a/packages/react/src/Select/README.md b/packages/react/src/Select/README.md new file mode 100644 index 0000000000..f830e9d3d1 --- /dev/null +++ b/packages/react/src/Select/README.md @@ -0,0 +1,5 @@ + + +# React Select component + +[Select documentation](../../../css/src/components/select/README.md) diff --git a/packages/react/src/Select/Select.test.tsx b/packages/react/src/Select/Select.test.tsx new file mode 100644 index 0000000000..d3e0902b43 --- /dev/null +++ b/packages/react/src/Select/Select.test.tsx @@ -0,0 +1,90 @@ +import { render, screen } from '@testing-library/react' +import { createRef } from 'react' +import { Select } from './Select' +import '@testing-library/jest-dom' + +describe('Select', () => { + it('renders', () => { + render() + + const component = screen.getByRole('combobox') + + expect(component).toHaveClass('ams-select') + }) + + it('renders an additional class name', () => { + render() + + const component = screen.getByRole('combobox') + + expect(ref.current).toBe(component) + }) + + it('renders options', () => { + render( + , + ) + + const select = screen.getByRole('combobox') + + const option = screen.getByRole('option', { + name: 'Option B', + }) + + expect(select).toContain(option) + }) + + it('can be disabled', () => { + render() + + const component = screen.getByRole('combobox') + + expect(component).toHaveClass('ams-select--invalid') + }) + + it('is not required by default', () => { + render() + + const component = screen.getByRole('combobox') + + expect(component).not.toHaveAttribute('required') + }) +}) diff --git a/packages/react/src/Select/Select.tsx b/packages/react/src/Select/Select.tsx new file mode 100644 index 0000000000..5dd90c63f1 --- /dev/null +++ b/packages/react/src/Select/Select.tsx @@ -0,0 +1,32 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import clsx from 'clsx' +import { forwardRef } from 'react' +import type { ForwardedRef, PropsWithChildren, SelectHTMLAttributes } from 'react' +import { SelectOption } from './SelectOption' +import { SelectOptionGroup } from './SelectOptionGroup' + +export type SelectProps = { + /** There is no native invalid attribute for select, but you can use this to get the same result as other form components */ + invalid?: boolean +} & PropsWithChildren> + +const SelectRoot = forwardRef( + ({ children, className, invalid, ...restProps }: SelectProps, ref: ForwardedRef) => ( + + ), +) + +SelectRoot.displayName = 'Select' + +export const Select = Object.assign(SelectRoot, { Option: SelectOption, Group: SelectOptionGroup }) diff --git a/packages/react/src/Select/SelectOption.test.tsx b/packages/react/src/Select/SelectOption.test.tsx new file mode 100644 index 0000000000..7a9db8a5c2 --- /dev/null +++ b/packages/react/src/Select/SelectOption.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from '@testing-library/react' +import { createRef } from 'react' +import { Select } from './Select' +import '@testing-library/jest-dom' + +describe('Select option', () => { + it('renders', () => { + render() + + const component = screen.getByRole('option') + + expect(component).toBeInTheDocument() + expect(component).toBeVisible() + }) + + it('renders an option role element with a text label', () => { + render(Option) + + const option = screen.getByRole('option', { + name: 'Option', + }) + + expect(option).toBeInTheDocument() + }) + + it('renders a design system BEM class name', () => { + render() + + const component = screen.getByRole('option') + + expect(component).toHaveClass('ams-select__option') + }) + + it('renders an additional class name', () => { + render() + + const component = screen.getByRole('option') + + expect(component).toHaveClass('ams-select__option extra') + }) + + it('supports ForwardRef in React', () => { + const ref = createRef() + + render() + + const component = screen.getByRole('option') + + expect(ref.current).toBe(component) + }) + + it('can be disabled', () => { + render() + + const component = screen.getByRole('option') + + expect(component).toBeDisabled() + }) +}) diff --git a/packages/react/src/Select/SelectOption.tsx b/packages/react/src/Select/SelectOption.tsx new file mode 100644 index 0000000000..70cc217dcb --- /dev/null +++ b/packages/react/src/Select/SelectOption.tsx @@ -0,0 +1,23 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import clsx from 'clsx' +import { forwardRef } from 'react' +import type { ForwardedRef, OptionHTMLAttributes, PropsWithChildren } from 'react' + +export type SelectOptionProps = OptionHTMLAttributes + +export const SelectOption = forwardRef( + ( + { children, className, ...restProps }: PropsWithChildren, + ref: ForwardedRef, + ) => ( + + ), +) + +SelectOption.displayName = 'Select.Option' diff --git a/packages/react/src/Select/SelectOptionGroup.test.tsx b/packages/react/src/Select/SelectOptionGroup.test.tsx new file mode 100644 index 0000000000..7f6d3d287c --- /dev/null +++ b/packages/react/src/Select/SelectOptionGroup.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from '@testing-library/react' +import { createRef } from 'react' +import { Select } from './Select' +import '@testing-library/jest-dom' + +describe('Select option group', () => { + it('renders', () => { + render() + + const component = screen.getByRole('group') + + expect(component).toBeInTheDocument() + expect(component).toBeVisible() + }) + + it('renders an group role element with a text label', () => { + render() + + const option = screen.getByRole('group', { + name: 'Options', + }) + + expect(option).toBeInTheDocument() + }) + + it('renders a design system BEM class name', () => { + render() + + const component = screen.getByRole('group') + + expect(component).toHaveClass('ams-select__group') + }) + + it('renders an additional class name', () => { + render() + + const component = screen.getByRole('group') + + expect(component).toHaveClass('ams-select__group extra') + }) + + it('supports ForwardRef in React', () => { + const ref = createRef() + + render() + + const component = screen.getByRole('group') + + expect(ref.current).toBe(component) + }) + + it('can be disabled', () => { + render() + + const component = screen.getByRole('group') + + expect(component).toBeDisabled() + }) +}) diff --git a/packages/react/src/Select/SelectOptionGroup.tsx b/packages/react/src/Select/SelectOptionGroup.tsx new file mode 100644 index 0000000000..7d0afd105f --- /dev/null +++ b/packages/react/src/Select/SelectOptionGroup.tsx @@ -0,0 +1,23 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import clsx from 'clsx' +import { forwardRef } from 'react' +import type { ForwardedRef, OptgroupHTMLAttributes, PropsWithChildren } from 'react' + +export type SelectOptionGroupProps = OptgroupHTMLAttributes + +export const SelectOptionGroup = forwardRef( + ( + { children, className, ...restProps }: PropsWithChildren, + ref: ForwardedRef, + ) => ( + + {children} + + ), +) + +SelectOptionGroup.displayName = 'Select.Group' diff --git a/packages/react/src/Select/index.ts b/packages/react/src/Select/index.ts new file mode 100644 index 0000000000..7fb657c440 --- /dev/null +++ b/packages/react/src/Select/index.ts @@ -0,0 +1,3 @@ +export { Select } from './Select' +export type { SelectProps } from './Select' +export type { SelectOptionProps } from './SelectOption' diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index fbdc4c7857..70b5d5f6da 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -4,6 +4,7 @@ */ /* Append here */ +export * from './Select' export * from './TimeInput' export * from './DateInput' export * from './Avatar' diff --git a/proprietary/tokens/src/components/ams/select.tokens.json b/proprietary/tokens/src/components/ams/select.tokens.json new file mode 100644 index 0000000000..ad865ff720 --- /dev/null +++ b/proprietary/tokens/src/components/ams/select.tokens.json @@ -0,0 +1,41 @@ +{ + "ams": { + "select": { + "background-color": { "value": "{ams.color.primary-white}" }, + "background-image": { + "value": "url(\"data:image/svg+xml;utf8,\")" + }, + "background-position": { "value": "right {ams.space.inside.md} center" }, + "box-shadow": { "value": "inset 0 0 0 {ams.border.width.sm} {ams.color.primary-black}" }, + "color": { "value": "{ams.color.primary-black}" }, + "font-family": { "value": "{ams.text.font-family}" }, + "font-size": { "value": "{ams.text.level.5.font-size}" }, + "font-weight": { "value": "{ams.text.font-weight.normal}" }, + "line-height": { "value": "{ams.text.level.5.line-height}" }, + "outline-offset": { "value": "{ams.focus.outline-offset}" }, + "padding-block": { "value": "{ams.space.inside.xs}" }, + "padding-inline": { "value": "{ams.space.inside.md} calc(2 * {ams.space.inside.md} + 1em)" }, + "disabled": { + "background-image": { + "value": "url(\"data:image/svg+xml;utf8,\")" + }, + "box-shadow": { "value": "inset 0 0 0 {ams.border.width.sm} {ams.color.neutral-grey2}" }, + "color": { "value": "{ams.color.neutral-grey2}" } + }, + "hover": { + "box-shadow": { "value": "inset 0 0 0 {ams.border.width.md} {ams.color.primary-black}" } + }, + "invalid": { + "box-shadow": { "value": "inset 0 0 0 {ams.border.width.sm} {ams.color.primary-red}" }, + "hover": { + "box-shadow": { "value": "inset 0 0 0 {ams.border.width.md} {ams.color.primary-red}" } + } + }, + "option": { + "disabled": { + "color": { "value": "{ams.color.neutral-grey2}" } + } + } + } + } +} diff --git a/storybook/src/components/Select/Select.docs.mdx b/storybook/src/components/Select/Select.docs.mdx new file mode 100644 index 0000000000..1c9141e937 --- /dev/null +++ b/storybook/src/components/Select/Select.docs.mdx @@ -0,0 +1,34 @@ +import { Canvas, Controls, Markdown, Meta, Primary } from "@storybook/blocks"; +import * as SelectStories from "./Select.stories.tsx"; +import README from "../../../../packages/css/src/components/select/README.md?raw"; + + + +{README} + +# Default + + + + + +## Multiple + +Avoid adding functionality to allow selecting multiple options. Multi select is harder to use +on desktop as they require the user to hold down the `Ctrl` or `Cmd` key while clicking on the options. +It is recommended to use checkboxes instead. + +## Grouped + +Use the `Select.Group` component to group options. +The component requires a `label` attribute. + + + +## Invalid + + + +## Disabled + + diff --git a/storybook/src/components/Select/Select.stories.tsx b/storybook/src/components/Select/Select.stories.tsx new file mode 100644 index 0000000000..fddfb3d0cc --- /dev/null +++ b/storybook/src/components/Select/Select.stories.tsx @@ -0,0 +1,108 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import { Select } from '@amsterdam/design-system-react' +import { Meta, StoryObj } from '@storybook/react' + +const meta = { + title: 'Components/Forms/Select', + component: Select, + args: { + invalid: false, + disabled: false, + children: [ + + Centrum + , + + Noord + , + + West + , + + Westpoort + , + + Nieuw-West + , + + Zuid + , + + Zuidoost + , + ], + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = {} + +export const Grouped: Story = { + args: { + children: [ + + BG-terrein e.o. + Burgwallen oost + Kop Zeedijk + Nes e.o. + Oude Kerk e.o. + , + + Begijnhofbuurt + Hemelrijk + Kalverdriehoek + Nieuwe Kerk e.o. + Nieuwendijk Noord + Spuistraat Noord + Spuistraat Zuid + Stationsplein e.o. + , + + Felix Meritisbuurt + Langestraat e.o. + Leidsegracht Noord + Leliegracht e.o. + , + + Haarlemmerbuurt Oost + Haarlemmerbuurt West + Planciusbuurt Noord + Planciusbuurt Zuid + Westelijke eilanden + Westerdokseiland + , + + Anjeliersbuurt Noord + Anjeliersbuurt Zuid + Bloemgrachtbuurt + Driehoekbuurt + Elandsgrachtbuurt + Groenmarktkadebuurt + Marnixbuurt Midden + Marnixbuurt Noord + Marnixbuurt Zuid + Passeerdersgrachtbuurt + Zaagpoortbuurt + , + ], + }, +} + +export const Invalid: Story = { + args: { + invalid: true, + }, +} + +export const Disabled: Story = { + args: { + disabled: true, + }, +}