Skip to content

Commit 4011deb

Browse files
committed
Prevent accidental miss-clicks outside multi-select checkboxes
1 parent 703add7 commit 4011deb

File tree

4 files changed

+83
-43
lines changed

4 files changed

+83
-43
lines changed

src/components/input/Checkbox.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { JSX } from 'preact';
1+
import type { JSX, Ref } from 'preact';
22
import { useState } from 'preact/hooks';
33

44
import type { CompositeProps, IconComponent } from '../../types';
@@ -20,6 +20,11 @@ type ComponentProps = {
2020
checkedIcon?: IconComponent;
2121
/** type is always `checkbox` */
2222
type?: never;
23+
24+
/** Optional extra CSS classes appended to the container's className */
25+
containerClasses?: string | string[];
26+
/** Ref associated with the component's container */
27+
containerRef?: Ref<HTMLLabelElement | undefined>;
2328
};
2429

2530
export type CheckboxProps = CompositeProps &

src/components/input/RadioButton.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { JSX } from 'preact';
1+
import type { JSX, Ref } from 'preact';
22

33
import type { CompositeProps, IconComponent } from '../../types';
44
import { RadioCheckedIcon, RadioIcon } from '../icons';
@@ -13,6 +13,11 @@ type ComponentProps = {
1313
checkedIcon?: IconComponent;
1414
/** type is always `radio` */
1515
type?: never;
16+
17+
/** Optional extra CSS classes appended to the container's className */
18+
containerClasses?: string | string[];
19+
/** Ref associated with the component's container */
20+
containerRef?: Ref<HTMLLabelElement | undefined>;
1621
};
1722

1823
export type RadioButtonProps = CompositeProps &

src/components/input/Select.tsx

