From fdbb755a0cae8c82caa925507a6bb5e0d2dd0932 Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Wed, 27 May 2026 11:36:12 -0400 Subject: [PATCH 1/2] fix(SDK-890): add indeterminate state to select-all checkbox in data views When only some rows are selected, the select-all checkbox in DataTable and DataCards now renders a dash icon and sets the native indeterminate property for screen reader support. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- docs/component-adapter/component-inventory.md | 33 ++++++++++--------- .../DataView/DataCards/DataCards.module.scss | 3 +- .../Common/DataView/DataCards/DataCards.tsx | 3 +- .../Common/DataView/DataTable/DataTable.tsx | 3 +- .../Common/DataView/useSelectionState.ts | 6 ++-- .../Common/UI/Checkbox/Checkbox.module.scss | 3 +- .../Common/UI/Checkbox/Checkbox.tsx | 32 ++++++++++++++++-- .../Common/UI/Checkbox/CheckboxTypes.ts | 5 +++ 8 files changed, 63 insertions(+), 25 deletions(-) diff --git a/docs/component-adapter/component-inventory.md b/docs/component-adapter/component-inventory.md index 27f9e7f47..2cceed05d 100644 --- a/docs/component-adapter/component-inventory.md +++ b/docs/component-adapter/component-inventory.md @@ -231,22 +231,23 @@ ## CheckboxProps -| Prop | Type | Required | Description | -| --------------------------- | ------------------------------- | -------- | ---------------------------------------------------------------------- | -| **value** | `boolean` | No | Current checked state of the checkbox | -| **onChange** | `(value: boolean) => void` | No | Callback when checkbox state changes | -| **inputRef** | `Ref` | No | React ref for the checkbox input element | -| **isInvalid** | `boolean` | No | Indicates if the checkbox is in an invalid state | -| **isDisabled** | `boolean` | No | Disables the checkbox and prevents interaction | -| **onBlur** | `() => void` | No | Handler for blur events | -| **description** | `React.ReactNode` | No | Optional description text for the field | -| **errorMessage** | `string` | No | Error message to display when the field is invalid | -| **isRequired** | `boolean` | No | Indicates if the field is required | -| **label** | `React.ReactNode` | Yes | Label text for the field | -| **shouldVisuallyHideLabel** | `boolean` | No | Hides the label visually while keeping it accessible to screen readers | -| **className** | `string` | No | - | -| **id** | `string` | No | - | -| **name** | `string` | No | - | +| Prop | Type | Required | Description | +| --------------------------- | ------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| **value** | `boolean` | No | Current checked state of the checkbox | +| **isIndeterminate** | `boolean` | No | Renders the checkbox in an indeterminate state (dash icon). Intended for select-all controls where only some items in the set are selected. | +| **onChange** | `(value: boolean) => void` | No | Callback when checkbox state changes | +| **inputRef** | `Ref` | No | React ref for the checkbox input element | +| **isInvalid** | `boolean` | No | Indicates if the checkbox is in an invalid state | +| **isDisabled** | `boolean` | No | Disables the checkbox and prevents interaction | +| **onBlur** | `() => void` | No | Handler for blur events | +| **description** | `React.ReactNode` | No | Optional description text for the field | +| **errorMessage** | `string` | No | Error message to display when the field is invalid | +| **isRequired** | `boolean` | No | Indicates if the field is required | +| **label** | `React.ReactNode` | Yes | Label text for the field | +| **shouldVisuallyHideLabel** | `boolean` | No | Hides the label visually while keeping it accessible to screen readers | +| **className** | `string` | No | - | +| **id** | `string` | No | - | +| **name** | `string` | No | - | ## ComboBoxProps diff --git a/src/components/Common/DataView/DataCards/DataCards.module.scss b/src/components/Common/DataView/DataCards/DataCards.module.scss index b47de3888..61cfa3f64 100644 --- a/src/components/Common/DataView/DataCards/DataCards.module.scss +++ b/src/components/Common/DataView/DataCards/DataCards.module.scss @@ -19,7 +19,8 @@ } .selectAllRow { - padding: toRem(8) toRem(16); + padding-top: toRem(8); + padding-bottom: toRem(8); } h5.columnTitle { diff --git a/src/components/Common/DataView/DataCards/DataCards.tsx b/src/components/Common/DataView/DataCards/DataCards.tsx index 62fe0ca5c..79c0ea3be 100644 --- a/src/components/Common/DataView/DataCards/DataCards.tsx +++ b/src/components/Common/DataView/DataCards/DataCards.tsx @@ -40,7 +40,7 @@ export const DataCards = ({ const Components = useComponentContext() const { t } = useTranslation('common') const radioGroupName = useId() - const { allSelected } = useSelectionState(data, getIsItemSelected) + const { allSelected, isIndeterminate } = useSelectionState(data, getIsItemSelected) const renderAction = (item: T, index: number) => { if (!onSelect) return undefined @@ -83,6 +83,7 @@ export const DataCards = ({
onSelectAll?.(checked, data)} label={t('card.selectAllRowsLabel')} /> diff --git a/src/components/Common/DataView/DataTable/DataTable.tsx b/src/components/Common/DataView/DataTable/DataTable.tsx index ce27583ea..b36875d0f 100644 --- a/src/components/Common/DataView/DataTable/DataTable.tsx +++ b/src/components/Common/DataView/DataTable/DataTable.tsx @@ -55,7 +55,7 @@ export const DataTable = ({ const { t } = useTranslation('common') const radioGroupName = useId() const [selectedRadioIndex, setSelectedRadioIndex] = useState(null) - const { allSelected } = useSelectionState(data, getIsItemSelected) + const { allSelected, isIndeterminate } = useSelectionState(data, getIsItemSelected) const headers: TableData[] = [ ...(onSelect @@ -86,6 +86,7 @@ export const DataTable = ({ > onSelectAll?.(checked, data)} label={t('table.selectAllRowsLabel')} shouldVisuallyHideLabel diff --git a/src/components/Common/DataView/useSelectionState.ts b/src/components/Common/DataView/useSelectionState.ts index adc198cde..492033e4d 100644 --- a/src/components/Common/DataView/useSelectionState.ts +++ b/src/components/Common/DataView/useSelectionState.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react' type SelectionState = { allSelected: boolean + isIndeterminate: boolean } export function useSelectionState( @@ -10,11 +11,12 @@ export function useSelectionState( ): SelectionState { return useMemo(() => { if (data.length === 0 || !getIsItemSelected) { - return { allSelected: false } + return { allSelected: false, isIndeterminate: false } } const allSelected = data.every(getIsItemSelected) + const someSelected = data.some(getIsItemSelected) - return { allSelected } + return { allSelected, isIndeterminate: someSelected && !allSelected } }, [data, getIsItemSelected]) } diff --git a/src/components/Common/UI/Checkbox/Checkbox.module.scss b/src/components/Common/UI/Checkbox/Checkbox.module.scss index 15067fa78..092080974 100644 --- a/src/components/Common/UI/Checkbox/Checkbox.module.scss +++ b/src/components/Common/UI/Checkbox/Checkbox.module.scss @@ -43,7 +43,8 @@ } } -.checked .checkbox { +.checked .checkbox, +.indeterminate .checkbox { border-color: var(--g-colorPrimary); background: var(--g-colorPrimary); diff --git a/src/components/Common/UI/Checkbox/Checkbox.tsx b/src/components/Common/UI/Checkbox/Checkbox.tsx index e92dea635..bd9979658 100644 --- a/src/components/Common/UI/Checkbox/Checkbox.tsx +++ b/src/components/Common/UI/Checkbox/Checkbox.tsx @@ -1,4 +1,4 @@ -import type { ChangeEvent } from 'react' +import { type ChangeEvent, useCallback, useEffect, useRef } from 'react' import classNames from 'classnames' import { useFieldIds } from '../hooks/useFieldIds' import styles from './Checkbox.module.scss' @@ -7,6 +7,7 @@ import { CheckboxDefaults } from './CheckboxTypes' import { applyMissingDefaults } from '@/helpers/applyMissingDefaults' import { HorizontalFieldLayout } from '@/components/Common/HorizontalFieldLayout' import IconChecked from '@/assets/icons/checkbox.svg?react' +import IconIndeterminate from '@/assets/icons/checkbox_indeterminate.svg?react' export const Checkbox = (rawProps: CheckboxProps) => { const resolvedProps = applyMissingDefaults(rawProps, CheckboxDefaults) @@ -18,6 +19,7 @@ export const Checkbox = (rawProps: CheckboxProps) => { isRequired, inputRef, value, + isIndeterminate, isInvalid, isDisabled, id, @@ -27,6 +29,25 @@ export const Checkbox = (rawProps: CheckboxProps) => { shouldVisuallyHideLabel, ...otherProps } = resolvedProps + + const internalRef = useRef(null) + const setRef = useCallback( + (node: HTMLInputElement | null) => { + ;(internalRef as React.MutableRefObject).current = node + if (typeof inputRef === 'function') { + inputRef(node) + } else if (inputRef != null) { + ;(inputRef as React.MutableRefObject).current = node + } + }, + [inputRef], + ) + + useEffect(() => { + if (internalRef.current) { + internalRef.current.indeterminate = isIndeterminate ?? false + } + }, [isIndeterminate]) const { inputId, errorMessageId, descriptionId, ariaDescribedBy } = useFieldIds({ inputId: id, errorMessage, @@ -54,6 +75,7 @@ export const Checkbox = (rawProps: CheckboxProps) => { className={classNames( styles.checkboxWrapper, value && styles.checked, + isIndeterminate && styles.indeterminate, isDisabled && styles.disabled, )} > @@ -65,13 +87,17 @@ export const Checkbox = (rawProps: CheckboxProps) => { aria-describedby={ariaDescribedBy} checked={value} id={inputId} - ref={inputRef} + ref={setRef} onBlur={onBlur} onChange={handleChange} className={styles.checkboxInput} />
- + {isIndeterminate ? ( + + ) : ( + + )}
diff --git a/src/components/Common/UI/Checkbox/CheckboxTypes.ts b/src/components/Common/UI/Checkbox/CheckboxTypes.ts index e14a5a9d1..3442784ec 100644 --- a/src/components/Common/UI/Checkbox/CheckboxTypes.ts +++ b/src/components/Common/UI/Checkbox/CheckboxTypes.ts @@ -9,6 +9,11 @@ export interface CheckboxProps * Current checked state of the checkbox */ value?: boolean + /** + * Renders the checkbox in an indeterminate state (dash icon). Intended for + * select-all controls where only some items in the set are selected. + */ + isIndeterminate?: boolean /** * Callback when checkbox state changes */ From 438b901058687c6d429fa549a65f2982623febaa Mon Sep 17 00:00:00 2001 From: Aaron Lee Date: Wed, 27 May 2026 11:38:50 -0400 Subject: [PATCH 2/2] test(SDK-890): add indeterminate state tests for Checkbox, DataTable, DataCards Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../Common/DataView/DataCards/DataCards.test.tsx | 5 +++++ .../Common/DataView/DataTable/DataTable.test.tsx | 5 +++++ src/components/Common/UI/Checkbox/Checkbox.test.tsx | 10 ++++++++++ 3 files changed, 20 insertions(+) diff --git a/src/components/Common/DataView/DataCards/DataCards.test.tsx b/src/components/Common/DataView/DataCards/DataCards.test.tsx index e071846e1..1fbc28915 100644 --- a/src/components/Common/DataView/DataCards/DataCards.test.tsx +++ b/src/components/Common/DataView/DataCards/DataCards.test.tsx @@ -97,6 +97,11 @@ describe('DataCards', () => { expect(screen.getByLabelText('Select all rows')).toBeChecked() }) + test('select-all checkbox is indeterminate when some (but not all) rows are selected', () => { + renderCards({ ...selectableProps, getIsItemSelected: item => item.id === 1 }) + expect(screen.getByLabelText('Select all rows')).toBePartiallyChecked() + }) + test('clicking select-all fires onSelectAll with true when not all selected', async () => { const onSelectAllMock = vi.fn() renderCards({ ...selectableProps, onSelectAll: onSelectAllMock }) diff --git a/src/components/Common/DataView/DataTable/DataTable.test.tsx b/src/components/Common/DataView/DataTable/DataTable.test.tsx index 2ff1d2248..8cf3889ee 100644 --- a/src/components/Common/DataView/DataTable/DataTable.test.tsx +++ b/src/components/Common/DataView/DataTable/DataTable.test.tsx @@ -118,6 +118,11 @@ describe('DataTable Component', () => { expect(screen.getAllByRole('checkbox')[0]).not.toBeChecked() }) + test('header checkbox is indeterminate when some (but not all) rows are selected', () => { + renderTable({ ...selectableProps, getIsItemSelected: item => item.id === 1 }) + expect(screen.getAllByRole('checkbox')[0]).toBePartiallyChecked() + }) + test('clicking the header checkbox fires onSelectAll with checked=true when not all selected', async () => { const onSelectAllMock = vi.fn() renderTable({ ...selectableProps, onSelectAll: onSelectAllMock }) diff --git a/src/components/Common/UI/Checkbox/Checkbox.test.tsx b/src/components/Common/UI/Checkbox/Checkbox.test.tsx index d7458e6de..1cc4be206 100644 --- a/src/components/Common/UI/Checkbox/Checkbox.test.tsx +++ b/src/components/Common/UI/Checkbox/Checkbox.test.tsx @@ -81,6 +81,16 @@ describe('Checkbox', () => { expect(checkbox).toBeChecked() }) + it('sets indeterminate property on input when isIndeterminate is true', () => { + renderWithProviders() + expect(screen.getByRole('checkbox')).toBePartiallyChecked() + }) + + it('does not set indeterminate when isIndeterminate is false', () => { + renderWithProviders() + expect(screen.getByRole('checkbox')).not.toBePartiallyChecked() + }) + it('renders with description', () => { renderWithProviders() expect(screen.getByText('Helpful description')).toBeInTheDocument()