From c486c5fbe6d29bcaded73a572ff24ba2fb8a267f Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Wed, 14 Aug 2024 15:17:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui/components):=20CheckboxPrimitive=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../checkbox/checkbox.primitive.tsx | 212 ++++++++++++++++++ packages/ui/src/components/checkbox/index.ts | 2 + 2 files changed, 214 insertions(+) create mode 100644 packages/ui/src/components/checkbox/checkbox.primitive.tsx create mode 100644 packages/ui/src/components/checkbox/index.ts diff --git a/packages/ui/src/components/checkbox/checkbox.primitive.tsx b/packages/ui/src/components/checkbox/checkbox.primitive.tsx new file mode 100644 index 0000000..ebb57fc --- /dev/null +++ b/packages/ui/src/components/checkbox/checkbox.primitive.tsx @@ -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({ + name: 'CheckboxContext', + hookName: 'useCheckboxContext', + providerName: '', + }); + +type CheckboxProps = HTMLFavolinkPropsWithout< + 'button', + 'checked' | 'defaultChecked' +> & { + checked?: boolean; + defaultChecked?: boolean; + required?: boolean; + onCheckedChange?: (state: boolean) => void; +}; + +const Checkbox = forwardRef( + function Checkbox(props, forwardedRef) { + const { + name, + value = 'on', + required, + disabled, + defaultChecked, + checked: checkedProp, + onCheckedChange, + ...restProps + } = props; + + const [button, setButton] = useState(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 ( + + { + 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 && ( + + )} + + ); + }, +); + +Checkbox.displayName = 'Checkbox'; + +type CheckboxIndicatorProps = HTMLFavolinkProps<'span'>; + +const CheckboxIndicator = forwardRef( + function CheckboxIndicator(props, forwardedRef) { + const context = useCheckboxContext(); + + return ( + context.state && ( + + ) + ); + }, +); + +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(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 ( + + ); +} + +export { + Checkbox as Root, + type CheckboxProps as RootProps, + CheckboxIndicator as Indicator, + type CheckboxIndicatorProps as IndicatorProps, +}; diff --git a/packages/ui/src/components/checkbox/index.ts b/packages/ui/src/components/checkbox/index.ts new file mode 100644 index 0000000..24486f6 --- /dev/null +++ b/packages/ui/src/components/checkbox/index.ts @@ -0,0 +1,2 @@ +/* eslint-disable @stylistic/padding-line-between-statements, react-refresh/only-export-components */ +export * as CheckboxPrimitive from './checkbox.primitive';