From 3685a8096b225252575b4064f9cb2afd4af1d62d Mon Sep 17 00:00:00 2001 From: Frida Date: Tue, 4 Nov 2025 15:03:52 +0100 Subject: [PATCH 01/27] WIP checkbox pure css using tokens and variables --- .../Checkbox/Checkbox.new.stories.tsx | 309 ++++++++++++++++++ .../components/Checkbox/Checkbox.new.test.tsx | 125 +++++++ .../src/components/Checkbox/Checkbox.new.tsx | 143 ++++++++ .../components/Checkbox/Checkbox.new.types.ts | 12 + .../__snapshots__/Checkbox.new.test.tsx.snap | 43 +++ .../src/components/Checkbox/checkbox.new.css | 175 ++++++++++ 6 files changed, 807 insertions(+) create mode 100644 packages/eds-core-react/src/components/Checkbox/Checkbox.new.stories.tsx create mode 100644 packages/eds-core-react/src/components/Checkbox/Checkbox.new.test.tsx create mode 100644 packages/eds-core-react/src/components/Checkbox/Checkbox.new.tsx create mode 100644 packages/eds-core-react/src/components/Checkbox/Checkbox.new.types.ts create mode 100644 packages/eds-core-react/src/components/Checkbox/__snapshots__/Checkbox.new.test.tsx.snap create mode 100644 packages/eds-core-react/src/components/Checkbox/checkbox.new.css diff --git a/packages/eds-core-react/src/components/Checkbox/Checkbox.new.stories.tsx b/packages/eds-core-react/src/components/Checkbox/Checkbox.new.stories.tsx new file mode 100644 index 0000000000..803445e46a --- /dev/null +++ b/packages/eds-core-react/src/components/Checkbox/Checkbox.new.stories.tsx @@ -0,0 +1,309 @@ +import { useState, useEffect, useRef, ChangeEvent } from 'react' +import { Typography, Button, EdsProvider, Table, Density } from '../..' +import { Checkbox } from './Checkbox.new' +import type { CheckboxProps } from './Checkbox.new.types' +import { action } from 'storybook/actions' +import { useForm } from 'react-hook-form' +import { StoryFn, Meta } from '@storybook/react-vite' +import { data } from '../../stories/data' +import { Stack } from './../../../.storybook/components' + +const meta: Meta = { + title: 'Inputs/Selection Controls/Checkbox.new', + component: Checkbox, + parameters: { + docs: { + description: { + component: + 'New Checkbox component using vanilla CSS and EDS foundation tokens. Supports compact mode, dark mode, and all accessibility features.', + }, + source: { + excludeDecorators: true, + }, + }, + }, + decorators: [ + (Story) => { + return ( + + + + ) + }, + ], +} + +export default meta + +const UnstyledList = ({ children, ...props }) => ( + +) + +const CheckboxWrapper = ({ children, ...props }) => ( +
+ {children} +
+) + +export const Introduction: StoryFn = (args) => { + return +} + +export const SingleCheckbox: StoryFn = () => { + // Use this to set the input to indeterminate = true as this must be done via JavaScript + // (cannot use an HTML attribute for this) + const indeterminateRef = useRef(null) + // State for controlled example + const [checked, updateChecked] = useState(false) + return ( + +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + ) => { + updateChecked(e.target.checked) + }} + checked={checked} + /> +
  • +
    + ) +} +SingleCheckbox.storyName = 'Single checkbox' + +export const GroupedCheckbox: StoryFn = () => { + return ( +
    + + We are in this together! + + 🙌 + + + +
  • + +
  • +
  • + +
  • +
  • + +
  • +
    +
    + ) +} +GroupedCheckbox.storyName = 'Grouped checkboxes' + +type FormData = { + favourites: string[] + agree: string +} + +export const WithFormsControl: StoryFn = () => { + // Example with external forms library, react-hook-form + // eslint-disable-next-line @typescript-eslint/unbound-method + const { + register, + handleSubmit, + formState: { errors }, + } = useForm() + const [isSubmitted, updateIsSubmitted] = useState(false) + const [formData, updateFormData] = useState(null) + + const onSubmit = (data: FormData) => { + updateFormData(data) + updateIsSubmitted(true) + action('onSubmit')(data) + } + + return ( +
    + + Real life example with an external{' '} + + form library + + +
    + {isSubmitted ? ( + <> + Submitted data: +

    {JSON.stringify(formData)}

    + + + ) : ( +
    +
    + What's your favourites? + + + + + + + + + + + + +
    + + + Hey you! This field is required + +
    + +
    +
    + )} +
    +
    + ) +} +WithFormsControl.storyName = 'Example with React Hook Form' + +export const Compact: StoryFn = () => { + const [density, setDensity] = useState('comfortable') + + useEffect(() => { + // Simulate user change + setDensity('comfortable') + }, [density]) + + return ( + + + + + + ) +} + +export const AlternativeToLabel: StoryFn = () => ( + +) +AlternativeToLabel.storyName = 'Alternative to label' + +export const TableCheckbox: StoryFn = () => ( + + + + Selected + + + + {data.map((data) => ( + + + + + + ))} + +
    +) +TableCheckbox.storyName = 'Table checkbox' + +export const DarkMode: StoryFn = () => { + return ( +
    + +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
    +
    + ) +} +DarkMode.storyName = 'Dark mode' diff --git a/packages/eds-core-react/src/components/Checkbox/Checkbox.new.test.tsx b/packages/eds-core-react/src/components/Checkbox/Checkbox.new.test.tsx new file mode 100644 index 0000000000..5bc0be989a --- /dev/null +++ b/packages/eds-core-react/src/components/Checkbox/Checkbox.new.test.tsx @@ -0,0 +1,125 @@ +/* eslint-disable no-undef */ +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.new' + +type ControlledProps = { + onChange: () => void +} + +const ControlledCheckbox = ({ onChange }: ControlledProps) => { + const [checked, setChecked] = useState(true) + return ( + { + setChecked(e.target.checked) + onChange() + }} + /> + ) +} + +describe('Checkbox.new', () => { + it('Matches snapshot', () => { + const { asFragment } = render() + expect(asFragment()).toMatchSnapshot() + }) + + it('should pass a11y test', async () => { + const { container } = render() + expect(await axe(container)).toHaveNoViolations() + }) + + it('should pass a11y test with external label', async () => { + const { container } = render( + <> + + + , + ) + expect(await axe(container)).toHaveNoViolations() + }) + + it('Can extend the css for the component', () => { + render( + , + ) + const checkbox = screen.getByLabelText('checkbox-test') + expect(checkbox).toBeInTheDocument() + // eslint-disable-next-line testing-library/no-node-access + const label = checkbox.closest('.checkbox') + expect(label).toHaveClass('custom-checkbox') + }) + + it('Has provided label', () => { + const label = 'Checkbox label' + render() + const inputNode = screen.getByLabelText(label) + expect(inputNode).toBeDefined() + }) + + 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('Can be 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('Can be disabled', async () => { + render( +
    + +
    , + ) + const one = screen.getByLabelText('Checkbox one') + expect(one).not.toBeChecked() + await userEvent.click(one) + expect(one).not.toBeChecked() + }) + + it('Can be indeterminate', () => { + render() + const checkbox = screen.getByLabelText( + 'Indeterminate checkbox', + ) as HTMLInputElement + expect(checkbox.indeterminate).toBe(true) + }) + + it('Renders without label', () => { + render() + const checkbox = screen.getByLabelText('No visible label') + expect(checkbox).toBeInTheDocument() + }) + + it('Applies disabled classes correctly', () => { + render() + const checkbox = screen.getByLabelText('Disabled checkbox') + // eslint-disable-next-line testing-library/no-node-access + const label = checkbox.closest('.checkbox') + expect(label).toHaveClass('checkbox--disabled') + }) +}) diff --git a/packages/eds-core-react/src/components/Checkbox/Checkbox.new.tsx b/packages/eds-core-react/src/components/Checkbox/Checkbox.new.tsx new file mode 100644 index 0000000000..8f83a2b0f2 --- /dev/null +++ b/packages/eds-core-react/src/components/Checkbox/Checkbox.new.tsx @@ -0,0 +1,143 @@ +/* eslint camelcase: "off" */ +import { forwardRef, useEffect, useRef } from 'react' +import { + checkbox, + checkbox_outline, + checkbox_indeterminate, +} from '@equinor/eds-icons' +import { TypographyNext } from '../Typography' +import type { CheckboxProps } from './Checkbox.new.types' +import './checkbox.new.css' + +const classNames = (...classes: (string | boolean | undefined)[]) => + classes.filter(Boolean).join(' ') + +export const Checkbox = forwardRef( + function Checkbox( + { + label, + disabled = false, + indeterminate = false, + className, + style, + ...rest + }, + ref, + ) { + const internalRef = useRef(null) + const inputRef = (ref as React.RefObject) || internalRef + + // Set indeterminate state via JavaScript (can't be done via HTML attribute) + useEffect(() => { + if (inputRef.current) { + inputRef.current.indeterminate = indeterminate + } + }, [indeterminate, inputRef]) + + const wrapperClasses = classNames( + 'checkbox', + disabled && 'checkbox--disabled', + className, + ) + + const labelClasses = classNames( + 'checkbox__label', + disabled && 'checkbox__label--disabled', + ) + + const iconClasses = classNames( + 'checkbox__icon', + disabled && 'checkbox__icon--disabled', + ) + + const checkboxInput = ( + + + {indeterminate ? ( + + ) : ( + + )} + + ) + + if (label) { + return ( + + ) + } + + return ( + + {checkboxInput} + + ) + }, +) + +Checkbox.displayName = 'Checkbox' diff --git a/packages/eds-core-react/src/components/Checkbox/Checkbox.new.types.ts b/packages/eds-core-react/src/components/Checkbox/Checkbox.new.types.ts new file mode 100644 index 0000000000..d78bbe18f0 --- /dev/null +++ b/packages/eds-core-react/src/components/Checkbox/Checkbox.new.types.ts @@ -0,0 +1,12 @@ +import { InputHTMLAttributes } from 'react' + +export type CheckboxProps = { + /** Label for the checkbox */ + label?: string + /** If true, the checkbox will be disabled */ + disabled?: boolean + /** If true, the checkbox appears indeterminate. Important! You'll have to + * set the native element to indeterminate yourself. + */ + indeterminate?: boolean +} & InputHTMLAttributes diff --git a/packages/eds-core-react/src/components/Checkbox/__snapshots__/Checkbox.new.test.tsx.snap b/packages/eds-core-react/src/components/Checkbox/__snapshots__/Checkbox.new.test.tsx.snap new file mode 100644 index 0000000000..4f00288380 --- /dev/null +++ b/packages/eds-core-react/src/components/Checkbox/__snapshots__/Checkbox.new.test.tsx.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`Checkbox.new Matches snapshot 1`] = ` + + + +`; diff --git a/packages/eds-core-react/src/components/Checkbox/checkbox.new.css b/packages/eds-core-react/src/components/Checkbox/checkbox.new.css new file mode 100644 index 0000000000..b1d2c0e9bc --- /dev/null +++ b/packages/eds-core-react/src/components/Checkbox/checkbox.new.css @@ -0,0 +1,175 @@ +@import '@equinor/eds-tokens/css/variables'; +@import '@equinor/eds-tokens/css/foundation'; + +/* Custom properties for checkbox-specific sizes */ +:root { + /* Hover circle size - smaller than 48px touch target for visual balance */ + --checkbox-hover-size: 40px; +} + +[data-density='compact'] { + --checkbox-hover-size: 32px; +} + +/* Checkbox wrapper/label */ +.checkbox { + display: inline-flex; + align-items: center; + gap: var(--eds-spacing-inline-sm); + cursor: pointer; + position: relative; +} + +.checkbox--disabled { + cursor: not-allowed; +} + +/* Checkbox input wrapper */ +.checkbox__input-wrapper { + display: inline-grid; + grid: [input] 1fr / [input] 1fr; + position: relative; + isolation: isolate; + padding: var(--eds-spacing-inset-xs-squared); +} + +/* Hover circle background */ +.checkbox__input-wrapper::before { + content: ''; + position: absolute; + width: var(--checkbox-hover-size); + aspect-ratio: 1/1; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + border-radius: 100%; + background-color: transparent; + transition: background-color 0.2s; +} + +@media (hover: hover) and (pointer: fine) { + .checkbox__input-wrapper:hover::before { + background-color: var(--eds-color-bg-accent-fill-muted-default); + } + + .checkbox--disabled .checkbox__input-wrapper:hover::before { + background-color: transparent; + } +} + +/* Native checkbox input */ +.checkbox__input { + appearance: none; + width: 100%; + height: 100%; + margin: 0; + grid-area: input; + cursor: pointer; + position: relative; + z-index: 1; + /* Scale to 48px touch target - fixed size for accessibility requirement */ + transform: scale(calc(48px / 24px)); +} + +.checkbox__input:disabled { + cursor: not-allowed; +} + +.checkbox__input:focus { + outline: none; +} + +/* Focus styles */ +.checkbox__input[data-focus-visible-added]:focus ~ .checkbox__icon, +.checkbox__input:focus-visible ~ .checkbox__icon { + outline: 2px dashed var(--eds-color-border-focus); + outline-offset: var(--eds-spacing-inline-xs); +} + +/* Compact mode focus */ +[data-density='comfortable'] + .checkbox__input[data-focus-visible-added]:focus + ~ .checkbox__icon, +[data-density='comfortable'] .checkbox__input:focus-visible ~ .checkbox__icon { + outline-offset: var(--eds-spacing-inline-3-xs); +} + +/* SVG icon */ +.checkbox__icon { + grid-area: input; + pointer-events: none; + /* Fixed 24px - standard EDS icon size */ + width: 24px; + height: 24px; + fill: var(--eds-color-border-accent-strong); + border-radius: var(--eds-shape-corner-medium); + position: relative; + z-index: 2; +} + +.checkbox__icon--disabled { + fill: var(--eds-color-text-neutral-subtle); +} + +/* Icon paths visibility */ +.checkbox__icon-path--checked { + display: none; +} + +.checkbox__input:checked ~ .checkbox__icon .checkbox__icon-path--checked { + display: inline; +} + +.checkbox__input:checked ~ .checkbox__icon .checkbox__icon-path--unchecked { + display: none; +} + +.checkbox__input:not(:checked) ~ .checkbox__icon .checkbox__icon-path--checked { + display: none; +} + +.checkbox__input:not(:checked) + ~ .checkbox__icon + .checkbox__icon-path--unchecked { + display: inline; +} + +/* Indeterminate state */ +.checkbox__icon-path--indeterminate { + display: none; +} + +.checkbox__input[data-indeterminate='true'] + ~ .checkbox__icon + .checkbox__icon-path--indeterminate { + display: inline; +} + +.checkbox__input[data-indeterminate='true'] + ~ .checkbox__icon + .checkbox__icon-path--checked, +.checkbox__input[data-indeterminate='true'] + ~ .checkbox__icon + .checkbox__icon-path--unchecked { + display: none; +} + +/* Label text */ +.checkbox__label { + color: var(--eds-color-text-neutral-strong); + user-select: none; +} + +.checkbox__label--disabled { + color: var(--eds-color-text-neutral-subtle); +} + +/* Compact density mode */ +[data-density='compact'] .checkbox__input-wrapper { + padding: var(--eds-spacing-inset-2-xs-squared); +} + +[data-density='compact'] .checkbox__input { + /* Maintain 48px touch target in compact mode for accessibility */ + transform: scale(calc(48px / 24px)); +} From 15060c86dbbe2d20420bdf8ce29f738d6c7343c0 Mon Sep 17 00:00:00 2001 From: Frida Date: Tue, 4 Nov 2025 16:01:25 +0100 Subject: [PATCH 02/27] Tests + labelProps --- .../Checkbox/Checkbox.new.stories.tsx | 12 +++++- .../components/Checkbox/Checkbox.new.test.tsx | 37 +++++++++++++++++++ .../src/components/Checkbox/Checkbox.new.tsx | 3 +- .../components/Checkbox/Checkbox.new.types.ts | 4 +- .../__snapshots__/Checkbox.new.test.tsx.snap | 6 +++ 5 files changed, 59 insertions(+), 3 deletions(-) diff --git a/packages/eds-core-react/src/components/Checkbox/Checkbox.new.stories.tsx b/packages/eds-core-react/src/components/Checkbox/Checkbox.new.stories.tsx index 803445e46a..d60aa8f65a 100644 --- a/packages/eds-core-react/src/components/Checkbox/Checkbox.new.stories.tsx +++ b/packages/eds-core-react/src/components/Checkbox/Checkbox.new.stories.tsx @@ -36,7 +36,17 @@ const meta: Meta = { export default meta const UnstyledList = ({ children, ...props }) => ( -
      +
        {children}
      ) diff --git a/packages/eds-core-react/src/components/Checkbox/Checkbox.new.test.tsx b/packages/eds-core-react/src/components/Checkbox/Checkbox.new.test.tsx index 5bc0be989a..b7f08e78cf 100644 --- a/packages/eds-core-react/src/components/Checkbox/Checkbox.new.test.tsx +++ b/packages/eds-core-react/src/components/Checkbox/Checkbox.new.test.tsx @@ -122,4 +122,41 @@ describe('Checkbox.new', () => { const label = checkbox.closest('.checkbox') expect(label).toHaveClass('checkbox--disabled') }) + + it('should apply data-* attributes to input element when using label', () => { + render( + , + ) + + const input = screen.getByRole('checkbox') + expect(input).toHaveAttribute('data-testid', 'test-checkbox') + expect(input).toHaveAttribute('data-analytics', 'track-checkbox') + }) + + it('should apply labelProps to label element', () => { + const { container } = render( + + } + />, + ) + + const input = screen.getByRole('checkbox') + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const label = container.querySelector('[data-testid="test-checkbox-label"]') + + expect(input).toHaveAttribute('data-testid', 'test-checkbox-input') + expect(label).toHaveAttribute('data-testid', 'test-checkbox-label') + expect(label).toHaveAttribute('data-analytics', 'checkbox-wrapper') + }) }) diff --git a/packages/eds-core-react/src/components/Checkbox/Checkbox.new.tsx b/packages/eds-core-react/src/components/Checkbox/Checkbox.new.tsx index 8f83a2b0f2..40fb664e07 100644 --- a/packages/eds-core-react/src/components/Checkbox/Checkbox.new.tsx +++ b/packages/eds-core-react/src/components/Checkbox/Checkbox.new.tsx @@ -20,6 +20,7 @@ export const Checkbox = forwardRef( indeterminate = false, className, style, + labelProps, ...rest }, ref, @@ -114,7 +115,7 @@ export const Checkbox = forwardRef( if (label) { return ( -