Skip to content

Commit d269640

Browse files
committed
feat: indeterminate checkbox
1 parent 1d50ded commit d269640

File tree

13 files changed

+125
-55
lines changed

13 files changed

+125
-55
lines changed

docs/registry/ui/checkbox.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import IconCheckmarkFatFill from "@daangn/react-monochrome-icon/IconCheckmarkFatFill";
4+
import IconMinusFatFill from "@daangn/react-monochrome-icon/IconMinusFatFill";
45
import { Checkbox as SeedCheckbox } from "@seed-design/react";
56
import * as React from "react";
67

@@ -20,6 +21,7 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
2021
<SeedCheckbox.Root ref={rootRef} {...otherProps}>
2122
<SeedCheckbox.Control>
2223
<SeedCheckbox.CheckedIcon svg={<IconCheckmarkFatFill />} />
24+
<SeedCheckbox.IndeterminateIcon svg={<IconMinusFatFill />} />
2325
</SeedCheckbox.Control>
2426
<SeedCheckbox.Label>{label}</SeedCheckbox.Label>
2527
<SeedCheckbox.HiddenInput ref={ref} {...inputProps} />

docs/stories/Checkbox.stories.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,36 @@ export default meta;
1515

1616
type Story = StoryObj<typeof meta>;
1717

18+
const conditionMap = {
19+
indeterminate: {
20+
false: {
21+
indeterminate: false,
22+
},
23+
true: {
24+
indeterminate: true,
25+
},
26+
},
27+
checked: {
28+
false: {
29+
checked: false,
30+
},
31+
true: {
32+
checked: true,
33+
},
34+
},
35+
};
36+
1837
const CommonStoryTemplate: Story = {
1938
args: {
2039
label: "Checkbox",
21-
checked: true,
2240
},
2341
render: (args) => (
24-
<VariantTable Component={meta.component} variantMap={checkboxVariantMap} {...args} />
42+
<VariantTable
43+
Component={meta.component}
44+
variantMap={checkboxVariantMap}
45+
conditionMap={conditionMap}
46+
{...args}
47+
/>
2548
),
2649
};
2750

packages/qvism-preset/src/checkbox.recipe.ts

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { checkbox as vars } from "@seed-design/vars/component";
22
import { defineRecipe } from "./helper";
3-
import { active, checked, disabled, pseudo, not } from "./pseudo";
3+
import { active, checkedOrIndeterminate, disabled, pseudo } from "./pseudo";
44

