Skip to content

Commit 828f8fb

Browse files
authored
feat(ui): enlarge checkbox hit area and add hover state (#9867)
* feat(ui): enlarge checkbox hit area and add hover state Checkboxes take clicks within 6px of the visible box, giving the small list checkbox a 24px target. Hovering the label, box, or surrounding area strengthens the border or shifts the checked fill; disabled and invalid controls are unchanged. * fix(ui): preserve invalid checkbox hover colors Exclude invalid checked and indeterminate controls from hover styling. Cover error appearance when hovering labels and surrounding hit areas.
1 parent a5f2fbc commit 828f8fb

4 files changed

Lines changed: 235 additions & 0 deletions

File tree

‎invokeai/frontend/webv2/src/platform/ui/list/ListItem.browser.test.tsx‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,34 @@ describe('ListItem', () => {
139139
expect(host.querySelector<HTMLElement>('[data-list-primary] [title]')!.children).toHaveLength(2);
140140
});
141141

142+
it('toggles the checkbox from the padding around it without stealing presses from the primary button', async () => {
143+
const onCheckedChange = vi.fn();
144+
const onPress = vi.fn();
145+
const item = (isChecked: boolean) => (
146+
<ListItem isChecked={isChecked} title="A model" onCheckedChange={onCheckedChange} onPress={onPress} />
147+
);
148+
149+
await render(item(false));
150+
151+
const row = primary().parentElement!;
152+
const rowRect = row.getBoundingClientRect();
153+
const control = host.querySelector<HTMLElement>('[data-part="control"]')!.getBoundingClientRect();
154+
const middle = control.top + control.height / 2 - rowRect.top;
155+
156+
// The row's start padding and the space above the control, both outside its visible box.
157+
await userEvent.click(row, { position: { x: control.left - rowRect.left - 4, y: middle } });
158+
expect(onCheckedChange).toHaveBeenLastCalledWith(true);
159+
await render(item(true));
160+
await userEvent.click(row, { position: { x: control.left - rowRect.left + 2, y: control.top - rowRect.top - 4 } });
161+
expect(onCheckedChange).toHaveBeenLastCalledWith(false);
162+
expect(onPress).not.toHaveBeenCalled();
163+
164+
// Past the outset, the primary button's start padding still belongs to the press.
165+
await userEvent.click(row, { position: { x: control.right - rowRect.left + 8, y: middle } });
166+
expect(onPress).toHaveBeenCalledTimes(1);
167+
expect(onCheckedChange).toHaveBeenCalledTimes(2);
168+
});
169+
142170
it('keeps a title with line breaks on one line', async () => {
143171
await render(<ListItem title="single line" titleTruncate="end" onPress={() => undefined} />);
144172
const singleLineHeight = host
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { Box, Checkbox, ChakraProvider } from '@chakra-ui/react';
2+
import { applyThemeToRoot } from '@theme/applyTheme';
3+
import { compositeColors, getContrastRatio } from '@theme/contrastRatio.testing';
4+
import { system } from '@theme/system';
5+
import { THEMES } from '@theme/themes';
6+
import { act } from 'react';
7+
import { createRoot, type Root } from 'react-dom/client';
8+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
9+
import { userEvent } from 'vitest/browser';
10+
11+
/**
12+
* Hovering anywhere in a checkbox's target (label, control, or the invisible outset around the control) must read as
13+
* a stronger boundary or a shifted fill in every theme, while a disabled checkbox stays as it rests.
14+
*/
15+
16+
let host: HTMLDivElement | null = null;
17+
let root: Root | null = null;
18+
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
19+
20+
beforeEach(() => {
21+
// Read settled colors: reduced motion shortens the hover transition to a millisecond.
22+
document.documentElement.dataset.reduceMotion = 'true';
23+
});
24+
25+
afterEach(async () => {
26+
await userEvent.unhover(document.body);
27+
await act(() => root?.unmount());
28+
host?.remove();
29+
delete document.documentElement.dataset.highContrast;
30+
delete document.documentElement.dataset.reduceMotion;
31+
applyThemeToRoot('classic');
32+
host = null;
33+
root = null;
34+
});
35+
36+
const SAMPLES = [
37+
{ id: 'unchecked', checked: false, disabled: false, invalid: false },
38+
{ id: 'checked', checked: true, disabled: false, invalid: false },
39+
{ id: 'disabled', checked: false, disabled: true, invalid: false },
40+
{ id: 'invalid', checked: false, disabled: false, invalid: true },
41+
{ id: 'invalid-checked', checked: true, disabled: false, invalid: true },
42+
{ id: 'invalid-indeterminate', checked: 'indeterminate', disabled: false, invalid: true },
43+
] as const;
44+
45+
const renderCheckboxes = async () => {
46+
host = document.createElement('div');
47+
document.body.append(host);
48+
root = createRoot(host);
49+
await act(() => {
50+
root?.render(
51+
<ChakraProvider value={system}>
52+
<Box bg="bg.panel" display="flex" flexDirection="column" gap="4" p="4">
53+
{SAMPLES.map(({ checked, disabled, id, invalid }) => (
54+
<Checkbox.Root
55+
key={id}
56+
checked={checked}
57+
colorPalette="accent"
58+
data-testid={id}
59+
disabled={disabled}
60+
invalid={invalid}
61+
size="sm"
62+
>
63+
<Checkbox.HiddenInput />
64+
<Checkbox.Control />
65+
<Checkbox.Label>{id}</Checkbox.Label>
66+
</Checkbox.Root>
67+
))}
68+
</Box>
69+
</ChakraProvider>
70+
);
71+
});
72+
73+
const panel = getComputedStyle(host.firstElementChild!).backgroundColor;
74+
const part = (id: string, name: string) =>
75+
host!.querySelector<HTMLElement>(`[data-testid="${id}"] [data-part="${name}"]`)!;
76+
const settle = () =>
77+
new Promise<void>((resolve) => {
78+
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
79+
});
80+
// A point in the outset: left of the control's visible box, level with its middle.
81+
const hoverOutset = async (id: string) => {
82+
const label = host!.querySelector<HTMLElement>(`[data-testid="${id}"]`)!;
83+
const labelRect = label.getBoundingClientRect();
84+
const control = part(id, 'control').getBoundingClientRect();
85+
86+
await userEvent.hover(label, {
87+
position: { x: control.left - labelRect.left - 4, y: control.top - labelRect.top + control.height / 2 },
88+
});
89+
await settle();
90+
};
91+
const hoverLabel = async (id: string) => {
92+
await userEvent.hover(part(id, 'label'));
93+
await settle();
94+
};
95+
const read = (id: string) => {
96+
const style = getComputedStyle(part(id, 'control'));
97+
const fill = compositeColors(style.backgroundColor, panel);
98+
99+
return {
100+
border: getContrastRatio(style.borderTopColor, panel, 1),
101+
fill,
102+
mark: getContrastRatio(style.color, fill, 1),
103+
};
104+
};
105+
106+
return { hoverLabel, hoverOutset, read };
107+
};
108+
109+
describe('checkbox hover', () => {
110+
for (const theme of THEMES) {
111+
for (const highContrast of [false, true]) {
112+
it(`strengthens the boundary and shifts the fill on ${theme.id}${highContrast ? ' (high contrast)' : ''}`, async () => {
113+
applyThemeToRoot(theme.id);
114+
if (highContrast) {
115+
document.documentElement.dataset.highContrast = 'true';
116+
}
117+
const { hoverOutset, read } = await renderCheckboxes();
118+
const unchecked = read('unchecked');
119+
const checked = read('checked');
120+
121+
await hoverOutset('unchecked');
122+
expect(read('unchecked').border).toBeGreaterThan(unchecked.border * 1.1);
123+
124+
await hoverOutset('checked');
125+
const hoveredChecked = read('checked');
126+
127+
expect(hoveredChecked.fill).not.toBe(checked.fill);
128+
// The check stays a legible graphic on the hovered fill.
129+
expect(hoveredChecked.mark).toBeGreaterThanOrEqual(3);
130+
});
131+
}
132+
}
133+
134+
it('shows the hover from the label, not on a disabled checkbox', async () => {
135+
const { hoverLabel, hoverOutset, read } = await renderCheckboxes();
136+
const unchecked = read('unchecked');
137+
const disabled = read('disabled');
138+
139+
await hoverLabel('unchecked');
140+
expect(read('unchecked').border).toBeGreaterThan(unchecked.border);
141+
142+
await hoverOutset('disabled');
143+
expect(read('disabled')).toEqual(disabled);
144+
await hoverLabel('disabled');
145+
expect(read('disabled')).toEqual(disabled);
146+
});
147+
148+
it.each(['invalid', 'invalid-checked', 'invalid-indeterminate'])(
149+
'preserves the error appearance of %s when hovering the label or outset',
150+
async (id) => {
151+
const { hoverLabel, hoverOutset, read } = await renderCheckboxes();
152+
const invalid = read(id);
153+
154+
await hoverLabel(id);
155+
expect(read(id)).toEqual(invalid);
156+
await hoverOutset(id);
157+
expect(read(id)).toEqual(invalid);
158+
}
159+
);
160+
});

‎invokeai/frontend/webv2/src/platform/ui/theme/recipes.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,51 @@ export const scrollAreaSlotRecipe = defineSlotRecipe({
715715
},
716716
});
717717

718+
/**
719+
* Extend the control's hit area past its box with an invisible outset; the root is a label, so a press there toggles
720+
* the input. 6px gives the smallest (sm, 12px) control a 24px target and stays under the 8px minimum spacing between
721+
* neighbouring controls, so it never reaches a neighbour's box. Insets resolve from the padding box, so the outset
722+
* also clears the control's 1px border.
723+
*
724+
* Ark marks the control hovered while any part of the root is, so the label and the outset show the hover too. The
725+
* hover is defined for the `solid` variant only, the default every checkbox uses.
726+
*/
727+
export const checkboxSlotRecipe = defineSlotRecipe({
728+
...chakraSlotRecipes.checkbox,
729+
base: {
730+
...chakraSlotRecipes.checkbox.base,
731+
control: {
732+
...chakraSlotRecipes.checkbox.base?.control,
733+
position: 'relative',
734+
transitionDuration: 'fast',
735+
transitionProperty: 'border-color, background',
736+
_before: { content: '""', inset: 'calc(-1px - {spacing.1.5})', position: 'absolute' },
737+
},
738+
},
739+
variants: {
740+
...chakraSlotRecipes.checkbox.variants,
741+
variant: {
742+
...chakraSlotRecipes.checkbox.variants?.variant,
743+
solid: {
744+
control: {
745+
...chakraSlotRecipes.checkbox.variants?.variant?.solid?.control,
746+
_hover: {
747+
// Halfway to the foreground is stronger than the rest border in every theme, high contrast included,
748+
// where the rest border already matches the muted foreground. An invalid control keeps its error border.
749+
'&[data-state=unchecked]:not([data-invalid])': {
750+
borderColor: 'color-mix(in oklab, {colors.border.emphasized}, {colors.fg})',
751+
},
752+
'&:is([data-state=checked], [data-state=indeterminate]):not([data-invalid])': {
753+
bg: 'colorPalette.solid/90',
754+
borderColor: 'colorPalette.solid/90',
755+
},
756+
},
757+
},
758+
},
759+
},
760+
},
761+
});
762+
718763
export const sliderSlotRecipe = defineSlotRecipe({
719764
...chakraSlotRecipes.slider,
720765
base: {

‎invokeai/frontend/webv2/src/platform/ui/theme/system.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createSystem, defineConfig } from '@chakra-ui/react';
33
import { baseConfig } from './rebase';
44
import {
55
buttonRecipe,
6+
checkboxSlotRecipe,
67
colorPickerSlotRecipe,
78
comboboxSlotRecipe,
89
dataListSlotRecipe,
@@ -388,6 +389,7 @@ const config = defineConfig({
388389
textarea: textareaRecipe,
389390
},
390391
slotRecipes: {
392+
checkbox: checkboxSlotRecipe,
391393
colorPicker: colorPickerSlotRecipe,
392394
combobox: comboboxSlotRecipe,
393395
dataList: dataListSlotRecipe,

0 commit comments

Comments
 (0)