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
5 changes: 5 additions & 0 deletions .changeset/migrate-checkbox-to-css-modules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clickhouse/click-ui': patch
---

Migrate Checkbox from styled-components to css modules with no change in behavior
116 changes: 116 additions & 0 deletions src/components/Checkbox/Checkbox.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/* The wrapper class is applied alongside FormRoot's styled-components class.
The .wrapper.wrapper double-class boost matches the specificity behavior of
the original `styled(FormRoot)` chain so these overrides reliably beat
FormRoot's `align-items: flex-start` regardless of stylesheet insertion
order between CSS Modules and the runtime-injected styled-components. */
.wrapper.wrapper {
/* stylelint-disable-next-line plugin/no-unsupported-browser-features -- `fit-content`
keyword on `max-width` is widely supported on evergreen browsers; the original
styled-components rule used the same value. */
max-width: fit-content;
align-items: center;
}

.checkinput {
display: flex;
width: var(--click-checkbox-size-all);
height: var(--click-checkbox-size-all);
flex-shrink: 0;
justify-content: center;
align-items: center;
border: 1px solid var(--checkbox-stroke-default);
border-radius: var(--click-checkbox-radii-all);
background: var(--checkbox-bg-default);
cursor: pointer;
}

.checkinput_variant_default {
--checkbox-bg-default: var(--click-checkbox-color-variations-default-background-default);
--checkbox-bg-hover: var(--click-checkbox-color-variations-default-background-hover);
--checkbox-bg-active: var(--click-checkbox-color-variations-default-background-active);
--checkbox-stroke-default: var(--click-checkbox-color-variations-default-stroke-default);
--checkbox-stroke-active: var(--click-checkbox-color-variations-default-stroke-active);
}

.checkinput_variant_var1 {
--checkbox-bg-default: var(--click-checkbox-color-variations-var1-background-default);
--checkbox-bg-hover: var(--click-checkbox-color-variations-var1-background-hover);
--checkbox-bg-active: var(--click-checkbox-color-variations-var1-background-active);
--checkbox-stroke-default: var(--click-checkbox-color-variations-var1-stroke-default);
--checkbox-stroke-active: var(--click-checkbox-color-variations-var1-stroke-active);
}

.checkinput_variant_var2 {
--checkbox-bg-default: var(--click-checkbox-color-variations-var2-background-default);
--checkbox-bg-hover: var(--click-checkbox-color-variations-var2-background-hover);
--checkbox-bg-active: var(--click-checkbox-color-variations-var2-background-active);
--checkbox-stroke-default: var(--click-checkbox-color-variations-var2-stroke-default);
--checkbox-stroke-active: var(--click-checkbox-color-variations-var2-stroke-active);
}

.checkinput_variant_var3 {
--checkbox-bg-default: var(--click-checkbox-color-variations-var3-background-default);
--checkbox-bg-hover: var(--click-checkbox-color-variations-var3-background-hover);
--checkbox-bg-active: var(--click-checkbox-color-variations-var3-background-active);
--checkbox-stroke-default: var(--click-checkbox-color-variations-var3-stroke-default);
--checkbox-stroke-active: var(--click-checkbox-color-variations-var3-stroke-active);
}

.checkinput_variant_var4 {
--checkbox-bg-default: var(--click-checkbox-color-variations-var4-background-default);
--checkbox-bg-hover: var(--click-checkbox-color-variations-var4-background-hover);
--checkbox-bg-active: var(--click-checkbox-color-variations-var4-background-active);
--checkbox-stroke-default: var(--click-checkbox-color-variations-var4-stroke-default);
--checkbox-stroke-active: var(--click-checkbox-color-variations-var4-stroke-active);
}

.checkinput_variant_var5 {
--checkbox-bg-default: var(--click-checkbox-color-variations-var5-background-default);
--checkbox-bg-hover: var(--click-checkbox-color-variations-var5-background-hover);
--checkbox-bg-active: var(--click-checkbox-color-variations-var5-background-active);
--checkbox-stroke-default: var(--click-checkbox-color-variations-var5-stroke-default);
--checkbox-stroke-active: var(--click-checkbox-color-variations-var5-stroke-active);
}