55
const checkbox = defineRecipe({
66
name: "checkbox",
@@ -56,25 +56,21 @@ const checkbox = defineRecipe({
5656
},
5757
},
5858
},
59-
indeterminate: {
60-
true: {},
61-
false: {},
62-
},
6359
variant: {
6460
square: {
6561
control: {
6662
borderWidth: vars.variantSquare.enabled.control.strokeWidth,
6763
borderStyle: "solid",
6864
borderColor: vars.variantSquare.enabled.control.strokeColor,
6965

70-
[pseudo(checked)]: {
66+
[pseudo(checkedOrIndeterminate)]: {
7167
background: vars.variantSquare.enabledSelected.control.color,
7268
borderWidth: 0,
7369
},
7470
[pseudo(active)]: {
7571
background: vars.variantSquare.pressed.control.color,
7672
},
77-
[pseudo(active, checked)]: {
73+
[pseudo(active, checkedOrIndeterminate)]: {
7874
background: vars.variantSquare.pressedSelected.control.color,
7975
},
8076
[pseudo(disabled)]: {
@@ -86,11 +82,11 @@ const checkbox = defineRecipe({
8682
},
8783
},
8884
icon: {
89-
[pseudo(checked)]: {
85+
[pseudo(checkedOrIndeterminate)]: {
9086
display: "block",
9187
color: vars.variantSquare.enabledSelected.icon.color,
9288
},
93-
[pseudo(disabled, checked)]: {
89+
[pseudo(disabled, checkedOrIndeterminate)]: {
9490
display: "block",
9591
color: vars.variantSquare.disabledSelected.icon.color,
9692
},
@@ -105,16 +101,16 @@ const checkbox = defineRecipe({
105101
control: {
106102
background: "none",
107103

108-
[pseudo(checked)]: {
104+
[pseudo(checkedOrIndeterminate)]: {
109105
background: "none",
110106
},
111107
[pseudo(active)]: {
112108
background: vars.variantGhost.pressed.control.color,
113109
},
114-
[pseudo(active, checked)]: {
110+
[pseudo(active, checkedOrIndeterminate)]: {
115111
background: vars.variantGhost.pressedSelected.control.color,
116112
},
117-
[pseudo(disabled, checked)]: {
113+
[pseudo(disabled, checkedOrIndeterminate)]: {
118114
background: "none",
119115
},
120116
[pseudo(disabled, active)]: {
@@ -125,10 +121,10 @@ const checkbox = defineRecipe({
125121
display: "block",
126122
color: vars.variantGhost.enabled.icon.color,
127123

128-
[pseudo(checked)]: {
124+
[pseudo(checkedOrIndeterminate)]: {
129125
color: vars.variantGhost.enabledSelected.icon.color,
130126
},
131-
[pseudo(disabled, checked)]: {
127+
[pseudo(disabled, checkedOrIndeterminate)]: {
132128
color: vars.variantGhost.disabledSelected.icon.color,
133129
},
134130
[pseudo(disabled)]: {
@@ -259,7 +255,6 @@ const checkbox = defineRecipe({
259255
size: "medium",
260256
variant: "square",
261257
weight: "default",
262-
indeterminate: false,
263258
},
264259
});
265260

packages/qvism-preset/src/pseudo.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ export const readOnly = ":is([data-readonly])";
1010

1111
export const checked = ":is(:checked, [data-checked])";
1212

13+
export const checkedOrIndeterminate =
14+
":is(:checked, :indeterminate, [data-checked], [data-indeterminate])";
15+
1316
export const pressed = ":is([aria-pressed=true], [data-pressed])";
1417

1518
export const selected = ":is(:selected, [data-selected])";
@@ -20,19 +23,16 @@ export const invalid = ":is(:invalid, [data-invalid])";
2023

2124
export const loading = "[data-loading]";
2225

23-
type JoinRest<Rest extends string[]> = Rest extends []
24-
? ""
25-
: Rest extends [string, ...string[]]
26-
? `${Rest[0]}${Rest extends [string, ...infer R extends string[]] ? R[0] : ""}`
27-
: "";
28-
29-
type JoinSelectors<T extends string[]> = T extends [string, string, ...infer Rest extends string[]]
30-
? `&${T[0]}${T[1]}${JoinRest<Rest>}`
31-
: never;
26+
type ConcatStrings<T extends string[]> = T extends [
27+
infer First extends string,
28+
...infer Rest extends string[],
29+
]
30+
? `${First}${ConcatStrings<Rest>}`
31+
: "";
3232

3333
export function pseudo<T extends string>(selectorA: T): `&${T}`;
3434
export function pseudo<T extends string, U extends string>(selectorA: T, selectorB: U): `&${T}${U}`;
35-
export function pseudo<T extends string[]>(...selectors: [...T]): JoinSelectors<T>;
35+
export function pseudo<T extends string[]>(...selectors: [...T]): `&${ConcatStrings<T>}`;
3636
export function pseudo(...selectors: string[]) {
3737
return `&${selectors.join("")}`;
3838
}

packages/react-headless/checkbox/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"build": "nanobundle build"
2727
},
2828
"dependencies": {
29+
"@radix-ui/react-compose-refs": "^1.1.1",
2930
"@radix-ui/react-use-controllable-state": "1.0.1",
3031
"@seed-design/dom-utils": "0.0.0-alpha-20241030023710",
3132
"@seed-design/react-primitive": "0.0.0"

packages/react-headless/checkbox/src/Checkbox.tsx

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,32 @@ import type * as React from "react";
44
import { forwardRef } from "react";
55
import { useCheckbox, type UseCheckboxProps } from "./useCheckbox";
66
import { CheckboxProvider, useCheckboxContext } from "./useCheckboxContext";
7+
import { composeRefs } from "@radix-ui/react-compose-refs";
78

89
export interface CheckboxRootProps
910
extends UseCheckboxProps,
1011
PrimitiveProps,
1112
React.HTMLAttributes<HTMLLabelElement> {}
1213

1314
export const CheckboxRoot = forwardRef<HTMLLabelElement, CheckboxRootProps>((props, ref) => {
14-
const { checked, defaultChecked, disabled, invalid, onCheckedChange, required, ...otherProps } =
15-
props;
15+
const {
16+
checked,
17+
defaultChecked,
18+
onCheckedChange,
19+
indeterminate,
20+
disabled,
21+
invalid,
22+
required,
23+
...otherProps
24+
} = props;
1625

1726
const api = useCheckbox({
1827
checked,
1928
defaultChecked,
29+
onCheckedChange,
30+
indeterminate,
2031
disabled,
2132
invalid,
22-
onCheckedChange,
2333
required,
2434
});
2535
const mergedProps = mergeProps(api.rootProps, otherProps);
@@ -49,9 +59,9 @@ export interface CheckboxHiddenInputProps
4959

5060
export const CheckboxHiddenInput = forwardRef<HTMLInputElement, CheckboxHiddenInputProps>(
5161
(props, ref) => {
52-
const { hiddenInputProps } = useCheckboxContext();
62+
const { refs, hiddenInputProps } = useCheckboxContext();
5363
const mergedProps = mergeProps(hiddenInputProps, props);
54-
return <Primitive.input ref={ref} {...mergedProps} />;
64+
return <Primitive.input ref={composeRefs(refs.input, ref)} {...mergedProps} />;
5565
},
5666
);
5767
CheckboxHiddenInput.displayName = "CheckboxHiddenInput";

packages/react-headless/checkbox/src/useCheckbox.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useControllableState } from "@radix-ui/react-use-controllable-state";
2-
import { useState } from "react";
2+
import { useEffect, useRef, useState } from "react";
33

44
import {
55
dataAttr,
@@ -15,10 +15,12 @@ interface UseCheckboxStateProps {
1515
defaultChecked?: boolean;
1616

1717
onCheckedChange?: (checked: boolean) => void;
18+
19+
indeterminate?: boolean;
1820
}
1921

2022
function useCheckboxState(props: UseCheckboxStateProps) {
21-
const [isChecked, setIsChecked] = useControllableState({
23+
const [isChecked = false, setIsChecked] = useControllableState({
2224
prop: props.checked,
2325
defaultProp: props.defaultChecked,
2426
onChange: props.onCheckedChange,
@@ -28,7 +30,26 @@ function useCheckboxState(props: UseCheckboxStateProps) {
2830
const [isFocused, setIsFocused] = useState(false);
2931
const [isFocusVisible, setIsFocusVisible] = useState(false);
3032

33+
const inputRef = useRef<HTMLInputElement>(null);
34+
const initialCheckedRef = useRef(isChecked);
35+
36+
useEffect(() => {
37+
const form = inputRef.current?.form;
38+
if (form) {
39+
const reset = () => setIsChecked(initialCheckedRef.current);
40+
form.addEventListener("reset", reset);
41+
return () => form.removeEventListener("reset", reset);
42+
}
43+
}, [setIsChecked]);
44+
45+
useEffect(() => {
46+
if (!inputRef.current) return;
47+
inputRef.current.indeterminate = props.indeterminate ?? false;
48+
}, [props.indeterminate]);
49+
3150
return {
51+
refs: { input: inputRef },
52+
isIndeterminate: props.indeterminate ?? false,
3253
isChecked,
3354
setIsChecked,
3455
isHovered,
@@ -56,6 +77,8 @@ export function useCheckbox(props: UseCheckboxProps) {
5677
const { checked, disabled, invalid, required } = props;
5778

5879
const {
80+
refs,
81+
isIndeterminate,
5982
setIsChecked,
6083
isChecked,
6184
setIsHovered,
@@ -70,6 +93,7 @@ export function useCheckbox(props: UseCheckboxProps) {
7093

7194
const stateProps = elementProps({
7295
"data-checked": dataAttr(isChecked),
96+
"data-indeterminate": dataAttr(isIndeterminate),
7397
"data-hover": dataAttr(isHovered),
7498
"data-active": dataAttr(isActive),
7599
"data-focus": dataAttr(isFocused),
@@ -82,12 +106,15 @@ export function useCheckbox(props: UseCheckboxProps) {
82106
const isControlled = checked != null;
83107

84108
return {
109+
isIndeterminate,
85110
isChecked,
86111
setIsChecked,
87112
isFocused,
88113
setIsFocused,
114+
isFocusVisible,
89115
setIsFocusVisible,
90116

117+
refs,
91118
stateProps,
92119
rootProps: labelProps({
93120
...stateProps,

packages/react/src/components/Checkbox/Checkbox.namespace.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ export {
22
CheckboxControl as Control,
33
CheckboxHiddenInput as HiddenInput,
44
CheckboxCheckedIcon as CheckedIcon,
5+
CheckboxIndeterminateIcon as IndeterminateIcon,
56
CheckboxLabel as Label,
67
CheckboxRoot as Root,
78
type CheckboxControlProps as ControlProps,
89
type CheckboxHiddenInputProps as HiddenInputProps,
910
type CheckboxCheckedIconProps as CheckedIconProps,
11+
type CheckboxIndeterminateIconProps as IndeterminateIconProps,
1012
type CheckboxLabelProps as LabelProps,
1113
type CheckboxRootProps as RootProps,
1214
} from "./Checkbox";

packages/react/src/components/Checkbox/Checkbox.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,27 @@ export interface CheckboxCheckedIconProps extends IconProps {}
3434

3535
export const CheckboxCheckedIcon = forwardRef<SVGSVGElement, CheckboxCheckedIconProps>(
3636
({ svg }, ref) => {
37-
const { stateProps, isChecked } = useCheckboxContext();
37+
const { stateProps, isChecked, isIndeterminate } = useCheckboxContext();
3838
const classNames = useClassNames();
3939

40-
if (!isChecked) return null;
40+
if (!isChecked || isIndeterminate) return null;
41+
42+
const mergedProps = mergeProps(stateProps, { className: classNames.icon });
43+
44+
return <Icon ref={ref} svg={svg} {...mergedProps} />;
45+
},
46+
);
47+
48+
////////////////////////////////////////////////////////////////////////////////////
49+
50+
export interface CheckboxIndeterminateIconProps extends IconProps {}
51+
52+
export const CheckboxIndeterminateIcon = forwardRef<SVGSVGElement, CheckboxIndeterminateIconProps>(
53+
({ svg }, ref) => {
54+
const { stateProps, isIndeterminate } = useCheckboxContext();
55+
const classNames = useClassNames();
56+
57+
if (!isIndeterminate) return null;
4158

4259
const mergedProps = mergeProps(stateProps, { className: classNames.icon });
4360

packages/react/src/components/Checkbox/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ export {
22
CheckboxControl,
33
CheckboxHiddenInput,
44
CheckboxCheckedIcon,
5+
CheckboxIndeterminateIcon,
56
CheckboxLabel,
67
CheckboxRoot,
78
type CheckboxControlProps,
89
type CheckboxHiddenInputProps,
910
type CheckboxCheckedIconProps,
11+
type CheckboxIndeterminateIconProps,
1012
type CheckboxLabelProps,
1113
type CheckboxRootProps,
1214
} from "./Checkbox";

0 commit comments

Comments
 (0)