diff --git a/.changeset/migrate-component-button-group-to-css.md b/.changeset/migrate-component-button-group-to-css.md new file mode 100644 index 000000000..43a405619 --- /dev/null +++ b/.changeset/migrate-component-button-group-to-css.md @@ -0,0 +1,16 @@ +--- +'@clickhouse/click-ui': minor +--- + +Migrate ButtonGroup component from Styled-Components to CSS Modules + +**New Features:** +- **Accessibility**: Added `aria-disabled` attribute to disabled buttons (complements native `disabled` prop) +- **Accessibility**: Added optional `aria-label` prop for WCAG 4.1.2 compliance (role="group" should have accessible name) +- **Accessibility**: Added `:focus-visible` styles for keyboard navigation with visible focus ring +- **Forms**: Added `type="button"` to prevent accidental form submissions when used inside forms +- **States**: Added proper `:hover:not(:disabled)` selector to prevent hover styles on disabled buttons +- **States**: Added explicit styling for disabled+active button combination + +**Migration Notes:** +Consumers are encouraged to add `aria-label` to their ButtonGroup instances for improved accessibility, though it remains optional for backward compatibility. diff --git a/src/components/ButtonGroup/ButtonGroup.module.css b/src/components/ButtonGroup/ButtonGroup.module.css new file mode 100644 index 000000000..5271ee960 --- /dev/null +++ b/src/components/ButtonGroup/ButtonGroup.module.css @@ -0,0 +1,86 @@ +.buttongroup { + display: inline-flex; + box-sizing: border-box; + flex-direction: row; + justify-content: center; + align-items: center; + border-radius: var(--click-button-group-radii-panel-all); + background: var(--click-button-group-color-background-panel); +} + +.buttongroup_type_default { + padding: var(--click-button-group-space-panel-default-x) + var(--click-button-group-space-panel-default-y); + gap: var(--click-button-group-space-panel-default-gap); + border: 1px solid var(--click-button-group-color-panel-stroke-default); +} + +.buttongroup_type_borderless { + padding: var(--click-button-group-space-panel-borderless-x) + var(--click-button-group-space-panel-borderless-y); + gap: var(--click-button-group-space-panel-borderless-gap); + border: none; +} + +.buttongroup_fillwidth { + width: 100%; +} + +.button { + display: flex; + box-sizing: border-box; + flex-direction: row; + justify-content: center; + align-items: center; + border: none; + background: var(--click-button-group-color-background-default); + color: var(--click-button-group-color-text-default); + font: var(--click-button-group-typography-label-default); + white-space: nowrap; + cursor: pointer; +} + +.button_type_default { + padding: var(--click-button-group-space-button-default-y) + var(--click-button-group-space-button-default-x); + border-radius: var(--click-button-group-radii-button-default-all); +} + +.button_type_borderless { + padding: var(--click-button-group-space-button-borderless-y) + var(--click-button-group-space-button-borderless-x); + border-radius: var(--click-button-group-radii-button-borderless-all); +} + +.button_fillwidth { + flex: 1; +} + +.button[aria-pressed='true'] { + background: var(--click-button-group-color-background-active); + color: var(--click-button-group-color-text-active); + font: var(--click-button-group-typography-label-active); +} + +.button[aria-pressed='true']:disabled { + background: var(--click-button-group-color-background-disabled-active); + color: var(--click-button-group-color-text-disabled-active); +} + +.button:disabled { + background: var(--click-button-group-color-background-disabled); + color: var(--click-button-group-color-text-disabled); + font: var(--click-button-group-typography-label-disabled); + cursor: not-allowed; +} + +.button:hover:not(:disabled) { + background: var(--click-button-group-color-background-hover); + color: var(--click-button-group-color-text-hover); + font: var(--click-button-group-typography-label-hover); +} + +.button:focus-visible:not(:disabled) { + outline: 2px solid var(--click-button-group-color-background-active); + outline-offset: 2px; +} diff --git a/src/components/ButtonGroup/ButtonGroup.stories.tsx b/src/components/ButtonGroup/ButtonGroup.stories.tsx index d16b1d18b..ceeb85582 100644 --- a/src/components/ButtonGroup/ButtonGroup.stories.tsx +++ b/src/components/ButtonGroup/ButtonGroup.stories.tsx @@ -33,6 +33,7 @@ export const Default: StoryObj = { { label: 'Option 3', value: 'option3' }, ], type: 'default', + 'aria-label': 'Button group', }, }; @@ -44,6 +45,7 @@ export const Borderless: StoryObj = { { label: 'Option 3', value: 'option3' }, ], type: 'borderless', + 'aria-label': 'Button group', }, }; @@ -56,6 +58,7 @@ export const DefaultSelected: StoryObj = { ], type: 'default', selected: 'option1', + 'aria-label': 'Button group', }, }; @@ -68,6 +71,7 @@ export const BorderlessSelected: StoryObj = { ], type: 'borderless', selected: 'option1', + 'aria-label': 'Button group', }, }; @@ -78,6 +82,7 @@ export const WithDisabledButton: StoryObj = { { label: 'Disabled', value: 'disabled', disabled: true }, ], type: 'default', + 'aria-label': 'Button group', }, }; @@ -89,6 +94,7 @@ export const WithDisabledSelectedButton: StoryObj = { ], type: 'default', selected: 'disabled', + 'aria-label': 'Button group', }, }; @@ -100,6 +106,7 @@ export const FillWidthDefault: StoryObj = { ], type: 'default', fillWidth: true, + 'aria-label': 'Button group', }, }; @@ -111,6 +118,7 @@ export const FillWidthBorderless: StoryObj = { ], type: 'borderless', fillWidth: true, + 'aria-label': 'Button group', }, }; @@ -124,6 +132,7 @@ export const MultiSelectSelected: StoryObj = { type: 'default', multiple: true, selected: new Set(['option1', 'option3']), + 'aria-label': 'Button group', }, }; @@ -137,6 +146,7 @@ export const MultiSelectBorderless: StoryObj = { type: 'borderless', multiple: true, selected: new Set(['option1', 'option3']), + 'aria-label': 'Button group', }, }; @@ -151,6 +161,7 @@ export const MultiSelect: StoryObj = { type: 'default', multiple: true, defaultSelected: new Set(['option1', 'option3']), + 'aria-label': 'Button group', onClick: (_value, selected) => { console.log('🔎 Selected:', [...selected]); }, @@ -167,6 +178,7 @@ export const ConsumerControlledStateSingle: StoryObj = { fillWidth: false, type: 'default', selected: 'option2', + 'aria-label': 'Button group', }, render: args => { const [selected, setSelected] = useState(args.selected); @@ -194,6 +206,7 @@ export const ConsumerControlledStateMulti: StoryObj = { type: 'default', multiple: true, selected: new Set(['option2']), + 'aria-label': 'Button group', }, render: args => { const [selected, setSelected] = useState(args.selected); diff --git a/src/components/ButtonGroup/ButtonGroup.test.tsx b/src/components/ButtonGroup/ButtonGroup.test.tsx index 14b8206ac..366466cf1 100644 --- a/src/components/ButtonGroup/ButtonGroup.test.tsx +++ b/src/components/ButtonGroup/ButtonGroup.test.tsx @@ -13,7 +13,7 @@ describe('ButtonGroup', () => { ]; it('renders buttons with labels correctly', () => { - const { getByText } = renderButtonGroup({ options }); + const { getByText } = renderButtonGroup({ options, 'aria-label': 'Button group' }); options.forEach(option => { expect(getByText(option.label).textContent).toBe(option.label); @@ -27,6 +27,7 @@ describe('ButtonGroup', () => { const { getByText } = renderButtonGroup({ options, onClick: handleClick, + 'aria-label': 'Button group', }); fireEvent.click(getByText('Option 2')); @@ -45,6 +46,7 @@ describe('ButtonGroup', () => { const { getByText } = renderButtonGroup({ options, onClick: handleClick, + 'aria-label': 'Button group', }); fireEvent.click(getByText('Option 2')); @@ -66,6 +68,7 @@ describe('ButtonGroup', () => { onClick: handleClick, multiple: true, selected: new Set(['option1']), + 'aria-label': 'Button group', }); fireEvent.click(getByText('Option 2')); @@ -85,6 +88,7 @@ describe('ButtonGroup', () => { onClick: handleClick, multiple: true, selected: new Set(['option1', 'option2']), + 'aria-label': 'Button group', }); fireEvent.click(getByText('Option 2')); @@ -97,6 +101,7 @@ describe('ButtonGroup', () => { options, multiple: true, selected: new Set(['option1', 'option3']), + 'aria-label': 'Button group', }); const option1 = getByText('Option 1'); @@ -112,6 +117,7 @@ describe('ButtonGroup', () => { const { getByText } = renderButtonGroup({ options, selected: 'option2', + 'aria-label': 'Button group', }); const activeButton = getByText('Option 2'); @@ -125,6 +131,7 @@ describe('ButtonGroup', () => { const { getByText } = renderButtonGroup({ options, defaultSelected: 'option2', + 'aria-label': 'Button group', }); expect(getByText('Option 2')).toHaveAttribute('aria-pressed', 'true'); @@ -136,6 +143,7 @@ describe('ButtonGroup', () => { options, multiple: true, defaultSelected: new Set(['option1', 'option3']), + 'aria-label': 'Button group', }); expect(getByText('Option 1')).toHaveAttribute('aria-pressed', 'true'); @@ -147,6 +155,7 @@ describe('ButtonGroup', () => { const { getByText } = renderButtonGroup({ options, defaultSelected: 'option1', + 'aria-label': 'Button group', }); fireEvent.click(getByText('Option 2')); @@ -160,6 +169,7 @@ describe('ButtonGroup', () => { options, multiple: true, defaultSelected: new Set(['option1']), + 'aria-label': 'Button group', }); fireEvent.click(getByText('Option 2')); @@ -173,6 +183,7 @@ describe('ButtonGroup', () => { options, multiple: true, defaultSelected: new Set(['option1', 'option2']), + 'aria-label': 'Button group', }); fireEvent.click(getByText('Option 2')); @@ -193,6 +204,7 @@ describe('ButtonGroup', () => { options, defaultSelected: 'option1', onClick: handleClick, + 'aria-label': 'Button group', }); fireEvent.click(getByText('Option 2')); @@ -209,6 +221,7 @@ describe('ButtonGroup', () => { options, selected: 'option3', defaultSelected: 'option1', + 'aria-label': 'Button group', }); expect(getByText('Option 3')).toHaveAttribute('aria-pressed', 'true'); @@ -219,6 +232,7 @@ describe('ButtonGroup', () => { const { getByText } = renderButtonGroup({ options, selected: 'option1', + 'aria-label': 'Button group', }); fireEvent.click(getByText('Option 2')); diff --git a/src/components/ButtonGroup/ButtonGroup.tsx b/src/components/ButtonGroup/ButtonGroup.tsx index 7895b2046..f3fb3e53e 100644 --- a/src/components/ButtonGroup/ButtonGroup.tsx +++ b/src/components/ButtonGroup/ButtonGroup.tsx @@ -1,6 +1,33 @@ import { useCallback, useState } from 'react'; -import { styled } from 'styled-components'; +import { cn, cva } from '@/lib/cva'; import { ButtonGroupProps, SelectionValue } from './ButtonGroup.types'; +import styles from './ButtonGroup.module.css'; + +const wrapperVariants = cva(styles.buttongroup, { + variants: { + type: { + default: styles['buttongroup_type_default'], + borderless: styles['buttongroup_type_borderless'], + }, + fillWidth: { + true: styles['buttongroup_fillwidth'], + }, + }, + defaultVariants: { type: 'default' }, +}); + +const buttonVariants = cva(styles.button, { + variants: { + type: { + default: styles['button_type_default'], + borderless: styles['button_type_borderless'], + }, + fillWidth: { + true: styles['button_fillwidth'], + }, + }, + defaultVariants: { type: 'default' }, +}); const normalizeToSet = (value: SelectionValue | undefined): Set => { if (value === undefined) { @@ -25,6 +52,7 @@ export const ButtonGroup = ({ type = 'default', multiple = false, 'aria-label': ariaLabel, + className, ...props }: ButtonGroupProps) => { const [internalSelection, setInternalSelection] = useState>(() => @@ -65,115 +93,33 @@ export const ButtonGroup = ({ [currentSelection, multiple, isControlled, onClick] ); - const buttons = options.map(({ value, label, ...buttonProps }) => { + const buttons = options.map(({ value, label, disabled, ...buttonProps }) => { const isActive = isValueSelected(value, currentSelection); return ( - + ); }); return ( - {buttons} - + ); }; - -import { ButtonGroupType } from './ButtonGroup.types'; - -const ButtonGroupWrapper = styled.div<{ $fillWidth: boolean; $type: ButtonGroupType }>` - display: inline-flex; - box-sizing: border-box; - flex-direction: row; - justify-content: center; - align-items: center; - padding: ${({ theme, $type }) => - `${theme.click.button.group.space.panel[$type].x} ${theme.click.button.group.space.panel[$type].y}`}; - gap: ${({ theme, $type }) => theme.click.button.group.space.panel[$type].gap}; - border: ${({ theme, $type }) => - $type === 'default' - ? `1px solid ${theme.click.button.group.color.panel.stroke[$type]}` - : 'none'}; - background: ${({ theme }) => theme.click.button.group.color.background.panel}; - border-radius: ${({ theme }) => theme.click.button.group.radii.panel.all}; - width: ${({ $fillWidth }) => ($fillWidth ? '100%' : 'auto')}; -`; - -const Button = styled.button.attrs<{ - disabled?: boolean; -}>(props => ({ - 'aria-disabled': props.disabled ? 'true' : undefined, -}))<{ - $active: boolean; - $fillWidth: boolean; - $type: ButtonGroupType; - disabled?: boolean; -}>` - box-sizing: border-box; - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - background: ${({ $active, theme }) => - $active - ? theme.click.button.group.color.background.active - : theme.click.button.group.color.background.default}; - color: ${({ theme }) => theme.click.button.group.color.text.default}; - font: ${({ theme }) => theme.click.button.group.typography.label.default}; - padding: ${({ theme, $type }) => - `${theme.click.button.group.space.button[$type].y} ${theme.click.button.group.space.button[$type].x}`}; - ${({ $fillWidth }) => ($fillWidth ? 'flex: 1;' : '')}; - border-radius: ${({ theme, $type }) => - theme.click.button.group.radii.button[$type].all}; - cursor: pointer; - border: none; - - &:hover { - background: ${({ theme }) => theme.click.button.group.color.background.hover}; - font: ${({ theme }) => theme.click.button.group.typography.label.hover}; - color: ${({ theme }) => theme.click.button.group.color.text.hover}; - } - - &:disabled { - cursor: not-allowed; - font: ${({ theme }) => theme.click.button.group.typography.label.disabled}; - color: ${({ theme }) => theme.click.button.group.color.text.disabled}; - background: ${({ theme, $active }) => - theme.click.button.group.color.background[ - $active ? 'disabled-active' : 'disabled' - ]}; - - &:active, - &:focus, - &[aria-pressed='true'] { - color: ${({ theme }) => theme.click.button.group.color.text.disabled}; - } - } - - &[aria-pressed='true'] { - background: ${({ theme }) => theme.click.button.group.color.background.active}; - font: ${({ theme }) => theme.click.button.group.typography.label.active}; - color: ${({ theme }) => theme.click.button.group.color.text.active}; - &:disabled { - background: ${({ theme }) => - theme.click.button.group.color.background['disabled-active']}; - } - } -`; diff --git a/src/components/ButtonGroup/ButtonGroup.types.ts b/src/components/ButtonGroup/ButtonGroup.types.ts index 6b4bbd3d3..e3aaef74f 100644 --- a/src/components/ButtonGroup/ButtonGroup.types.ts +++ b/src/components/ButtonGroup/ButtonGroup.types.ts @@ -5,7 +5,7 @@ export type SelectionValue = string | Set; export interface ButtonGroupElementProps extends Omit< ButtonHTMLAttributes, - 'children' + 'children' | 'onClick' > { value: string; label?: ReactNode; @@ -22,4 +22,10 @@ export interface ButtonGroupProps extends Omit< fillWidth?: boolean; type?: ButtonGroupType; multiple?: boolean; + /** + * Accessible label for the button group. + * Strongly recommended for WCAG 4.1.2 compliance - role="group" should have an accessible name. + * @see https://www.w3.org/WAI/WCAG21/Understanding/name-role-value + */ + 'aria-label'?: string; } diff --git a/src/components/DatePicker/DateTimeRangePicker.tsx b/src/components/DatePicker/DateTimeRangePicker.tsx index d06410c53..e40db767b 100644 --- a/src/components/DatePicker/DateTimeRangePicker.tsx +++ b/src/components/DatePicker/DateTimeRangePicker.tsx @@ -559,6 +559,7 @@ const TimeInput = ({ date, setDate, shouldShowSeconds }: TimeInputProps) => { ]} selected={meridiem} type="default" + aria-label="AM/PM selection" /> diff --git a/tests/buttons/buttongroup.spec.ts-snapshots/buttongroup-button-focus-light-chromium-linux.png b/tests/buttons/buttongroup.spec.ts-snapshots/buttongroup-button-focus-light-chromium-linux.png index 7a19fa353..5857caee8 100644 Binary files a/tests/buttons/buttongroup.spec.ts-snapshots/buttongroup-button-focus-light-chromium-linux.png and b/tests/buttons/buttongroup.spec.ts-snapshots/buttongroup-button-focus-light-chromium-linux.png differ diff --git a/tests/buttons/buttongroup.spec.ts-snapshots/buttongroup-disabled-active-dark-chromium-linux.png b/tests/buttons/buttongroup.spec.ts-snapshots/buttongroup-disabled-active-dark-chromium-linux.png index 61be6a5f6..0e2c7cc7f 100644 Binary files a/tests/buttons/buttongroup.spec.ts-snapshots/buttongroup-disabled-active-dark-chromium-linux.png and b/tests/buttons/buttongroup.spec.ts-snapshots/buttongroup-disabled-active-dark-chromium-linux.png differ