Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,34 @@ describe('ListItem', () => {
expect(host.querySelector<HTMLElement>('[data-list-primary] [title]')!.children).toHaveLength(2);
});

it('toggles the checkbox from the padding around it without stealing presses from the primary button', async () => {
const onCheckedChange = vi.fn();
const onPress = vi.fn();
const item = (isChecked: boolean) => (
<ListItem isChecked={isChecked} title="A model" onCheckedChange={onCheckedChange} onPress={onPress} />
);

await render(item(false));

const row = primary().parentElement!;
const rowRect = row.getBoundingClientRect();
const control = host.querySelector<HTMLElement>('[data-part="control"]')!.getBoundingClientRect();
const middle = control.top + control.height / 2 - rowRect.top;

// The row's start padding and the space above the control, both outside its visible box.
await userEvent.click(row, { position: { x: control.left - rowRect.left - 4, y: middle } });
expect(onCheckedChange).toHaveBeenLastCalledWith(true);
await render(item(true));
await userEvent.click(row, { position: { x: control.left - rowRect.left + 2, y: control.top - rowRect.top - 4 } });
expect(onCheckedChange).toHaveBeenLastCalledWith(false);
expect(onPress).not.toHaveBeenCalled();

// Past the outset, the primary button's start padding still belongs to the press.
await userEvent.click(row, { position: { x: control.right - rowRect.left + 8, y: middle } });
expect(onPress).toHaveBeenCalledTimes(1);
expect(onCheckedChange).toHaveBeenCalledTimes(2);
});

