Skip to content

Commit

Permalink
feat(checkbox): add uncontrolled checkbox with indeterminate state
Browse files Browse the repository at this point in the history
  • Loading branch information
kimdaeyeobbb committed Jan 21, 2025
1 parent 9cf22b2 commit d8b249b
Show file tree
Hide file tree
Showing 8 changed files with 348 additions and 4 deletions.
11 changes: 9 additions & 2 deletions packages/checkbox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
},
"type": "module",
"exports": "./src/index.ts",
"files": ["dist"],
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"build:storybook": "storybook build",
Expand Down Expand Up @@ -58,5 +60,10 @@
"./styles.css": "./dist/index.css"
}
},
"sideEffects": false
"sideEffects": false,
"dependencies": {
"@radix-ui/react-slot": "^1.1.0",
"clsx": "^2.1.1",
"nanoid": "^5.0.9"
}
}
58 changes: 58 additions & 0 deletions packages/checkbox/src/Checkbox.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
.checkbox {
display: flex;
align-items: center;
gap: 8px;
margin: var(--checkbox-margin);
padding: var(--checkbox-padding);
}

.checkbox-input {
appearance: none;
width: var(--checkbox-size);
height: var(--checkbox-size);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
background-color: var(--background-color);

}

.checkbox-input:checked {
background-color: var(--checked-color);
border-color: var(--checked-color);
/* background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3E%3Cpath d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z'/%3E%3C/svg%3E");
background-size: 75%;
background-position: center;
background-repeat: no-repeat; */
background-image: var(--background-image);
background-size: var(--background-size);
background-position: var(--background-position);
background-repeat: var(--background-repeat);
}

.checkbox-input:disabled {
background-color: var(--disabled-color);
border-color: var(--disabled-color);
}

.checkbox-label {
font-size: var(--label-size);
cursor: pointer;
}

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

.checkbox-input:indeterminate {
background-color: var(--checked-color);
border-color: var(--checked-color);
/* background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3E%3Cpath d='M19 13H5v-2h14v2z'/%3E%3C/svg%3E");
background-size: 75%;
background-position: center;
background-repeat: no-repeat; */
background-image: var(--background-image);
background-size: var(--background-size);
background-position: var(--background-position);
background-repeat: var(--background-repeat);
}
141 changes: 139 additions & 2 deletions packages/checkbox/src/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,140 @@
export function Checkbox() {
return <div>Component</div>;
import { Slot } from '@radix-ui/react-slot';
import { clsx as cx } from 'clsx';
import {
type CSSProperties,
type ComponentProps,
type ReactNode,
useEffect,
useRef,
} from 'react';

import styles from './Checkbox.module.css';
import {
CHECKBOX_SIZES,
type CheckboxSize,
type CheckboxStyleConfig,
DEFAULT_CHECKBOX_STYLE,
} from './constants/size';
import { generateId } from './utils/generateId';

// ////////////////////////////////////////////////////////////////////////////////

export interface CheckboxProps extends ComponentProps<'div'> {
name?: string;
value?: string;
size?: CheckboxSize;
checked?: boolean;
defaultChecked?: boolean;
indeterminate?: boolean;
disabled?: boolean;
label?: string;
onCheckedChange?: (checked: boolean) => void;
asChild?: boolean;
innerRef?: React.RefObject<HTMLDivElement>;
children?: ReactNode;
styleConfig?: Partial<CheckboxStyleConfig>;
}

// ////////////////////////////////////////////////////////////////////////////////

export const Checkbox = ({
className,
name,
value,
label,
asChild = true,
size = 'medium',
checked,
defaultChecked,
indeterminate = false,
disabled = false,
onCheckedChange,
children,
style: _style,
innerRef,
styleConfig = {},
...props
}: CheckboxProps) => {
const localRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const inputId = useRef(generateId('checkbox'));

useEffect(() => {
if (inputRef.current) {
inputRef.current.indeterminate = indeterminate;
}
}, [indeterminate]);

const Component = asChild ? Slot : 'div';

const handleChange = () => {
if (!disabled && onCheckedChange) {
onCheckedChange(!checked);
}
};

const sizeConfig = CHECKBOX_SIZES[size];
const mergedStyleConfig = { ...DEFAULT_CHECKBOX_STYLE, ...styleConfig };

const style = {
'--checkbox-size': `${sizeConfig.checkboxSize}px`,
'--label-size': `${sizeConfig.labelSize}px`,
'--checkbox-padding': `${sizeConfig.padding}px`,
'--checkbox-margin': `${sizeConfig.margin}px`,
'--border-radius': `${mergedStyleConfig.borderRadius}px`,
'--border-width': `${mergedStyleConfig.borderWidth}px`,
'--border-color': mergedStyleConfig.borderColor,
'--background-color': mergedStyleConfig.backgroundColor,
'--checked-color': mergedStyleConfig.checkedColor,
'--disabled-color': mergedStyleConfig.disabledColor,
'--hover-color': mergedStyleConfig.hoverColor,
'--background-image': mergedStyleConfig.backgroundImage,
'--background-size': mergedStyleConfig.backgroundSize,
'--background-position': mergedStyleConfig.backgroundPosition,
'--background-repeat': mergedStyleConfig.backgroundRepeat,
..._style,
} as CSSProperties;

if (!label && children) {
return <>{children}</>;
}

const content = (
<div
className={cx(
styles.checkbox,
{
[styles.indeterminate]: indeterminate,
[styles.disabled]: disabled,
},
className,
)}
style={style}
{...props}
>
<input
ref={inputRef}
type="checkbox"
id={inputId.current}
name={name}
value={value}
checked={checked}
defaultChecked={defaultChecked}
disabled={disabled}
onChange={handleChange}
className={styles['checkbox-input']}
/>
{label && (
<label
htmlFor={inputId.current}
className={styles['checkbox-label']}
style={{ fontSize: sizeConfig.labelSize }}
>
{label}
</label>
)}
</div>
);

return asChild ? <Component ref={localRef}>{content}</Component> : content;
};
53 changes: 53 additions & 0 deletions packages/checkbox/src/constants/size.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
export type CheckboxSize = 'small' | 'medium' | 'large';

interface SizeConfig {
checkboxSize: number;
labelSize: number;
padding: number;
margin: number;
}

export const CHECKBOX_SIZES: Record<CheckboxSize, SizeConfig> = {
small: {
checkboxSize: 16,
labelSize: 14,
padding: 8,
margin: 4,
},
medium: {
checkboxSize: 20,
labelSize: 16,
padding: 10,
margin: 6,
},
large: {
checkboxSize: 24,
labelSize: 18,
padding: 12,
margin: 8,
},
} as const;

export interface CheckboxStyleConfig {
borderRadius?: number;
borderWidth?: number;
borderColor?: string;
backgroundColor?: string;
checkedColor?: string;
disabledColor?: string;
hoverColor?: string;
backgroundImage?: string;
backgroundSize?: string;
backgroundPosition?: string;
backgroundRepeat?: string;
}

export const DEFAULT_CHECKBOX_STYLE: CheckboxStyleConfig = {
borderRadius: 4,
borderWidth: 1,
borderColor: '#D1D5DB',
backgroundColor: '#FFFFFF',
checkedColor: '#3B82F6',
disabledColor: '#E5E7EB',
hoverColor: '#F3F4F6',
} as const;
55 changes: 55 additions & 0 deletions packages/checkbox/src/hooks/useCheckboxGroup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useCallback, useState } from 'react';