.checkinput_variant_var6 {
--checkbox-bg-default: var(--click-checkbox-color-variations-var6-background-default);
--checkbox-bg-hover: var(--click-checkbox-color-variations-var6-background-hover);
--checkbox-bg-active: var(--click-checkbox-color-variations-var6-background-active);
--checkbox-stroke-default: var(--click-checkbox-color-variations-var6-stroke-default);
--checkbox-stroke-active: var(--click-checkbox-color-variations-var6-stroke-active);
}

.checkinput:hover {
background: var(--checkbox-bg-hover);
}

.checkinput[data-state='checked'],
.checkinput[data-state='indeterminate'] {
border-color: var(--checkbox-stroke-active);
background: var(--checkbox-bg-active);
}

/* stylelint-disable no-descending-specificity -- disabled state intentionally
defined after checked/indeterminate to mirror the source cascade order;
matches the styled-components rule which used a nested `&[data-disabled]`
block placed after the state selectors. */
.checkinput[data-disabled] {
border-color: var(--click-checkbox-color-stroke-disabled);
background: var(--click-checkbox-color-background-disabled);
cursor: not-allowed;
}

.checkinput[data-disabled][data-state='checked'],
.checkinput[data-disabled][data-state='indeterminate'] {
border-color: var(--click-checkbox-color-stroke-disabled);
background: var(--click-checkbox-color-background-disabled);
}
/* stylelint-enable no-descending-specificity */

.checkicon {
color: var(--click-checkbox-color-check-active);
}

.checkicon[data-disabled] {
color: var(--click-checkbox-color-check-disabled);
}
115 changes: 114 additions & 1 deletion src/components/Checkbox/Checkbox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,121 @@ export default meta;

type Story = StoryObj<typeof Checkbox>;

const baseArgs = {
label: 'Accept terms and conditions',
};

export const Playground: Story = {
args: baseArgs,
};

export const Default: Story = {
args: baseArgs,
};

export const DefaultChecked: Story = {
args: {
...baseArgs,
checked: true,
},
};

export const DefaultIndeterminate: Story = {
args: {
...baseArgs,
checked: 'indeterminate',
},
};

export const Disabled: Story = {
args: {
...baseArgs,
disabled: true,
},
};

export const DisabledChecked: Story = {
args: {
...baseArgs,
disabled: true,
checked: true,
},
};

export const DisabledIndeterminate: Story = {
args: {
...baseArgs,
disabled: true,
checked: 'indeterminate',
},
};

export const Var1Checked: Story = {
args: {
...baseArgs,
variant: 'var1',
checked: true,
},
};

export const Var2Checked: Story = {
args: {
...baseArgs,
variant: 'var2',
checked: true,
},
};

export const Var3Checked: Story = {
args: {
...baseArgs,
variant: 'var3',
checked: true,
},
};

export const Var4Checked: Story = {
args: {
...baseArgs,
variant: 'var4',
checked: true,
},
};

export const Var5Checked: Story = {
args: {
...baseArgs,
variant: 'var5',
checked: true,
},
};

export const Var6Checked: Story = {
args: {
...baseArgs,
variant: 'var6',
checked: true,
},
};

export const OrientationVerticalDirStart: Story = {
args: {
...baseArgs,
orientation: 'vertical',
dir: 'start',
},
};

export const OrientationHorizontalDirStart: Story = {
args: {
...baseArgs,
orientation: 'horizontal',
dir: 'start',
},
};

export const NoLabel: Story = {
args: {
label: 'Accept terms and conditions',
label: undefined,
},
};
Comment on lines +145 to 149
Copy link
Copy Markdown
Contributor Author

@DreaminDani DreaminDani May 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The NoLabel story is intentionally exercising the current (broken) rendering so the visual-regression snapshot captures it. The underlying aria-label="undefined" bug is a pre-existing styled-components behavior the migration preserves; fixing the story without first fixing the component would change what the snapshot tests. The fix will land separately and the story can be updated alongside it.

