Skip to content

Commit 1f6f4b9

Browse files
fix(checkbox): toggle on mouse release (#303)
* fix(checkbox): toggle on mouse release * fix(checkbox): address review feedback * test(checkbox): clean rebased integration coverage
1 parent 946cea3 commit 1f6f4b9

File tree

4 files changed

+104
-5
lines changed

4 files changed

+104
-5
lines changed

docs/widgets/catalog.json

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -337,8 +337,17 @@
337337
},
338338
{
339339
"name": "checkbox",
340-
"requiredProps": ["id", "checked", "onChange"],
341-
"commonProps": ["label", "disabled"],
340+
"requiredProps": ["id", "checked"],
341+
"commonProps": [
342+
"onChange",
343+
"label",
344+
"disabled",
345+
"focusable",
346+
"accessibleLabel",
347+
"focusConfig",
348+
"dsTone",
349+
"dsSize"
350+
],
342351
"example": "ui.checkbox({ id: 'agree', checked, onChange })"
343352
},
344353
{

docs/widgets/checkbox.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ ui.checkbox({
2121
|---|---|---|---|
2222
| `id` | `string` | **required** | Unique identifier for focus and event routing |
2323
| `checked` | `boolean` | **required** | Current checked state |
24+
| `focusable` | `boolean` | `true` | Opt out of Tab focus while keeping id-based routing available |
25+
| `accessibleLabel` | `string` | - | Semantic label used for accessibility and focus announcements |
2426
| `label` | `string` | - | Optional label displayed next to the box |
2527
| `onChange` | `(checked: boolean) => void` | - | Called when the user toggles the checkbox |
2628
| `disabled` | `boolean` | `false` | Disable focus and interaction |
29+
| `focusConfig` | `FocusConfig` | - | Custom focus appearance configuration |
2730
| `dsTone` | `"default" \| "primary" \| "danger" \| "success" \| "warning"` | `"default"` | Design-system tone for checked/focus rendering |
2831
| `dsSize` | `"sm" \| "md" \| "lg"` | `"md"` | Design-system size preset |
2932
| `key` | `string` | - | Reconciliation key |
@@ -38,7 +41,8 @@ The indicator and label use `checkboxRecipe()` for checked/focus/disabled states
3841

3942
- Focusable when enabled.
4043
- Toggle with **Space** (and commonly **Enter** depending on terminal key mapping).
41-
- **Mouse click** focuses and toggles the checkbox.
44+
- **Mouse down** focuses the checkbox.
45+
- **Mouse up on the same checkbox** toggles it when `onChange` is provided.
4246
- **Tab / Shift+Tab** moves focus.
4347

4448
## Examples
@@ -48,9 +52,19 @@ The indicator and label use `checkboxRecipe()` for checked/focus/disabled states
4852
```typescript
4953
import { ui } from "@rezi-ui/core";
5054

51-
ui.checkbox({ id: "flag", checked: state.flag, onChange: (c) => app.update((s) => ({ ...s, flag: c })) });
55+
ui.checkbox({
56+
id: "flag",
57+
checked: state.flag,
58+
accessibleLabel: "Feature flag",
59+
onChange: (c) => app.update((s) => ({ ...s, flag: c })),
60+
});
5261
```
5362

63+
When `label` is omitted or visually ambiguous, provide `accessibleLabel` so focus
64+
announcements and other semantic affordances stay clear. Set `focusable: false`
65+
only when you intentionally want the checkbox out of Tab order while keeping
66+
id-based routing available.
67+
5468
### 2) Disabled
5569

5670
```typescript

packages/core/src/app/__tests__/widgetRenderer.integration.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,45 @@ describe("WidgetRenderer integration battery", () => {
621621
assert.deepEqual(values, []);
622622
});
623623

624+
test("checkbox mouse toggles on release over the same target", () => {
625+
const backend = createNoopBackend();
626+
const renderer = new WidgetRenderer<void>({
627+
backend,
628+
requestRender: () => {},
629+
});
630+
631+
const changes: boolean[] = [];
632+
const vnode = ui.checkbox({
633+
id: "agree",
634+
checked: false,
635+
label: "Agree",
636+
onChange: (checked) => changes.push(checked),
637+
});
638+
639+
const res = renderer.submitFrame(
640+
() => vnode,
641+
undefined,
642+
{ cols: 40, rows: 5 },
643+
defaultTheme,
644+
noRenderHooks(),
645+
);
646+
assert.ok(res.ok);
647+
648+
const down = renderer.routeEngineEvent(mouseEvent(1, 0, 3, { timeMs: 1, buttons: 1 }));
649+
assert.equal(down.action, undefined);
650+
assert.deepEqual(changes, []);
651+
assert.equal(renderer.getFocusedId(), "agree");
652+
653+
const up = renderer.routeEngineEvent(mouseEvent(1, 0, 4, { timeMs: 2, buttons: 0 }));
654+
assert.deepEqual(up.action, {
655+
id: "agree",
656+
action: "toggle",
657+
checked: true,
658+
});
659+
assert.deepEqual(changes, [true]);
660+
assert.equal(renderer.getFocusedId(), "agree");
661+
});
662+
624663
test("select uses Enter and Space to advance like the inline cycler runtime", () => {
625664
const backend = createNoopBackend();
626665
const renderer = new WidgetRenderer<void>({

packages/core/src/app/widgetRenderer/routeEngineEvent.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,29 @@ type SplitPaneLastDividerDown = Readonly<{
141141
timeMs: number;
142142
}> | null;
143143

144+
function extendMousePressableIds(
145+
pressableIds: ReadonlySet<string>,
146+
checkboxById: ReadonlyMap<string, CheckboxProps>,
147+
): ReadonlySet<string> {
148+
let merged: Set<string> | null = null;
149+
150+
for (const [id, checkbox] of checkboxById) {
151+
if (
152+
typeof checkbox.onChange !== "function" ||
153+
checkbox.disabled === true ||
154+
pressableIds.has(id)
155+
) {
156+
continue;
157+
}
158+
if (merged === null) {
159+
merged = new Set(pressableIds);
160+
}
161+
merged.add(id);
162+
}
163+
164+
return merged ?? pressableIds;
165+
}
166+
144167
export type RouteEngineEventOutcome = Readonly<{
145168
needsRender: boolean;
146169
action?: RoutedAction;
@@ -677,7 +700,7 @@ export function routeEngineEventImpl(
677700
pressedId: state.pressedId,
678701
hitTestTargetId: mouseTargetId,
679702
enabledById,
680-
pressableIds: ctx.pressableIds,
703+
pressableIds: extendMousePressableIds(ctx.pressableIds, ctx.checkboxById),
681704
})
682705
: EMPTY_ROUTING;
683706

@@ -731,6 +754,20 @@ export function routeEngineEventImpl(
731754

732755
if (res.action) {
733756
if (res.action.action === "press") {
757+
const checkbox = ctx.checkboxById.get(res.action.id);
758+
if (checkbox && typeof checkbox.onChange === "function" && checkbox.disabled !== true) {
759+
const nextChecked = !checkbox.checked;
760+
checkbox.onChange(nextChecked);
761+
return Object.freeze({
762+
needsRender,
763+
action: Object.freeze({
764+
id: res.action.id,
765+
action: "toggle",
766+
checked: nextChecked,
767+
}),
768+
});
769+
}
770+
734771
const btn = ctx.buttonById.get(res.action.id);
735772
if (btn?.onPress) btn.onPress();
736773
const link = ctx.linkById.get(res.action.id);

0 commit comments

Comments
 (0)