interface UseCheckboxGroupProps {
total: number;
onChange?: (
checkedItems: boolean[],
allChecked: boolean,
indeterminate: boolean,
) => void;
}

export const useCheckboxGroup = ({
total,
onChange,
}: UseCheckboxGroupProps) => {
const [checkedItems, setCheckedItems] = useState<boolean[]>(
new Array(total).fill(false),
);

const updateCheckedItems = useCallback(
(index: number, checked: boolean) => {
const newCheckedItems = [...checkedItems];
newCheckedItems[index] = checked;
setCheckedItems(newCheckedItems);

const checkedCount = newCheckedItems.filter(Boolean).length;
const allChecked = checkedCount === total;
const indeterminate = checkedCount > 0 && checkedCount < total;

onChange?.(newCheckedItems, allChecked, indeterminate);
},
[checkedItems, total, onChange],
);

const setAllChecked = useCallback(
(checked: boolean) => {
const newCheckedItems = new Array(total).fill(checked);
setCheckedItems(newCheckedItems);
onChange?.(newCheckedItems, checked, false);
},
[total, onChange],
);

const checkedCount = checkedItems.filter(Boolean).length;
const allChecked = checkedCount === total;
const indeterminate = checkedCount > 0 && checkedCount < total;

return {
checkedItems,
updateCheckedItems,
setAllChecked,
allChecked,
indeterminate,
};
};
13 changes: 13 additions & 0 deletions packages/checkbox/src/hooks/useIndeterminateEffect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// hooks/useIndeterminateEffect.ts
import { type RefObject, useEffect } from 'react';

export const useIndeterminateEffect = (
inputRef: RefObject<HTMLInputElement>,
indeterminate: boolean,
) => {
useEffect(() => {
if (inputRef.current) {
inputRef.current.indeterminate = indeterminate;
}
}, [inputRef, indeterminate]);
};
4 changes: 4 additions & 0 deletions packages/checkbox/src/utils/generateId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// utils/generateId.ts
import { nanoid } from 'nanoid';

export const generateId = (prefix: string) => `${prefix}-${nanoid()}`;
17 changes: 17 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit d8b249b

Please sign in to comment.