Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 17 additions & 16 deletions docs/component-adapter/component-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement \| null>` | 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<HTMLInputElement \| null>` | 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
}

.selectAllRow {
padding: toRem(8) toRem(16);
padding-top: toRem(8);
padding-bottom: toRem(8);
}

h5.columnTitle {
Expand Down
5 changes: 5 additions & 0 deletions src/components/Common/DataView/DataCards/DataCards.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
3 changes: 2 additions & 1 deletion src/components/Common/DataView/DataCards/DataCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const DataCards = <T,>({
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
Expand Down Expand Up @@ -83,6 +83,7 @@ export const DataCards = <T,>({
<div className={styles.selectAllRow}>
<Components.Checkbox
value={allSelected}
isIndeterminate={isIndeterminate}
onChange={(checked: boolean) => onSelectAll?.(checked, data)}
label={t('card.selectAllRowsLabel')}
/>
Expand Down
5 changes: 5 additions & 0 deletions src/components/Common/DataView/DataTable/DataTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
3 changes: 2 additions & 1 deletion src/components/Common/DataView/DataTable/DataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export const DataTable = <T,>({
const { t } = useTranslation('common')
const radioGroupName = useId()
const [selectedRadioIndex, setSelectedRadioIndex] = useState<number | null>(null)
const { allSelected } = useSelectionState(data, getIsItemSelected)
const { allSelected, isIndeterminate } = useSelectionState(data, getIsItemSelected)

const headers: TableData[] = [
...(onSelect
Expand Down Expand Up @@ -86,6 +86,7 @@ export const DataTable = <T,>({
>
<Components.Checkbox
value={allSelected}
isIndeterminate={isIndeterminate}
onChange={(checked: boolean) => onSelectAll?.(checked, data)}
label={t('table.selectAllRowsLabel')}
shouldVisuallyHideLabel
Expand Down
6 changes: 4 additions & 2 deletions src/components/Common/DataView/useSelectionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useMemo } from 'react'

type SelectionState = {
allSelected: boolean
isIndeterminate: boolean
}

export function useSelectionState<T>(
Expand All @@ -10,11 +11,12 @@ export function useSelectionState<T>(
): 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])
}
3 changes: 2 additions & 1 deletion src/components/Common/UI/Checkbox/Checkbox.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
}
}

.checked .checkbox {
.checked .checkbox,
.indeterminate .checkbox {
border-color: var(--g-colorPrimary);
background: var(--g-colorPrimary);

Expand Down
10 changes: 10 additions & 0 deletions src/components/Common/UI/Checkbox/Checkbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ describe('Checkbox', () => {
expect(checkbox).toBeChecked()
})

it('sets indeterminate property on input when isIndeterminate is true', () => {
renderWithProviders(<Checkbox {...defaultProps} isIndeterminate />)
expect(screen.getByRole('checkbox')).toBePartiallyChecked()
})

it('does not set indeterminate when isIndeterminate is false', () => {
renderWithProviders(<Checkbox {...defaultProps} isIndeterminate={false} />)
expect(screen.getByRole('checkbox')).not.toBePartiallyChecked()
})

it('renders with description', () => {
renderWithProviders(<Checkbox {...defaultProps} description="Helpful description" />)
expect(screen.getByText('Helpful description')).toBeInTheDocument()
Expand Down
32 changes: 29 additions & 3 deletions src/components/Common/UI/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)
Expand All @@ -18,6 +19,7 @@ export const Checkbox = (rawProps: CheckboxProps) => {
isRequired,
inputRef,
value,
isIndeterminate,
isInvalid,
isDisabled,
id,
Expand All @@ -27,6 +29,25 @@ export const Checkbox = (rawProps: CheckboxProps) => {
shouldVisuallyHideLabel,
...otherProps
} = resolvedProps

const internalRef = useRef<HTMLInputElement>(null)
const setRef = useCallback(
(node: HTMLInputElement | null) => {
;(internalRef as React.MutableRefObject<HTMLInputElement | null>).current = node
if (typeof inputRef === 'function') {
inputRef(node)
} else if (inputRef != null) {
;(inputRef as React.MutableRefObject<HTMLInputElement | null>).current = node
}
},
[inputRef],
)

useEffect(() => {
if (internalRef.current) {
internalRef.current.indeterminate = isIndeterminate ?? false
}
}, [isIndeterminate])
const { inputId, errorMessageId, descriptionId, ariaDescribedBy } = useFieldIds({
inputId: id,
errorMessage,
Expand Down Expand Up @@ -54,6 +75,7 @@ export const Checkbox = (rawProps: CheckboxProps) => {
className={classNames(
styles.checkboxWrapper,
value && styles.checked,
isIndeterminate && styles.indeterminate,
isDisabled && styles.disabled,
)}
>
Expand All @@ -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}
/>
<div className={styles.checkbox}>
<IconChecked className={styles.check} />
{isIndeterminate ? (
<IconIndeterminate className={styles.check} />
) : (
<IconChecked className={styles.check} />
)}
</div>
</div>
</HorizontalFieldLayout>
Expand Down
5 changes: 5 additions & 0 deletions src/components/Common/UI/Checkbox/CheckboxTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Loading