diff --git a/apps/www/src/content/docs/components/checkbox/demo.ts b/apps/www/src/content/docs/components/checkbox/demo.ts index e57be2af5..23d700cb2 100644 --- a/apps/www/src/content/docs/components/checkbox/demo.ts +++ b/apps/www/src/content/docs/components/checkbox/demo.ts @@ -25,6 +25,11 @@ export const playground = { disabled: { type: 'checkbox', defaultValue: false + }, + size: { + type: 'select', + options: ['large', 'small'], + defaultValue: 'large' } }, getCode @@ -52,6 +57,20 @@ export const statesExamples = { ] }; +export const sizeExamples = { + type: 'code', + tabs: [ + { + name: 'Large (default)', + code: `` + }, + { + name: 'Small', + code: `` + } + ] +}; + export const groupDemo = { type: 'code', code: ` @@ -73,6 +92,25 @@ export const groupDemo = { ` }; +export const groupHorizontalDemo = { + type: 'code', + code: ` + + + + + + + + + + + + + +` +}; + export const groupDisabledDemo = { type: 'code', code: ` diff --git a/apps/www/src/content/docs/components/checkbox/index.mdx b/apps/www/src/content/docs/components/checkbox/index.mdx index df9b952fd..6aed9fa7a 100644 --- a/apps/www/src/content/docs/components/checkbox/index.mdx +++ b/apps/www/src/content/docs/components/checkbox/index.mdx @@ -4,7 +4,7 @@ description: Checkbox is a user interface control that enables users to toggle b source: packages/raystack/components/checkbox --- -import { playground, statesExamples, groupDemo, groupDisabledDemo, parentDemo } from "./demo.ts"; +import { playground, statesExamples, sizeExamples, groupDemo, groupHorizontalDemo, groupDisabledDemo, parentDemo } from "./demo.ts"; @@ -55,12 +55,24 @@ The Checkbox component supports multiple states to represent different selection +### Size Variants + +The Checkbox component comes in two sizes: `large` (default) and `small`. + + + ### Group Use `Checkbox.Group` to coordinate multiple checkboxes with shared state. +### Horizontal Group + +Use the `orientation` prop to lay out checkboxes in a horizontal row. + + + ### Disabled Group Disable the entire group to prevent user interaction. @@ -80,3 +92,6 @@ Use a parent checkbox with `allValues` to toggle all items at once. - Uses `aria-checked` to indicate state (checked, unchecked, indeterminate) - Associates with labels via `id` and `htmlFor` attributes - Wrap `Checkbox.Group` with `aria-label` or `aria-labelledby` for an accessible group name +- **Disabled state** preserves the visual checked/indeterminate appearance while preventing interaction +- **Read-only state** reduces opacity and changes cursor to indicate non-editable content +- **Invalid state** displays a danger border (or danger background when checked/indeterminate) for form validation feedback diff --git a/apps/www/src/content/docs/components/checkbox/props.ts b/apps/www/src/content/docs/components/checkbox/props.ts index caba71e94..1d40d3887 100644 --- a/apps/www/src/content/docs/components/checkbox/props.ts +++ b/apps/www/src/content/docs/components/checkbox/props.ts @@ -20,10 +20,24 @@ export interface CheckboxProps { indeterminate?: boolean; /** - * When true, prevents the user from interacting with the checkbox + * When true, prevents the user from interacting with the checkbox. + * @defaultValue false */ disabled?: boolean; + /** + * When true, the checkbox is displayed in a read-only state. + * The user cannot change the value but it is still focusable. + * @defaultValue false + */ + readOnly?: boolean; + + /** + * When true, the user must tick the checkbox before submitting a form. + * @defaultValue false + */ + required?: boolean; + /** * Identifies the checkbox within a `Checkbox.Group`. The group uses this value in its `value` array. */ @@ -35,6 +49,20 @@ export interface CheckboxProps { */ parent?: boolean; + /** + * The size of the checkbox. + * @defaultValue 'large' + */ + size?: 'small' | 'large'; + + /** + * Custom render function for the indicator. Receives `(props, state)` where state includes `checked` and `indeterminate`. + */ + render?: ( + props: React.HTMLAttributes, + state: { checked: boolean; indeterminate: boolean } + ) => React.ReactNode; + /** Additional CSS class name. */ className?: string; } @@ -60,6 +88,12 @@ export interface CheckboxGroupProps { */ disabled?: boolean; + /** + * Layout direction of the checkbox group. + * @defaultValue 'vertical' + */ + orientation?: 'vertical' | 'horizontal'; + /** Additional CSS class name. */ className?: string; } diff --git a/packages/raystack/components/checkbox/__tests__/checkbox-group.test.tsx b/packages/raystack/components/checkbox/__tests__/checkbox-group.test.tsx index 3d6e5468c..609e97b63 100644 --- a/packages/raystack/components/checkbox/__tests__/checkbox-group.test.tsx +++ b/packages/raystack/components/checkbox/__tests__/checkbox-group.test.tsx @@ -63,6 +63,44 @@ describe('Checkbox.Group', () => { }); }); + describe('Orientation', () => { + it('renders vertical by default', () => { + render( + + + + + ); + const group = screen.getByTestId('group'); + expect(group).toHaveClass(styles.group); + expect(group).not.toHaveClass(styles['group-horizontal']); + }); + + it('renders vertical when orientation="vertical"', () => { + render( + + + + + ); + const group = screen.getByTestId('group'); + expect(group).toHaveClass(styles.group); + expect(group).not.toHaveClass(styles['group-horizontal']); + }); + + it('renders horizontal when orientation="horizontal"', () => { + render( + + + + + ); + const group = screen.getByTestId('group'); + expect(group).toHaveClass(styles.group); + expect(group).toHaveClass(styles['group-horizontal']); + }); + }); + describe('Selection Behavior', () => { it('works with defaultValue', () => { render( diff --git a/packages/raystack/components/checkbox/__tests__/checkbox.test.tsx b/packages/raystack/components/checkbox/__tests__/checkbox.test.tsx index eec6d6147..cb4fb60f9 100644 --- a/packages/raystack/components/checkbox/__tests__/checkbox.test.tsx +++ b/packages/raystack/components/checkbox/__tests__/checkbox.test.tsx @@ -31,6 +31,23 @@ describe('Checkbox', () => { }); }); + describe('Sizes', () => { + const sizes = ['small', 'large'] as const; + sizes.forEach(size => { + it(`renders ${size} size`, () => { + render(); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toHaveClass(styles[size]); + }); + }); + + it('renders large size by default', () => { + render(); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toHaveClass(styles.large); + }); + }); + describe('Checked State', () => { it('renders unchecked by default', () => { render(); @@ -133,6 +150,52 @@ describe('Checkbox', () => { }); }); + describe('Custom Indicator (render prop)', () => { + it('renders custom indicator via render prop', () => { + render( + ( + + {state.checked ? 'checked' : 'unchecked'} + + )} + /> + ); + expect(screen.getByTestId('custom-indicator')).toHaveTextContent( + 'checked' + ); + }); + + it('receives indeterminate state in render prop', () => { + render( + ( + + {state.indeterminate ? 'mixed' : 'clear'} + + )} + /> + ); + expect(screen.getByTestId('custom-indicator')).toHaveTextContent('mixed'); + }); + + it('renders default check icon when no render prop provided', () => { + const { container } = render(); + const indicator = container.querySelector(`.${styles.indicator}`); + const svg = indicator?.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + + it('renders default indeterminate icon when no render prop provided', () => { + const { container } = render(); + const indicator = container.querySelector(`.${styles.indicator}`); + const svg = indicator?.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + }); + describe('Event Handling', () => { it('calls onCheckedChange when clicked', () => { const handleChange = vi.fn(); @@ -232,5 +295,11 @@ describe('Checkbox', () => { const checkbox = screen.getByRole('checkbox'); expect(checkbox).toHaveAttribute('aria-invalid', 'true'); }); + + it('supports required prop', () => { + render(); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toHaveAttribute('data-required'); + }); }); }); diff --git a/packages/raystack/components/checkbox/checkbox.module.css b/packages/raystack/components/checkbox/checkbox.module.css index d4b538a99..a12fe9aa8 100644 --- a/packages/raystack/components/checkbox/checkbox.module.css +++ b/packages/raystack/components/checkbox/checkbox.module.css @@ -4,16 +4,17 @@ gap: var(--rs-space-3); } +.group-horizontal { + flex-direction: row; + flex-wrap: wrap; +} + .checkbox { all: unset; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; - width: var(--rs-space-5); - height: var(--rs-space-5); - min-width: var(--rs-space-5); - min-height: var(--rs-space-5); border-radius: var(--rs-radius-1); background: var(--rs-color-background-base-primary); border: 1px solid var(--rs-color-border-base-secondary); @@ -21,6 +22,21 @@ flex-shrink: 0; } +/* Size variants */ +.large { + width: var(--rs-space-5); + height: var(--rs-space-5); + min-width: var(--rs-space-5); + min-height: var(--rs-space-5); +} + +.small { + width: var(--rs-space-4); + height: var(--rs-space-4); + min-width: var(--rs-space-4); + min-height: var(--rs-space-4); +} + .checkbox:hover { background: var(--rs-color-background-base-primary-hover); border-color: var(--rs-color-border-base-focus); @@ -41,18 +57,48 @@ border: none; } +/* A5: Indeterminate hover visual feedback */ .checkbox[data-indeterminate]:hover { - background: var(--rs-color-background-neutral-tertiary); + background: var(--rs-color-background-neutral-secondary); border: none; } +/* A2: Read-only styling */ +.checkbox[data-readonly] { + cursor: default; + opacity: 0.7; +} + +/* A3: Invalid state — red border for validation errors */ +.checkbox[data-invalid] { + border-color: var(--rs-color-border-danger-primary); +} + +.checkbox[data-invalid][data-checked], +.checkbox[data-invalid][data-indeterminate] { + background: var(--rs-color-background-danger-primary); + border-color: var(--rs-color-border-danger-primary); +} + +/* A4: Disabled state */ .checkbox[data-disabled] { opacity: 0.5; + cursor: not-allowed; + pointer-events: none; } -.checkbox[data-disabled]:hover { - background: var(--rs-color-background-base-primary); - border-color: var(--rs-color-border-base-primary); +/* A4: Preserve checked state when disabled */ +.checkbox[data-disabled][data-checked], +.checkbox[data-disabled][data-checked]:hover { + background: var(--rs-color-background-accent-emphasis); + border: none; +} + +/* A4: Preserve indeterminate state when disabled */ +.checkbox[data-disabled][data-indeterminate], +.checkbox[data-disabled][data-indeterminate]:hover { + background: var(--rs-color-background-neutral-tertiary); + border: none; } .indicator { @@ -65,6 +111,6 @@ } .icon { - width: var(--rs-space-5); - height: var(--rs-space-5); -} \ No newline at end of file + width: 100%; + height: 100%; +} diff --git a/packages/raystack/components/checkbox/checkbox.tsx b/packages/raystack/components/checkbox/checkbox.tsx index c8e7dd3d5..d3b9340fe 100644 --- a/packages/raystack/components/checkbox/checkbox.tsx +++ b/packages/raystack/components/checkbox/checkbox.tsx @@ -2,7 +2,7 @@ import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox'; import { CheckboxGroup as CheckboxGroupPrimitive } from '@base-ui/react/checkbox-group'; -import { cx } from 'class-variance-authority'; +import { cva, cx, type VariantProps } from 'class-variance-authority'; import { useFieldContext } from '../field'; import styles from './checkbox.module.css'; @@ -43,10 +43,37 @@ const IndeterminateIcon = () => ( ); -function CheckboxGroup({ className, ...props }: CheckboxGroupPrimitive.Props) { +const checkboxVariants = cva(styles.checkbox, { + variants: { + size: { + small: styles.small, + large: styles.large + } + }, + defaultVariants: { + size: 'large' + } +}); + +interface CheckboxGroupProps extends CheckboxGroupPrimitive.Props { + /** Layout direction of the checkbox group. + * @defaultValue 'vertical' + */ + orientation?: 'vertical' | 'horizontal'; +} + +function CheckboxGroup({ + className, + orientation = 'vertical', + ...props +}: CheckboxGroupProps) { return ( ); @@ -54,27 +81,36 @@ function CheckboxGroup({ className, ...props }: CheckboxGroupPrimitive.Props) { CheckboxGroup.displayName = 'Checkbox.Group'; +interface CheckboxItemProps + extends CheckboxPrimitive.Root.Props, + VariantProps {} + function CheckboxItem({ className, required, + size, + render, ...props -}: CheckboxPrimitive.Root.Props) { +}: CheckboxItemProps) { const fieldContext = useFieldContext(); const resolvedRequired = required ?? fieldContext?.required; return ( ( - - {state.indeterminate ? : } - - )} + render={ + render ?? + ((props, state) => ( + + {state.indeterminate ? : } + + )) + } /> );