Skip to content

Commit

Permalink
feat(ui/components): CheckboxPrimitive 추가 (#313)
Browse files Browse the repository at this point in the history
  • Loading branch information
sukvvon authored Aug 14, 2024
1 parent 69a298d commit c486c5f
Show file tree
Hide file tree
Showing 2 changed files with 214 additions and 0 deletions.
212 changes: 212 additions & 0 deletions packages/ui/src/components/checkbox/checkbox.primitive.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { useEffect, useRef, useState } from 'react';
import {
useComposeRefs,
useControllableState,
usePrevious,
useSize,
} from '../../hooks';
import {
type HTMLFavolinkProps,
type HTMLFavolinkPropsWithout,
type HTMLProps,
createContext,
favolink,
forwardRef,
} from '../../system';
import { mergeFns, mergeStyles } from '../../utils';

type CheckboxContextValue = {
state: boolean;
disabled?: boolean;
};

const [CheckboxProvider, useCheckboxContext] =
createContext<CheckboxContextValue>({
name: 'CheckboxContext',
hookName: 'useCheckboxContext',
providerName: '<CheckboxProvider />',
});

type CheckboxProps = HTMLFavolinkPropsWithout<
'button',
'checked' | 'defaultChecked'
> & {
checked?: boolean;
defaultChecked?: boolean;
required?: boolean;
onCheckedChange?: (state: boolean) => void;
};

const Checkbox = forwardRef<CheckboxProps, 'button'>(
function Checkbox(props, forwardedRef) {
const {
name,
value = 'on',
required,
disabled,
defaultChecked,
checked: checkedProp,
onCheckedChange,
...restProps
} = props;

const [button, setButton] = useState<HTMLButtonElement | null>(null);
const [checked = false, setChecked] = useControllableState({
value: checkedProp,
defaultValue: defaultChecked,
onChange: onCheckedChange,
});

const composedRefs = useComposeRefs(forwardedRef, (button) => {
setButton(button);
});
const initialCheckedStateRef = useRef(checked);
const hasConsumerStoppedPropagation = useRef(false);

const isFormControl = button ? Boolean(button.closest('form')) : true;

useEffect(() => {
const form = button?.form;

if (!form) {
return;
}

function reset() {
setChecked(initialCheckedStateRef.current);
}

form.addEventListener('reset', reset);

return () => {
form.removeEventListener('reset', reset);
};
}, [button, setChecked]);

return (
<CheckboxProvider value={{ state: checked, disabled }}>
<favolink.button
type="button"
role="checkbox"
aria-checked={checked}
aria-required={required}
data-state={checked}
data-disabled={disabled ? '' : undefined}
disabled={disabled}
value={value}
{...restProps}
ref={composedRefs}
onKeyDown={mergeFns(props.onKeyDown, (event) => {
if (event.key === 'Enter') {
event.preventDefault();
}
})}
onClick={mergeFns(props.onClick, (event) => {
setChecked(!checked);

if (isFormControl) {
hasConsumerStoppedPropagation.current =
event.isPropagationStopped();

if (!hasConsumerStoppedPropagation.current) {
event.stopPropagation();
}
}
})}
/>
{isFormControl && (
<BubbleInput
control={button}
bubbles={!hasConsumerStoppedPropagation.current}
name={name}
value={value}
checked={checked}
required={required}
disabled={disabled}
style={{ transform: 'translateX(-100%)' }}
/>
)}
</CheckboxProvider>
);
},
);

Checkbox.displayName = 'Checkbox';

type CheckboxIndicatorProps = HTMLFavolinkProps<'span'>;

const CheckboxIndicator = forwardRef<CheckboxIndicatorProps, 'span'>(
function CheckboxIndicator(props, forwardedRef) {
const context = useCheckboxContext();

return (
context.state && (
<favolink.span
data-state={context.state}
data-disabled={context.disabled ? '' : undefined}
{...props}
ref={forwardedRef}
style={mergeStyles({ pointerEvents: 'none' }, props.style)}
/>
)
);
},
);

CheckboxIndicator.displayName = 'CheckboxIndicator';

type BubbleInputProps = HTMLProps<'input'> & {
bubbles: boolean;
control: HTMLElement | null;
};

function BubbleInput(props: BubbleInputProps) {
const { control, checked, bubbles = true, ...restProps } = props;

const ref = useRef<HTMLInputElement>(null);
const controlSize = useSize(control);
const prevChecked = usePrevious(checked);

useEffect(() => {
const input = ref.current;
const inputPrototype = window.HTMLInputElement.prototype;
const checkedDescriptor = Object.getOwnPropertyDescriptor(
inputPrototype,
'checked',
) as PropertyDescriptor;
const setChecked = checkedDescriptor.set?.bind(input);

if (prevChecked === checked || !setChecked) {
return;
}

const event = new Event('click', { bubbles });

setChecked.call(input, checked);
input?.dispatchEvent(event);
}, [bubbles, prevChecked, checked]);

return (
<input
type="checkbox"
aria-hidden
defaultChecked={checked}
{...restProps}
tabIndex={-1}
ref={ref}
style={mergeStyles(props.style, controlSize, {
position: 'absolute',
pointerEvents: 'none',
margin: 0,
opacity: 0,
})}
/>
);
}

export {
Checkbox as Root,
type CheckboxProps as RootProps,
CheckboxIndicator as Indicator,
type CheckboxIndicatorProps as IndicatorProps,
};
2 changes: 2 additions & 0 deletions packages/ui/src/components/checkbox/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/* eslint-disable @stylistic/padding-line-between-statements, react-refresh/only-export-components */
export * as CheckboxPrimitive from './checkbox.primitive';

0 comments on commit c486c5f

Please sign in to comment.