8 changes: 6 additions & 2 deletions src/components/Checkbox/Checkbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,12 @@ describe('Checkbox', () => {

const checkbox = getByTestId('checkbox');

const computedStyle = window.getComputedStyle(checkbox);
expect(computedStyle.cursor).toBe('not-allowed');
// The disabled cursor is asserted via the visual-regression spec in
// tests/forms/checkbox.spec.ts. jsdom does not compute styles from
// imported CSS Modules, so we check the semantic disabled state here
// and rely on Playwright snapshots for the rendered `cursor: not-allowed`.
expect(checkbox).toBeDisabled();
expect(checkbox).toHaveAttribute('data-disabled');

fireEvent.click(checkbox);

Expand Down
87 changes: 28 additions & 59 deletions src/components/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,27 @@ import { Icon } from '@/components/Icon';

import * as RadixCheckbox from '@radix-ui/react-checkbox';
import { useId } from 'react';
import { styled } from 'styled-components';
import { FormRoot } from '@/components/FormContainer';
import { CheckboxProps, CheckboxVariants } from './Checkbox.types';
import { cn, cva } from '@/lib/cva';
import styles from './Checkbox.module.css';
import { CheckboxProps } from './Checkbox.types';

const Wrapper = styled(FormRoot)`
align-items: center;
max-width: fit-content;
`;
const checkInputVariants = cva(styles.checkinput, {
variants: {
variant: {
default: styles['checkinput_variant_default'],
var1: styles['checkinput_variant_var1'],
var2: styles['checkinput_variant_var2'],
var3: styles['checkinput_variant_var3'],
var4: styles['checkinput_variant_var4'],
var5: styles['checkinput_variant_var5'],
var6: styles['checkinput_variant_var6'],
},
},
defaultVariants: {
variant: 'default',
},
});

export const Checkbox = ({
id,
Expand All @@ -20,30 +33,32 @@ export const Checkbox = ({
orientation = 'horizontal',
dir = 'end',
checked,
className,
...delegated
}: CheckboxProps) => {
const defaultId = useId();
return (
<Wrapper
<FormRoot
$orientation={orientation}
$dir={dir}
className={styles.wrapper}
>
<CheckInput
<RadixCheckbox.Root
id={id ?? defaultId}
data-testid="checkbox"
variant={variant}
disabled={disabled}
aria-label={`${label}`}
Copy link
Copy Markdown
Contributor Author

@DreaminDani DreaminDani May 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preserving this exactly as-is in the migration: the original styled-components Checkbox had the same aria-label={\${label}`}` template-string. Per the migration project's scope rule, ARIA refinements are kept out of the CSS Modules PR so the visual-regression byte-for-byte guarantee holds. Filed as a follow-up

checked={checked}
{...delegated}
className={cn(checkInputVariants({ variant }), className)}
>
<CheckIconWrapper>
<RadixCheckbox.Indicator className={styles.checkicon}>
<Icon
name={checked === 'indeterminate' ? 'minus' : 'check'}
size="sm"
/>
</CheckIconWrapper>
</CheckInput>
</RadixCheckbox.Indicator>
</RadixCheckbox.Root>
{label && (
<GenericLabel
htmlFor={id ?? defaultId}
Expand All @@ -52,52 +67,6 @@ export const Checkbox = ({
{label}
</GenericLabel>
)}
</Wrapper>
</FormRoot>
);
};

const CheckInput = styled(RadixCheckbox.Root)<{
variant: CheckboxVariants;
}>`
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;

${({ theme, variant }) => `
border-radius: ${theme.click.checkbox.radii.all};
width: ${theme.click.checkbox.size.all};
height: ${theme.click.checkbox.size.all};
background: ${theme.click.checkbox.color.variations[variant].background.default};
border: 1px solid ${theme.click.checkbox.color.variations[variant].stroke.default};
cursor: pointer;

&:hover {
background: ${theme.click.checkbox.color.variations[variant].background.hover};
}
&[data-state="checked"],
&[data-state="indeterminate"] {
border-color: ${theme.click.checkbox.color.variations[variant].stroke.active};
background: ${theme.click.checkbox.color.variations[variant].background.active};
}
&[data-disabled] {
background: ${theme.click.checkbox.color.background.disabled};
border-color: ${theme.click.checkbox.color.stroke.disabled};
cursor: not-allowed;
&[data-state="checked"],
&[data-state="indeterminate"] {
background: ${theme.click.checkbox.color.background.disabled};
border-color: ${theme.click.checkbox.color.stroke.disabled};
}
}
`};
`;

const CheckIconWrapper = styled(RadixCheckbox.Indicator)`
${({ theme }) => `
color: ${theme.click.checkbox.color.check.active};
&[data-disabled] {
color: ${theme.click.checkbox.color.check.disabled};
}
`}
`;
Loading
Loading