diff --git a/.gitignore b/.gitignore index 938cd50938..a53d2e2979 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ dist +build .cache node_modules pnpm-debug.log diff --git a/packages/eds-core-react/src/components/next/Checkbox/Checkbox.figma.tsx b/packages/eds-core-react/src/components/next/Checkbox/Checkbox.figma.tsx new file mode 100644 index 0000000000..672a463972 --- /dev/null +++ b/packages/eds-core-react/src/components/next/Checkbox/Checkbox.figma.tsx @@ -0,0 +1,21 @@ +import figma from '@figma/code-connect' +import { Checkbox } from './Checkbox' + +figma.connect( + Checkbox, + 'https://www.figma.com/design/dz0XQdc5j7AAtjXr1gTfVR/EDS-Core-Components?node-id=112-5455&m=dev', + { + props: { + label: figma.string('Label'), + disabled: figma.boolean('Disabled'), + indeterminate: figma.boolean('Indeterminate'), + }, + example: ({ label, disabled, indeterminate }) => ( + + ), + }, +) diff --git a/packages/eds-core-react/src/components/next/Checkbox/Checkbox.stories.tsx b/packages/eds-core-react/src/components/next/Checkbox/Checkbox.stories.tsx new file mode 100644 index 0000000000..5debeaf961 --- /dev/null +++ b/packages/eds-core-react/src/components/next/Checkbox/Checkbox.stories.tsx @@ -0,0 +1,359 @@ +import { useState } from 'react' +import type { ChangeEvent, ReactNode } from 'react' +import { action } from 'storybook/actions' +import { useForm } from 'react-hook-form' +import { StoryFn, Meta } from '@storybook/react-vite' +import { Button, Table } from '../../..' +import { data } from '../../../stories/data' +import { Stack } from './../../../../.storybook/components' +import { Checkbox } from './Checkbox' +import type { CheckboxProps } from './Checkbox.types' + +const meta: Meta = { + title: 'EDS 2.0 (beta)/Inputs/Selection Controls/Checkbox', + component: Checkbox, + args: { + label: 'Label', + disabled: false, + indeterminate: false, + }, + parameters: { + docs: { + description: { + component: `**⚠️ Beta Component** - This component is under active development and may have breaking changes. + +\`\`\`bash +npm install @equinor/eds-core-react@beta +\`\`\` + +\`\`\`tsx +import { Checkbox } from '@equinor/eds-core-react/next' + +// Basic checkbox + + +// Checkbox group + + Select options + + + + +// Without visible label (use aria-label) + +\`\`\` + +Checkboxes allow users to select one or more options from a set, or toggle a single option on/off. +`, + }, + source: { + excludeDecorators: true, + }, + }, + }, + argTypes: { + // Core + label: { + control: 'text', + description: 'Label text displayed next to the checkbox', + table: { + category: 'Core', + }, + }, + + // States + disabled: { + control: 'boolean', + description: 'Disables checkbox interaction', + table: { + category: 'States', + defaultValue: { summary: 'false' }, + }, + }, + indeterminate: { + control: 'boolean', + description: + 'Shows indeterminate state (partially checked). Useful for "select all" scenarios.', + table: { + category: 'States', + defaultValue: { summary: 'false' }, + }, + }, + checked: { + control: 'boolean', + description: 'Controlled checked state', + table: { + category: 'States', + }, + }, + defaultChecked: { + control: 'boolean', + description: 'Initial checked state for uncontrolled usage', + table: { + category: 'States', + }, + }, + + // Styling + className: { + control: 'text', + description: 'Additional CSS class names for the input element', + table: { + category: 'Styling', + }, + }, + + // HTML attributes + id: { + control: 'text', + description: 'HTML id attribute', + table: { + category: 'HTML Attributes', + }, + }, + name: { + control: 'text', + description: 'HTML name attribute for form submission', + table: { + category: 'HTML Attributes', + }, + }, + value: { + control: 'text', + description: 'HTML value attribute for form submission', + table: { + category: 'HTML Attributes', + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +} + +export default meta + +const Wrapper = ({ + children, + gap = 16, + ...rest +}: { + children: ReactNode + gap?: number +} & React.HTMLAttributes) => ( + + {children} + +) + +const AllVariants = () => ( + <> + + + + + + > +) + +export const Introduction: StoryFn = (args) => { + return +} + +export const Spacious: StoryFn = () => ( + + + +) + +export const Comfortable: StoryFn = () => ( + + + +) + +export const ColorSchemes: StoryFn = () => ( + + + + Light Mode + + + + + + + + + + + Dark Mode + + + + + + + + + +) +ColorSchemes.storyName = 'Light & Dark Mode' + +export const GroupedCheckbox: StoryFn = () => ( + + Select your options + + + + + + +) +GroupedCheckbox.storyName = 'Grouped' + +export const WithoutVisibleLabel: StoryFn = () => ( + + + Spacious + + + + Comfortable + + + +) + +WithoutVisibleLabel.parameters = { + docs: { + description: { + story: + 'When no visible label is needed, use `aria-label` to provide an accessible name for screen readers. This is common in tables or compact UIs.', + }, + }, +} + +export const Controlled: StoryFn = () => { + const [checked, setChecked] = useState(false) + return ( + ) => + setChecked(e.target.checked) + } + /> + ) +} + +export const TableCheckbox: StoryFn = () => ( + + + + Selected + Description + + + + {data.slice(0, 4).map((row) => ( + + + + + + + {row.description} + + ))} + + +) +TableCheckbox.storyName = 'In Table' + +type FormData = { + favourites: string[] + agree: string +} + +export const WithReactHookForm: StoryFn = () => { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm() + const [isSubmitted, setIsSubmitted] = useState(false) + const [formData, setFormData] = useState(null) + + const onSubmit = (data: FormData) => { + setFormData(data) + setIsSubmitted(true) + action('onSubmit')(data) + } + + if (isSubmitted) { + return ( + + Submitted: {JSON.stringify(formData)} + { + setIsSubmitted(false) + setFormData(null) + }} + > + Reset + + + ) + } + + return ( + + + Pick your favourites + + + + + + + + + + + Submit + + + ) +} diff --git a/packages/eds-core-react/src/components/next/Checkbox/Checkbox.test.tsx b/packages/eds-core-react/src/components/next/Checkbox/Checkbox.test.tsx new file mode 100644 index 0000000000..81ff6728c3 --- /dev/null +++ b/packages/eds-core-react/src/components/next/Checkbox/Checkbox.test.tsx @@ -0,0 +1,185 @@ +import { useState } from 'react' +import { render, fireEvent, screen } from '@testing-library/react' +import { axe } from 'jest-axe' +import userEvent from '@testing-library/user-event' +import '@testing-library/jest-dom' +import { Checkbox } from './Checkbox' + +type ControlledProps = { + onChange: () => void +} + +const ControlledCheckbox = ({ onChange }: ControlledProps) => { + const [checked, setChecked] = useState(true) + return ( + { + setChecked(e.target.checked) + onChange() + }} + /> + ) +} + +describe('Checkbox (next)', () => { + it('matches snapshot', () => { + const { asFragment } = render() + expect(asFragment()).toMatchSnapshot() + }) + + describe('Rendering', () => { + it('renders with provided label', () => { + const label = 'Checkbox label' + render() + const inputNode = screen.getByLabelText(label) + expect(inputNode).toBeDefined() + }) + + it('renders without visible label using aria-label', () => { + render() + const checkbox = screen.getByLabelText('No visible label') + expect(checkbox).toBeInTheDocument() + }) + + it('extends css with custom className and style', () => { + render( + , + ) + const checkbox = screen.getByLabelText('checkbox-test') + expect(checkbox).toBeInTheDocument() + expect(checkbox).toHaveClass('custom-checkbox') + }) + + it('applies data-* attributes to input element', () => { + render( + , + ) + + const input = screen.getByRole('checkbox') + expect(input).toHaveAttribute('data-testid', 'test-checkbox') + expect(input).toHaveAttribute('data-analytics', 'track-checkbox') + }) + }) + + describe('Accessibility', () => { + it('passes axe accessibility test', async () => { + const { container } = render() + expect(await axe(container)).toHaveNoViolations() + }) + + it('passes axe accessibility test with external label', async () => { + const { container } = render( + <> + Label text + + >, + ) + expect(await axe(container)).toHaveNoViolations() + }) + + it('connects helperMessage to input via aria-describedby', () => { + render( + , + ) + + const checkbox = screen.getByRole('checkbox') + const helperMessage = screen.getByText('Helper text for a11y') + + expect(checkbox).toHaveAttribute('aria-describedby', helperMessage.id) + }) + }) + + describe('Interaction', () => { + it('can be selected', () => { + const labelText = 'Checkbox label' + render() + const checkbox = screen.getByLabelText(labelText) + expect(checkbox).not.toBeChecked() + fireEvent.click(checkbox) + expect(checkbox).toBeChecked() + }) + + it('works as a controlled component', () => { + const handleChange = jest.fn() + render() + const checkbox = screen.getByLabelText('checkbox-label') + + expect(checkbox).toBeChecked() + expect(handleChange).toHaveBeenCalledTimes(0) + + fireEvent.click(checkbox) + expect(checkbox).not.toBeChecked() + expect(handleChange).toHaveBeenCalledTimes(1) + }) + + it('cannot be clicked when disabled', async () => { + render( + + + , + ) + const one = screen.getByLabelText('Checkbox one') + expect(one).not.toBeChecked() + await userEvent.click(one) + expect(one).not.toBeChecked() + }) + }) + + describe('States', () => { + it('supports indeterminate state', () => { + render() + const checkbox = screen.getByRole('checkbox', { + name: 'Indeterminate checkbox', + }) + expect(checkbox).toHaveAttribute('data-indeterminate', 'true') + }) + + it('applies disabled attribute to wrapper', () => { + render() + const checkbox = screen.getByLabelText('Disabled checkbox') + // eslint-disable-next-line testing-library/no-node-access + const label = checkbox.closest('.eds-checkbox') + expect(label).toHaveAttribute('data-disabled', 'true') + }) + }) + + describe('Field props', () => { + it('supports indicator prop for required/optional text', () => { + render() + + expect(screen.getByText('(Required)')).toBeInTheDocument() + }) + + it('supports helperMessage prop', () => { + render( + , + ) + + expect(screen.getByText('This is a helper message')).toBeInTheDocument() + }) + }) + + describe('Color appearance', () => { + it('defaults to accent color appearance', () => { + render() + + const checkbox = screen.getByRole('checkbox') + // eslint-disable-next-line testing-library/no-node-access + const wrapper = checkbox.closest('.eds-checkbox') + expect(wrapper).toHaveAttribute('data-color-appearance', 'accent') + }) + }) +}) diff --git a/packages/eds-core-react/src/components/next/Checkbox/Checkbox.tsx b/packages/eds-core-react/src/components/next/Checkbox/Checkbox.tsx new file mode 100644 index 0000000000..59fd36738b --- /dev/null +++ b/packages/eds-core-react/src/components/next/Checkbox/Checkbox.tsx @@ -0,0 +1,110 @@ +/* eslint camelcase: "off" */ +import { forwardRef, useEffect, useId, useRef } from 'react' +import { + checkbox, + checkbox_outline, + checkbox_indeterminate, +} from '@equinor/eds-icons' +import { Field } from '../Field' +import { Icon } from '../Icon' +import type { CheckboxProps } from './Checkbox.types' +import './checkbox.css' + +const classNames = (...classes: (string | boolean | undefined)[]) => + classes.filter(Boolean).join(' ') + +export const Checkbox = forwardRef( + function Checkbox( + { + label, + disabled = false, + indeterminate = false, + indicator, + helperMessage, + id: providedId, + ...rest + }, + ref, + ) { + const internalRef = useRef(null) + const inputRef = (ref as React.RefObject) || internalRef + const generatedId = useId() + const inputId = providedId ?? generatedId + const helperMessageId = `${inputId}-helper` + + useEffect(() => { + if (inputRef.current) { + inputRef.current.indeterminate = indeterminate + } + }, [indeterminate, inputRef]) + + const checkboxInput = ( + <> + + + + + + + > + ) + + // Use Field for layout when label is provided + if (label) { + return ( + + {checkboxInput} + + {label} + + {helperMessage && ( + + {helperMessage} + + )} + + ) + } + + return ( + + {checkboxInput} + + ) + }, +) + +Checkbox.displayName = 'Checkbox' diff --git a/packages/eds-core-react/src/components/next/Checkbox/Checkbox.types.ts b/packages/eds-core-react/src/components/next/Checkbox/Checkbox.types.ts new file mode 100644 index 0000000000..c5c8a48c15 --- /dev/null +++ b/packages/eds-core-react/src/components/next/Checkbox/Checkbox.types.ts @@ -0,0 +1,16 @@ +import type { InputHTMLAttributes, ReactNode } from 'react' + +export type CheckboxProps = { + /** Label for the checkbox */ + label?: ReactNode + /** If true, the checkbox will be disabled */ + disabled?: boolean + /** If true, the checkbox appears indeterminate. Note: You must also set + * the native element's indeterminate property via ref if needed. + */ + indeterminate?: boolean + /** Indicator text shown after the label, e.g. "(Required)" or "(Optional)" */ + indicator?: string + /** Helper message shown below the checkbox, useful for additional context */ + helperMessage?: ReactNode +} & Omit, 'type'> diff --git a/packages/eds-core-react/src/components/next/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/packages/eds-core-react/src/components/next/Checkbox/__snapshots__/Checkbox.test.tsx.snap new file mode 100644 index 0000000000..23b958d496 --- /dev/null +++ b/packages/eds-core-react/src/components/next/Checkbox/__snapshots__/Checkbox.test.tsx.snap @@ -0,0 +1,81 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`Checkbox (next) matches snapshot 1`] = ` + + + + + + + + + + + + + + + + checkbox + + + +`; diff --git a/packages/eds-core-react/src/components/next/Checkbox/checkbox.css b/packages/eds-core-react/src/components/next/Checkbox/checkbox.css new file mode 100644 index 0000000000..28bbabb3f4 --- /dev/null +++ b/packages/eds-core-react/src/components/next/Checkbox/checkbox.css @@ -0,0 +1,165 @@ +@layer eds-components { + .eds-checkbox { + --_checkbox-icon-color: var(--eds-color-bg-fill-emphasis-default); + --_checkbox-hover-color: var(--eds-color-bg-fill-muted-default); + cursor: pointer; + position: relative; + box-sizing: border-box; + } + + .eds-checkbox--standalone { + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + padding: 0.375rem; /* 6px for spacious: 24px icon + 12px = 36px */ + } + + .eds-field.eds-checkbox { + /* Gap compensates for icon-wrapper negative margin (-4.8px) */ + gap: 0.925rem; + padding-inline: var(--eds-selectable-space-horizontal); + padding-block: var(--eds-selectable-space-vertical); + width: auto; + border-radius: var(--eds-spacing-border-radius-rounded); + transition: background-color 150ms; + } + + .eds-checkbox[data-disabled='true'] { + cursor: not-allowed; + } + + .eds-checkbox__icon-wrapper { + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + flex-shrink: 0; + overflow: visible; + line-height: 0; + } + + /* Negative margin matches Figma icon container padding */ + .eds-field.eds-checkbox .eds-checkbox__icon-wrapper { + margin-block: -6px; + margin-inline: -4.8px; + } + + .eds-checkbox--standalone::before { + content: ''; + position: absolute; + inset: 0; + border-radius: var(--eds-spacing-border-radius-pill); + background-color: transparent; + transition: background-color 150ms; + } + + @media (hover: hover) and (pointer: fine) { + .eds-checkbox--standalone:hover::before { + background-color: var(--_checkbox-hover-color); + } + + .eds-checkbox--standalone[data-disabled='true']:hover::before { + background-color: transparent; + } + + .eds-field.eds-checkbox:hover { + background-color: var(--_checkbox-hover-color); + } + + .eds-field.eds-checkbox[data-disabled='true']:hover { + background-color: transparent; + } + } + + .eds-checkbox__input { + appearance: none; + position: absolute; + inset: 0; + margin: 0; + cursor: pointer; + z-index: 1; + } + + .eds-checkbox__input:disabled { + cursor: not-allowed; + } + + .eds-checkbox__input:focus { + outline: none; + } + + .eds-checkbox:has(.eds-checkbox__input:focus-visible)::after { + content: ''; + position: absolute; + inset: -2px; + border: 1px solid var(--eds-color-border-focus); + border-radius: 6px; + pointer-events: none; + } + + .eds-checkbox--standalone:has(.eds-checkbox__input:focus-visible)::after { + border-radius: var(--eds-spacing-border-radius-pill); + } + + .eds-checkbox__icon { + position: relative; + pointer-events: none; + fill: var(--_checkbox-icon-color); + flex-shrink: 0; + } + + .eds-checkbox[data-disabled='true'] .eds-checkbox__icon { + fill: var(--eds-color-border-neutral-medium); + } + + /* Icon visibility */ + .eds-checkbox__icon--checked, + .eds-checkbox__icon--indeterminate { + display: none; + } + + .eds-checkbox__icon--unchecked { + display: block; + } + + .eds-checkbox:has(.eds-checkbox__input:checked) .eds-checkbox__icon--checked { + display: block; + } + + .eds-checkbox:has(.eds-checkbox__input:checked) + .eds-checkbox__icon--unchecked { + display: none; + } + + .eds-checkbox:has(.eds-checkbox__input[data-indeterminate='true']) + .eds-checkbox__icon--indeterminate { + display: block; + } + + .eds-checkbox:has(.eds-checkbox__input[data-indeterminate='true']) + .eds-checkbox__icon--checked, + .eds-checkbox:has(.eds-checkbox__input[data-indeterminate='true']) + .eds-checkbox__icon--unchecked { + display: none; + } + + .eds-field.eds-checkbox .eds-field__label { + cursor: pointer; + } + + .eds-field.eds-checkbox[data-disabled='true'] .eds-field__label { + color: var(--eds-color-border-neutral-medium); + cursor: not-allowed; + } + + .eds-field.eds-checkbox[data-color-appearance='accent'] { + --_eds-field-helper-color: var(--eds-color-text-neutral-subtle); + } + + /* Comfortable density for standalone checkbox */ + /* Icon is 20px in comfortable (--eds-sizing-icon-lg: 1.25rem) */ + [data-density='comfortable'] .eds-checkbox--standalone { + padding: 0.25rem; /* 4px: 20px icon + 8px = 28px */ + } +} diff --git a/packages/eds-core-react/src/components/next/Checkbox/index.ts b/packages/eds-core-react/src/components/next/Checkbox/index.ts new file mode 100644 index 0000000000..e405168f6e --- /dev/null +++ b/packages/eds-core-react/src/components/next/Checkbox/index.ts @@ -0,0 +1,2 @@ +export { Checkbox } from './Checkbox' +export type { CheckboxProps } from './Checkbox.types' diff --git a/packages/eds-core-react/src/components/next/Field/field.css b/packages/eds-core-react/src/components/next/Field/field.css index 88dae31150..2b17d4fed6 100644 --- a/packages/eds-core-react/src/components/next/Field/field.css +++ b/packages/eds-core-react/src/components/next/Field/field.css @@ -9,7 +9,7 @@ /* Private color variables */ --_eds-field-label-color: var(--eds-color-text-strong); --_eds-field-description-color: var(--eds-color-text-subtle); - --_eds-field-indicator-color: var(--eds-color-text-subtle); + --_eds-field-indicator-color: var(--eds-color-text-neutral-subtle); --_eds-field-helper-color: var(--eds-color-text-subtle); } diff --git a/packages/eds-core-react/src/components/next/index.css b/packages/eds-core-react/src/components/next/index.css index 6b253397ad..1d3aff9cd8 100644 --- a/packages/eds-core-react/src/components/next/index.css +++ b/packages/eds-core-react/src/components/next/index.css @@ -1,7 +1,7 @@ /* EDS 2.0 Next Components - CSS */ @import '@equinor/eds-tokens/css/variables'; +@import './Field/field.css'; +@import './Checkbox/checkbox.css'; /* Define layer order - eds-components has lowest priority for easy user overrides */ @layer eds-components; - -@import './Field/field.css'; diff --git a/packages/eds-core-react/src/components/next/index.ts b/packages/eds-core-react/src/components/next/index.ts index 92120ac1e4..72dafc409e 100644 --- a/packages/eds-core-react/src/components/next/index.ts +++ b/packages/eds-core-react/src/components/next/index.ts @@ -1,8 +1,9 @@ export { Icon } from './Icon' export { Field, useFieldIds } from './Field' +export { Checkbox } from './Checkbox' export type { IconProps, IconSize, IconData, IconName } from './Icon' - +export type { CheckboxProps } from './Checkbox' export type { FieldProps, FieldLabelProps,
Spacious
Comfortable
Submitted: {JSON.stringify(formData)}