|
1 |
| -import React, { CSSProperties, useState } from "react" |
2 |
| -import * as RCheckbox from "@radix-ui/react-checkbox" |
| 1 | +import React, { |
| 2 | + type InputHTMLAttributes, |
| 3 | + forwardRef, |
| 4 | + useState, |
| 5 | + type ForwardedRef, |
| 6 | + ReactNode, |
| 7 | + useImperativeHandle, |
| 8 | + useRef, |
| 9 | + useEffect, |
| 10 | +} from "react" |
3 | 11 | import { twJoin, twMerge } from "tailwind-merge"
|
4 | 12 | import CheckboxIcon from "@atlaskit/icon/glyph/checkbox"
|
5 | 13 | import CheckboxIndeterminateIcon from "@atlaskit/icon/glyph/checkbox-indeterminate"
|
6 | 14 |
|
7 | 15 | const indeterminateState = "indeterminate" as const
|
8 | 16 |
|
9 |
| -type CheckboxProps = { |
10 |
| - label: React.ReactChild |
11 |
| - id?: string |
12 |
| - value?: string |
| 17 | +type CheckboxProps2 = Omit< |
| 18 | + InputHTMLAttributes<HTMLInputElement>, |
| 19 | + "type" | "checked" |
| 20 | +> & { |
| 21 | + indeterminate?: boolean |
13 | 22 | checked?: boolean | typeof indeterminateState
|
14 |
| - title?: string |
15 |
| - defaultChecked?: boolean |
16 |
| - disabled?: boolean |
| 23 | + label?: ReactNode |
17 | 24 | invalid?: boolean
|
18 |
| - required?: boolean |
19 |
| - indeterminate?: boolean |
20 |
| - autoFocus?: boolean |
21 |
| - name?: string |
22 |
| - onChange?: (event: { target: { checked: boolean } }) => void |
23 |
| - onCheckedChange?: (checked: boolean | typeof indeterminateState) => void |
24 |
| - style?: CSSProperties |
25 |
| - className?: string |
| 25 | + onCheckedChange?: (checked: boolean) => void |
26 | 26 | }
|
27 | 27 |
|
28 | 28 | const checkBoxStyles =
|
29 |
| - "box-border focus:border-selected-border mx-2 ease-linear transition duration-200 flex flex-none h-[14px] w-[14px] cursor-default items-center justify-center rounded-[3px] border-[2.5px] outline-none outline-0 outline-offset-0 focus:border-2 disabled:border-disabled invalid:border-danger-bold" as const |
| 29 | + "absolute left-0 box-border border-border focus:border-selected-border mr-2 ease-linear transition duration-150 flex flex-none h-[14px] w-[14px] cursor-default items-center justify-center rounded-[3px] border-[2.5px] outline-none outline-0 outline-offset-0 focus:border-2" as const |
| 30 | + |
| 31 | +const disabledStyles = "cursor-not-allowed border-disabled" as const |
30 | 32 |
|
31 | 33 | const checkBoxCheckedStyles =
|
32 |
| - "text-selected-text border-selected-border" as const |
33 |
| -const checkBoxUncheckedStyles = "border-border" as const |
| 34 | + "text-selected-text border-selected-border opacity-100" as const |
| 35 | +const checkBoxUncheckedStyles = "border-border text-transparent" as const |
34 | 36 | const checkBoxInvalidStyles = "border-danger-border" as const
|
35 | 37 |
|
| 38 | +//#region label styles |
36 | 39 | const disabledLabelStyles = "aria-disabled:text-disabled-text" as const
|
37 | 40 |
|
38 | 41 | const requiredLabelStyles =
|
39 | 42 | "aria-required:after:content-['*'] aria-required:after:text-danger-bold aria-required:after:ml-0.5"
|
| 43 | +//#endregion |
40 | 44 |
|
41 |
| -function Checkbox({ |
42 |
| - label, |
43 |
| - value, |
44 |
| - checked: checkedProp, |
45 |
| - defaultChecked, |
46 |
| - disabled, |
47 |
| - invalid, |
48 |
| - /** |
49 |
| - * indeterminate is a special case where the checkbox is neither checked nor unchecked. It replaces the checked state by a different icon. |
50 |
| - */ |
51 |
| - indeterminate, |
52 |
| - required, |
53 |
| - autoFocus, |
54 |
| - onChange, |
55 |
| - onCheckedChange, |
56 |
| - name, |
57 |
| - style, |
58 |
| - className, |
59 |
| - ...props |
60 |
| -}: CheckboxProps) { |
61 |
| - const [checked, setChecked] = useState<boolean | typeof indeterminateState>( |
62 |
| - checkedProp ?? defaultChecked ?? false, |
63 |
| - ) |
| 45 | +const Checkbox = forwardRef( |
| 46 | + ( |
| 47 | + { |
| 48 | + id, |
| 49 | + className, |
| 50 | + style, |
| 51 | + label, |
| 52 | + disabled, |
| 53 | + required, |
| 54 | + checked: checkedProp, |
| 55 | + defaultChecked, |
| 56 | + indeterminate: indeterminateProp, |
| 57 | + invalid, |
| 58 | + onChange, |
| 59 | + onCheckedChange, |
| 60 | + ...props |
| 61 | + }: CheckboxProps2, |
| 62 | + ref: ForwardedRef<HTMLInputElement>, |
| 63 | + ) => { |
| 64 | + const [checked, setChecked] = useState( |
| 65 | + checkedProp ?? defaultChecked ?? false, |
| 66 | + ) |
64 | 67 |
|
65 |
| - if (indeterminate) { |
66 |
| - if (checkedProp !== undefined || checkedProp !== undefined) { |
67 |
| - if (checkedProp === true && checked !== indeterminateState) { |
68 |
| - setChecked(indeterminateState) |
69 |
| - } else if (!checkedProp && checked !== false) { |
70 |
| - setChecked(false) |
71 |
| - } |
72 |
| - } |
73 |
| - } else { |
| 68 | + const inputRef = useRef<HTMLInputElement>(null) |
| 69 | + // forward the local ref to the forwarded ref |
| 70 | + useImperativeHandle(ref, () => inputRef.current!) |
| 71 | + |
| 72 | + // update from outside |
74 | 73 | if (checkedProp !== undefined && checked !== checkedProp) {
|
75 |
| - setChecked(checkedProp ?? false) |
| 74 | + setChecked(checkedProp) |
76 | 75 | }
|
77 |
| - } |
78 | 76 |
|
79 |
| - return ( |
80 |
| - <div className={twMerge("flex items-center", className)}> |
81 |
| - <RCheckbox.Root |
82 |
| - name={name} |
83 |
| - value={value} |
| 77 | + const indeterminate = |
| 78 | + indeterminateProp ?? checked === indeterminateState |
| 79 | + |
| 80 | + // this needs to be in a useEffect to check if the input is checked after react rendered the component, else it will still have the old checked value |
| 81 | + // this is needed because the input is not controlled by react |
| 82 | + // eslint-disable-next-line react-hooks/exhaustive-deps |
| 83 | + useEffect(() => { |
| 84 | + const checked = inputRef.current?.checked |
| 85 | + setChecked(checked ?? false) |
| 86 | + }) |
| 87 | + |
| 88 | + return ( |
| 89 | + <div |
| 90 | + className={twMerge( |
| 91 | + "relative flex items-center justify-start", |
| 92 | + className, |
| 93 | + )} |
84 | 94 | style={style}
|
85 |
| - checked={checked} |
86 |
| - disabled={disabled} |
87 |
| - required={required} |
88 |
| - aria-invalid={invalid} |
89 |
| - autoFocus={autoFocus} |
90 |
| - onCheckedChange={(e: RCheckbox.CheckedState) => { |
91 |
| - onCheckedChange?.(e) |
92 |
| - if (checked === indeterminateState) { |
93 |
| - if (e === true || e === indeterminateState) { |
94 |
| - setChecked(false) |
95 |
| - onChange?.({ target: { checked: false } }) |
96 |
| - return |
97 |
| - } |
98 |
| - } else { |
99 |
| - if (indeterminate && e === true) { |
100 |
| - setChecked(indeterminateState) |
101 |
| - } else { |
102 |
| - setChecked(e) |
103 |
| - } |
104 |
| - onChange?.({ target: { checked: !!e } }) |
105 |
| - } |
106 |
| - }} |
107 |
| - className={`${twMerge( |
108 |
| - checkBoxStyles, |
109 |
| - invalid ? checkBoxInvalidStyles : undefined, |
110 |
| - )} ${ |
111 |
| - checked ? checkBoxCheckedStyles : checkBoxUncheckedStyles |
112 |
| - } `} |
113 |
| - {...props} |
114 | 95 | >
|
115 |
| - <RCheckbox.Indicator className="relative box-border flex h-4 w-4 flex-none items-center justify-center"> |
116 |
| - {typeof checked === "boolean" && checked === true && ( |
117 |
| - <div className="absolute inset-0 flex items-center justify-center"> |
118 |
| - <CheckboxIcon label="" /> |
119 |
| - </div> |
120 |
| - )} |
121 |
| - {checked === indeterminateState && ( |
122 |
| - <div className="absolute inset-0 flex items-center justify-center"> |
123 |
| - <CheckboxIndeterminateIcon label="" /> |
124 |
| - </div> |
| 96 | + <div |
| 97 | + className={`${twMerge( |
| 98 | + checkBoxStyles, |
| 99 | + invalid ? checkBoxInvalidStyles : undefined, |
| 100 | + disabled ? disabledStyles : undefined, |
| 101 | + )} ${ |
| 102 | + checked |
| 103 | + ? checkBoxCheckedStyles |
| 104 | + : checkBoxUncheckedStyles |
| 105 | + } `} |
| 106 | + > |
| 107 | + {!indeterminate ? ( |
| 108 | + <CheckboxIcon label="" /> |
| 109 | + ) : ( |
| 110 | + <CheckboxIndeterminateIcon label="" /> |
125 | 111 | )}
|
126 |
| - </RCheckbox.Indicator> |
127 |
| - </RCheckbox.Root> |
128 |
| - <label |
129 |
| - aria-disabled={disabled} |
130 |
| - aria-required={required} |
131 |
| - className={twJoin(disabledLabelStyles, requiredLabelStyles)} |
132 |
| - > |
133 |
| - {label} |
134 |
| - </label> |
135 |
| - </div> |
136 |
| - ) |
137 |
| -} |
| 112 | + </div> |
| 113 | + <input |
| 114 | + type="checkbox" |
| 115 | + id={id} |
| 116 | + ref={inputRef} |
| 117 | + disabled={disabled} |
| 118 | + checked={!!checked} |
| 119 | + required={required} |
| 120 | + className={"mr-2 opacity-0"} |
| 121 | + onChange={(e) => { |
| 122 | + onCheckedChange?.(e.target.checked) |
| 123 | + onChange?.(e) |
| 124 | + setChecked(e.target.checked) |
| 125 | + }} |
| 126 | + {...props} |
| 127 | + /> |
| 128 | + |
| 129 | + <label |
| 130 | + htmlFor={id} |
| 131 | + aria-disabled={disabled} |
| 132 | + aria-required={required} |
| 133 | + aria-invalid={invalid} |
| 134 | + className={twJoin(disabledLabelStyles, requiredLabelStyles)} |
| 135 | + > |
| 136 | + {label} |
| 137 | + </label> |
| 138 | + </div> |
| 139 | + ) |
| 140 | + }, |
| 141 | +) |
| 142 | +Checkbox.displayName = "Checkbox" |
138 | 143 |
|
139 |
| -const memoizedCheckbox = React.memo(Checkbox) |
140 |
| -export { memoizedCheckbox as Checkbox } |
| 144 | +export { Checkbox } |
0 commit comments