Lines changed: 54 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,11 @@ function SelectOption<T>({
7272
elementRef,
7373
}: SelectOptionProps<T>) {
7474
const checkboxRef = useRef<HTMLElement | null>(null);
75+
const checkboxContainerRef = useRef<HTMLLabelElement | null>(null);
7576
const optionRef = useSyncedRef(elementRef);
77+
const eventTriggeredInCheckbox = (e: Event) =>
78+
e.target === checkboxRef.current ||
79+
e.target === checkboxContainerRef.current;
7680

7781
const selectContext = useContext(SelectContext);
7882
if (!selectContext) {
@@ -150,9 +154,12 @@ function SelectOption<T>({
150154
classes,
151155
)}
152156
onClick={e => {
153-
// Do not invoke callback if clicked element is the checkbox, as it has
154-
// its own event handler.
155-
if (!disabled && e.target !== checkboxRef.current) {
157+
if (
158+
!disabled &&
159+
// Do not invoke callback if clicked element is the checkbox or its
160+
// container, as it has its own event handler.
161+
!eventTriggeredInCheckbox(e)
162+
) {
156163
selectOneValue();
157164
}
158165
}}
@@ -163,9 +170,9 @@ function SelectOption<T>({
163170

164171
if (
165172
['Enter', ' '].includes(e.key) &&
166-
// Do not invoke callback if event triggers in checkbox, as it has its
167-
// own event handler.
168-
e.target !== checkboxRef.current
173+
// Do not invoke callback if event triggered in the checkbox or its
174+
// container, as it has its own event handler.
175+
!eventTriggeredInCheckbox(e)
169176
) {
170177
e.preventDefault();
171178
selectOneValue();
@@ -183,46 +190,57 @@ function SelectOption<T>({
183190
>
184191
<div
185192
className={classnames(
186-
'w-full flex justify-between items-center gap-3',
187-
'rounded py-2 px-3',
193+
// Make items stretch so that all have the same height. This is
194+
// important for multi-selects, where the checkbox actionable surface
195+
// should span to the very edges of the option containing it.
196+
'flex justify-between items-stretch',
197+
'w-full rounded',
188198
{
189199
'hover:bg-grey-1 group-focus-visible:ring': !disabled,
190200
'bg-grey-1 hover:bg-grey-2': selected,
191201
},
192202
)}
193203
>
194-
{optionChildren(children, { selected, disabled })}
204+
<div className="flex items-center py-2 pl-3">
205+
{optionChildren(children, { selected, disabled })}
206+
</div>
195207
{!multiple && (
196-
<CheckIcon
197-
className={classnames('text-grey-6 scale-125', {
198-
// Make the icon visible/invisible, instead of conditionally
199-
// rendering it, to ensure consistent spacing among selected and
200-
// non-selected options
201-
'opacity-0': !selected,
202-
})}
203-
/>
204-
)}
205-
{multiple && (
206-
<div
207-
className={classnames('scale-125', {
208-
'text-grey-6': selected,
209-
'text-grey-3 hover:text-grey-6': !selected,
210-
})}
211-
>
212-
<Checkbox
213-
checked={selected}
214-
checkedIcon={CheckboxCheckedFilledIcon}
215-
elementRef={checkboxRef}
216-
onChange={toggleValue}
217-
onKeyDown={e => {
218-
if (e.key === 'ArrowLeft') {
219-
e.preventDefault();
220-
optionRef.current?.focus();
221-
}
222-
}}
208+
<div className="flex items-center py-2 px-3">
209+
<CheckIcon
210+
className={classnames('text-grey-6 scale-125', {
211+
// Make the icon visible/invisible, instead of conditionally
212+
// rendering it, to ensure consistent spacing among selected and
213+
// non-selected options
214+
'opacity-0': !selected,
215+
})}
223216
/>
224217
</div>
225218
)}
219+
{multiple && (
220+
<Checkbox
221+
containerClasses={classnames(
222+
'flex items-center py-2 px-3',
223+
// The checkbox is sized based on the container's font size. Make
224+
// it a bit larger.
225+
'text-lg',
226+
{
227+
'text-grey-6': selected,
228+
'text-grey-3 hover:text-grey-6': !selected,
229+
},
230+
)}
231+
checked={selected}
232+
checkedIcon={CheckboxCheckedFilledIcon}
233+
elementRef={checkboxRef}
234+
containerRef={checkboxContainerRef}
235+
onChange={toggleValue}
236+
onKeyDown={e => {
237+
if (e.key === 'ArrowLeft') {
238+
e.preventDefault();
239+
optionRef.current?.focus();
240+
}
241+
}}
242+
/>
243+
)}
226244
</div>
227245
</li>
228246
);

src/components/input/ToggleInput.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import classnames from 'classnames';
2-
import type { JSX } from 'preact';
2+
import type { JSX, Ref } from 'preact';
33

44
import type { CompositeProps, IconComponent } from '../../types';
55
import { downcastRef } from '../../util/typing';
@@ -13,6 +13,11 @@ type ComponentProps = {
1313
checkedIcon: IconComponent;
1414

1515
type: 'checkbox' | 'radio';
16+
17+
/** Optional extra CSS classes appended to the container's className */
18+
containerClasses?: string | string[];
19+
/** Ref associated with the component's container */
20+
containerRef?: Ref<HTMLLabelElement | undefined>;
1621
};
1722

1823
export type ToggleInputProps = CompositeProps &
@@ -27,6 +32,7 @@ export type ToggleInputProps = CompositeProps &
2732
export default function ToggleInput({
2833
children,
2934
elementRef,
35+
containerRef,
3036

3137
checked,
3238
icon: UncheckedIcon,
@@ -36,20 +42,26 @@ export default function ToggleInput({
3642
onChange,
3743
id,
3844
type,
45+
containerClasses,
3946
...htmlAttributes
4047
}: ToggleInputProps) {
4148
const Icon = checked ? CheckedIcon : UncheckedIcon;
4249

4350
return (
4451
<label
45-
className={classnames('relative flex items-center gap-x-1.5', {
46-
'cursor-pointer': !disabled,
47-
'opacity-70': disabled,
48-
})}
52+
className={classnames(
53+
'relative flex items-center gap-x-1.5',
54+
{
55+
'cursor-pointer': !disabled,
56+
'opacity-70': disabled,
57+
},
58+
containerClasses,
59+
)}
4960
htmlFor={id}
5061
data-composite-component={
5162
type === 'checkbox' ? 'Checkbox' : 'RadioButton'
5263
}
64+
ref={downcastRef(containerRef)}
5365
>
5466
<input
5567
{...htmlAttributes}

0 commit comments

Comments
 (0)