Skip to content
Merged
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
15 changes: 10 additions & 5 deletions apps/www/src/content/docs/components/checkbox/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@ export const playground = {
type: 'playground',
controls: {
checked: {
type: 'select',
options: ['true', 'false', 'indeterminate'],
initialValue: 'true'
type: 'checkbox',
initialValue: false,
defaultValue: false
},
indeterminate: {
type: 'checkbox',
initialValue: false,
defaultValue: false
},
disabled: {
type: 'checkbox',
Expand All @@ -34,11 +39,11 @@ export const statesExamples = {
},
{
name: 'Checked',
code: `<Checkbox checked={true} />`
code: `<Checkbox checked/>`
},
{
name: 'Indeterminate',
code: `<Checkbox checked="indeterminate" />`
code: `<Checkbox indeterminate />`
},
{
name: 'Disabled',
Expand Down
11 changes: 8 additions & 3 deletions apps/www/src/content/docs/components/checkbox/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,22 @@ export interface CheckboxProps {
/**
* The controlled state of the checkbox
*/
checked?: boolean | 'indeterminate';
checked?: boolean;

/**
* The default state when initially rendered
*/
defaultChecked?: boolean | 'indeterminate';
defaultChecked?: boolean;

/**
* Event handler called when the state changes
*/
onCheckedChange?: (checked: boolean | 'indeterminate') => void;
onCheckedChange?: (checked: boolean) => void;

/**
* When true, the checkbox is in an indeterminate state
*/
indeterminate?: boolean;

/**
* When true, prevents the user from interacting with the checkbox
Expand Down
49 changes: 23 additions & 26 deletions packages/raystack/components/checkbox/__tests__/checkbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,42 +73,39 @@ describe('Checkbox', () => {
});

describe('Indeterminate State', () => {
it('applies indeterminate class', () => {
render(<Checkbox checked='indeterminate' />);
it('has data-indeterminate attribute when indeterminate', () => {
render(<Checkbox indeterminate />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toHaveClass(styles['checkbox-indeterminate']);
expect(checkbox).toHaveAttribute('data-indeterminate');
});

it('renders with defaultChecked as indeterminate', () => {
render(<Checkbox defaultChecked='indeterminate' />);
it('can be both checked and indeterminate', () => {
render(<Checkbox checked indeterminate />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toHaveClass(styles['checkbox-indeterminate']);
expect(checkbox).toHaveAttribute('aria-checked', 'mixed');
expect(checkbox).toHaveAttribute('data-indeterminate');
});

it('transitions from indeterminate to unchecked on click', () => {
const handleChange = vi.fn();
render(
<Checkbox checked='indeterminate' onCheckedChange={handleChange} />
);

const checkbox = screen.getByRole('checkbox');
fireEvent.click(checkbox);

expect(handleChange).toHaveBeenCalledWith(false);
it('shows indeterminate icon when indeterminate', () => {
const { container } = render(<Checkbox indeterminate />);
const indicator = container.querySelector(`.${styles.indicator}`);
expect(indicator).toBeInTheDocument();
const svg = indicator?.querySelector('svg');
expect(svg).toBeInTheDocument();
});
});

describe('Disabled State', () => {
it('renders as disabled when disabled prop is true', () => {
render(<Checkbox disabled />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeDisabled();
expect(checkbox).toHaveAttribute('aria-disabled', 'true');
});

it('applies disabled class', () => {
it('has data-disabled attribute when disabled', () => {
render(<Checkbox disabled />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toHaveClass(styles['checkbox-disabled']);
expect(checkbox).toHaveAttribute('data-disabled');
});

it('does not trigger onCheckedChange when disabled', () => {
Expand All @@ -124,15 +121,15 @@ describe('Checkbox', () => {
it('can be disabled while checked', () => {
render(<Checkbox disabled checked />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeDisabled();
expect(checkbox).toHaveAttribute('aria-disabled', 'true');
expect(checkbox).toBeChecked();
});

it('can be disabled while indeterminate', () => {
render(<Checkbox disabled checked='indeterminate' />);
render(<Checkbox disabled indeterminate />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeDisabled();
expect(checkbox).toHaveClass(styles['checkbox-indeterminate']);
expect(checkbox).toHaveAttribute('aria-disabled', 'true');
expect(checkbox).toHaveAttribute('data-indeterminate');
});
});

Expand All @@ -145,7 +142,7 @@ describe('Checkbox', () => {
fireEvent.click(checkbox);

expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChange).toHaveBeenCalledWith(true);
expect(handleChange).toHaveBeenCalledWith(true, expect.anything());
});

it('toggles from unchecked to checked', () => {
Expand All @@ -155,7 +152,7 @@ describe('Checkbox', () => {
const checkbox = screen.getByRole('checkbox');
fireEvent.click(checkbox);

expect(handleChange).toHaveBeenCalledWith(true);
expect(handleChange).toHaveBeenCalledWith(true, expect.anything());
});

it('toggles from checked to unchecked', () => {
Expand All @@ -165,7 +162,7 @@ describe('Checkbox', () => {
const checkbox = screen.getByRole('checkbox');
fireEvent.click(checkbox);

expect(handleChange).toHaveBeenCalledWith(false);
expect(handleChange).toHaveBeenCalledWith(false, expect.anything());
});

it('supports focus events', () => {
Expand Down
125 changes: 62 additions & 63 deletions packages/raystack/components/checkbox/checkbox.module.css
Original file line number Diff line number Diff line change
@@ -1,66 +1,65 @@
.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);
cursor: pointer;
flex-shrink: 0;
}

.checkbox:hover {
background: var(--rs-color-background-base-primary-hover);
border-color: var(--rs-color-border-base-focus);
}

.checkbox[data-state="checked"] {
background: var(--rs-color-background-accent-emphasis);
border: none;
}

.checkbox[data-state="checked"]:hover {
background: var(--rs-color-background-accent-emphasis-hover);
}
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);
cursor: pointer;
flex-shrink: 0;
}

/* Indeterminate state */
.checkbox-indeterminate[data-state="checked"] {
background: var(--rs-color-background-neutral-tertiary);
border: none;
}
.checkbox:hover {
background: var(--rs-color-background-base-primary-hover);
border-color: var(--rs-color-border-base-focus);
}

.checkbox-indeterminate[data-state="checked"]:hover {
background: var(--rs-color-background-neutral-tertiary);
border: none;
}

.checkbox-disabled {
opacity: 0.5;
cursor: not-allowed;
}

.checkbox-disabled:hover {
background: var(--rs-color-background-base-primary);
border-color: var(--rs-color-border-base-primary);
}

.indicator {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--rs-color-foreground-accent-emphasis);
}

.icon {
width: var(--rs-space-5);
height: var(--rs-space-5);
}

.checkbox[data-checked] {
background: var(--rs-color-background-accent-emphasis);
border: none;
}

.checkbox[data-checked]:hover {
background: var(--rs-color-background-accent-emphasis-hover);
}

/* Indeterminate state */
.checkbox[data-indeterminate] {
background: var(--rs-color-background-neutral-tertiary);
border: none;
}

.checkbox[data-indeterminate]:hover {
background: var(--rs-color-background-neutral-tertiary);
border: none;
}

.checkbox[data-disabled] {
opacity: 0.5;
cursor: not-allowed;
}

.checkbox[data-disabled]:hover {
background: var(--rs-color-background-base-primary);
border-color: var(--rs-color-border-base-primary);
}

.indicator {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--rs-color-foreground-accent-emphasis);
}

.icon {
width: var(--rs-space-5);
height: var(--rs-space-5);
}
77 changes: 18 additions & 59 deletions packages/raystack/components/checkbox/checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use client';

import { VariantProps, cva, cx } from 'class-variance-authority';
import { Checkbox as CheckboxPrimitive } from 'radix-ui';
import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react';
import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox';
import { cx } from 'class-variance-authority';
import { ElementRef, forwardRef } from 'react';

import styles from './checkbox.module.css';

Expand Down Expand Up @@ -42,63 +42,22 @@ const IndeterminateIcon = () => (
</svg>
);

const checkbox = cva(styles.checkbox);

type CheckboxVariants = VariantProps<typeof checkbox>;
type CheckedState = boolean | 'indeterminate';

export interface CheckboxProps
extends Omit<
ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>,
keyof CheckboxVariants
>,
CheckboxVariants {
checked?: CheckedState;
defaultChecked?: CheckedState;
onCheckedChange?: (checked: CheckedState) => void;
}

export const Checkbox = forwardRef<
ElementRef<typeof CheckboxPrimitive.Root>,
CheckboxProps
>(
(
{ className, disabled, checked, defaultChecked, onCheckedChange, ...props },
forwardedRef
) => {
const isIndeterminate =
checked === 'indeterminate' || defaultChecked === 'indeterminate';

return (
<CheckboxPrimitive.Root
className={checkbox({
className: cx(className, {
[styles['checkbox-disabled']]: disabled,
[styles['checkbox-indeterminate']]: isIndeterminate
})
})}
checked={isIndeterminate || checked === true}
defaultChecked={defaultChecked === true}
onCheckedChange={value => {
if (onCheckedChange) {
// If it's currently indeterminate, next state will be unchecked
if (checked === 'indeterminate') {
onCheckedChange(false);
} else {
onCheckedChange(value);
}
}
}}
disabled={disabled}
ref={forwardedRef}
{...props}
>
<CheckboxPrimitive.Indicator className={styles.indicator}>
{isIndeterminate ? <IndeterminateIcon /> : <CheckMarkIcon />}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
);
CheckboxPrimitive.Root.Props
>(({ className, indeterminate, ...props }, ref) => {
return (
<CheckboxPrimitive.Root
className={cx(styles.checkbox, className)}
indeterminate={indeterminate}
ref={ref}
{...props}
>
<CheckboxPrimitive.Indicator className={styles.indicator}>
{indeterminate ? <IndeterminateIcon /> : <CheckMarkIcon />}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
});

Checkbox.displayName = 'Checkbox';
2 changes: 1 addition & 1 deletion packages/raystack/components/checkbox/index.tsx
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { Checkbox } from "./checkbox";
export { Checkbox } from './checkbox';
Loading