it('keeps a title with line breaks on one line', async () => {
await render(<ListItem title="single line" titleTruncate="end" onPress={() => undefined} />);
const singleLineHeight = host
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { Box, Checkbox, ChakraProvider } from '@chakra-ui/react';
import { applyThemeToRoot } from '@theme/applyTheme';
import { compositeColors, getContrastRatio } from '@theme/contrastRatio.testing';
import { system } from '@theme/system';
import { THEMES } from '@theme/themes';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { userEvent } from 'vitest/browser';

/**
* Hovering anywhere in a checkbox's target (label, control, or the invisible outset around the control) must read as
* a stronger boundary or a shifted fill in every theme, while a disabled checkbox stays as it rests.
*/

let host: HTMLDivElement | null = null;
let root: Root | null = null;
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

beforeEach(() => {
// Read settled colors: reduced motion shortens the hover transition to a millisecond.
document.documentElement.dataset.reduceMotion = 'true';
});

afterEach(async () => {
await userEvent.unhover(document.body);
await act(() => root?.unmount());
host?.remove();
delete document.documentElement.dataset.highContrast;
delete document.documentElement.dataset.reduceMotion;
applyThemeToRoot('classic');
host = null;
root = null;
});

const SAMPLES = [
{ id: 'unchecked', checked: false, disabled: false, invalid: false },
{ id: 'checked', checked: true, disabled: false, invalid: false },
{ id: 'disabled', checked: false, disabled: true, invalid: false },
{ id: 'invalid', checked: false, disabled: false, invalid: true },
{ id: 'invalid-checked', checked: true, disabled: false, invalid: true },
{ id: 'invalid-indeterminate', checked: 'indeterminate', disabled: false, invalid: true },
] as const;

const renderCheckboxes = async () => {
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
await act(() => {
root?.render(
<ChakraProvider value={system}>
<Box bg="bg.panel" display="flex" flexDirection="column" gap="4" p="4">
{SAMPLES.map(({ checked, disabled, id, invalid }) => (
<Checkbox.Root
key={id}
checked={checked}
colorPalette="accent"
data-testid={id}
disabled={disabled}
invalid={invalid}
size="sm"
>
<Checkbox.HiddenInput />
<Checkbox.Control />
<Checkbox.Label>{id}</Checkbox.Label>
</Checkbox.Root>
))}
</Box>
</ChakraProvider>
);
});

const panel = getComputedStyle(host.firstElementChild!).backgroundColor;
const part = (id: string, name: string) =>
host!.querySelector<HTMLElement>(`[data-testid="${id}"] [data-part="${name}"]`)!;
const settle = () =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
// A point in the outset: left of the control's visible box, level with its middle.
const hoverOutset = async (id: string) => {
const label = host!.querySelector<HTMLElement>(`[data-testid="${id}"]`)!;
const labelRect = label.getBoundingClientRect();
const control = part(id, 'control').getBoundingClientRect();

await userEvent.hover(label, {
position: { x: control.left - labelRect.left - 4, y: control.top - labelRect.top + control.height / 2 },
});
await settle();
};
const hoverLabel = async (id: string) => {
await userEvent.hover(part(id, 'label'));
await settle();
};
const read = (id: string) => {
const style = getComputedStyle(part(id, 'control'));
const fill = compositeColors(style.backgroundColor, panel);

return {
border: getContrastRatio(style.borderTopColor, panel, 1),
fill,
mark: getContrastRatio(style.color, fill, 1),
};
};

return { hoverLabel, hoverOutset, read };
};

describe('checkbox hover', () => {
for (const theme of THEMES) {
for (const highContrast of [false, true]) {
it(`strengthens the boundary and shifts the fill on ${theme.id}${highContrast ? ' (high contrast)' : ''}`, async () => {
applyThemeToRoot(theme.id);
if (highContrast) {
document.documentElement.dataset.highContrast = 'true';
}
const { hoverOutset, read } = await renderCheckboxes();
const unchecked = read('unchecked');
const checked = read('checked');

await hoverOutset('unchecked');
expect(read('unchecked').border).toBeGreaterThan(unchecked.border * 1.1);

await hoverOutset('checked');
const hoveredChecked = read('checked');

expect(hoveredChecked.fill).not.toBe(checked.fill);
// The check stays a legible graphic on the hovered fill.
expect(hoveredChecked.mark).toBeGreaterThanOrEqual(3);
});
}
}

it('shows the hover from the label, not on a disabled checkbox', async () => {
const { hoverLabel, hoverOutset, read } = await renderCheckboxes();
const unchecked = read('unchecked');
const disabled = read('disabled');

await hoverLabel('unchecked');
expect(read('unchecked').border).toBeGreaterThan(unchecked.border);

await hoverOutset('disabled');
expect(read('disabled')).toEqual(disabled);
await hoverLabel('disabled');
expect(read('disabled')).toEqual(disabled);
});

it.each(['invalid', 'invalid-checked', 'invalid-indeterminate'])(
'preserves the error appearance of %s when hovering the label or outset',
async (id) => {
const { hoverLabel, hoverOutset, read } = await renderCheckboxes();
const invalid = read(id);

await hoverLabel(id);
expect(read(id)).toEqual(invalid);
await hoverOutset(id);
expect(read(id)).toEqual(invalid);
}
);
});
45 changes: 45 additions & 0 deletions invokeai/frontend/webv2/src/platform/ui/theme/recipes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,51 @@ export const scrollAreaSlotRecipe = defineSlotRecipe({
},
});

/**
* Extend the control's hit area past its box with an invisible outset; the root is a label, so a press there toggles
* the input. 6px gives the smallest (sm, 12px) control a 24px target and stays under the 8px minimum spacing between
* neighbouring controls, so it never reaches a neighbour's box. Insets resolve from the padding box, so the outset
* also clears the control's 1px border.
*
* Ark marks the control hovered while any part of the root is, so the label and the outset show the hover too. The
* hover is defined for the `solid` variant only, the default every checkbox uses.
*/
export const checkboxSlotRecipe = defineSlotRecipe({
...chakraSlotRecipes.checkbox,
base: {
...chakraSlotRecipes.checkbox.base,
control: {
...chakraSlotRecipes.checkbox.base?.control,
position: 'relative',
transitionDuration: 'fast',
transitionProperty: 'border-color, background',
_before: { content: '""', inset: 'calc(-1px - {spacing.1.5})', position: 'absolute' },
},
},
variants: {
...chakraSlotRecipes.checkbox.variants,
variant: {
...chakraSlotRecipes.checkbox.variants?.variant,
solid: {
control: {
...chakraSlotRecipes.checkbox.variants?.variant?.solid?.control,
_hover: {
// Halfway to the foreground is stronger than the rest border in every theme, high contrast included,
// where the rest border already matches the muted foreground. An invalid control keeps its error border.
'&[data-state=unchecked]:not([data-invalid])': {
borderColor: 'color-mix(in oklab, {colors.border.emphasized}, {colors.fg})',
},
'&:is([data-state=checked], [data-state=indeterminate]):not([data-invalid])': {
bg: 'colorPalette.solid/90',
borderColor: 'colorPalette.solid/90',
},
},
},
},
},
},
});

export const sliderSlotRecipe = defineSlotRecipe({
...chakraSlotRecipes.slider,
base: {
Expand Down
2 changes: 2 additions & 0 deletions invokeai/frontend/webv2/src/platform/ui/theme/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createSystem, defineConfig } from '@chakra-ui/react';
import { baseConfig } from './rebase';
import {
buttonRecipe,
checkboxSlotRecipe,
colorPickerSlotRecipe,
comboboxSlotRecipe,
dataListSlotRecipe,
Expand Down Expand Up @@ -388,6 +389,7 @@ const config = defineConfig({
textarea: textareaRecipe,
},
slotRecipes: {
checkbox: checkboxSlotRecipe,
colorPicker: colorPickerSlotRecipe,
combobox: comboboxSlotRecipe,
dataList: dataListSlotRecipe,
Expand Down
Loading