Skip to content

Commit b38cac6

Browse files
fix(focus-zone): preserve input editing inside active zones (#302)
* fix(focus-zone): preserve input editing inside active zones * docs(focus-zone): address review feedback
1 parent ce7e69b commit b38cac6

File tree

3 files changed

+168
-38
lines changed

3 files changed

+168
-38
lines changed

docs/widgets/focus-zone.md

Lines changed: 42 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,25 @@ Groups focusable widgets into a logical unit for Tab traversal. Focus zones help
88
ui.focusZone(
99
{ id: "toolbar", tabIndex: 0 },
1010
[
11-
ui.button({ id: "new", label: "New" }),
12-
ui.button({ id: "open", label: "Open" }),
13-
ui.button({ id: "save", label: "Save" }),
11+
ui.row({ gap: 1 }, [
12+
ui.button({ id: "new", label: "New" }),
13+
ui.button({ id: "open", label: "Open" }),
14+
ui.button({ id: "save", label: "Save" }),
15+
]),
1416
]
1517
)
1618
```
1719

1820
## Layout behavior
1921

20-
`focusZone` is layout-transparent: it does not force children into a column axis.
21-
Children keep their natural layout behavior (for example, a `row` stays horizontal,
22-
`grid` stays grid, nested stacks preserve their own axis rules).
22+
`focusZone` is layout-transparent when it wraps a single child: it does not force
23+
that child into a column axis. The child keeps its natural layout behavior
24+
(for example, a `row` stays horizontal, `grid` stays grid, nested stacks
25+
preserve their own axis rules).
26+
27+
When you pass multiple direct children, Rezi currently uses a legacy column
28+
fallback. Keep explicit layout wrappers around multi-child zones so structure
29+
stays obvious and stable.
2330

2431
When you want explicit structure, keep using standard layout widgets:
2532

@@ -56,9 +63,11 @@ Arrow keys move through items in document order:
5663

5764
```typescript
5865
ui.focusZone({ id: "list", navigation: "linear" }, [
59-
ui.button({ id: "a", label: "A" }),
60-
ui.button({ id: "b", label: "B" }),
61-
ui.button({ id: "c", label: "C" }),
66+
ui.column({ gap: 1 }, [
67+
ui.button({ id: "a", label: "A" }),
68+
ui.button({ id: "b", label: "B" }),
69+
ui.button({ id: "c", label: "C" }),
70+
]),
6271
])
6372
// Up/Down or Left/Right moves A -> B -> C
6473
```
@@ -69,12 +78,14 @@ Arrow keys navigate a 2D grid layout:
6978

7079
```typescript
7180
ui.focusZone({ id: "grid", navigation: "grid", columns: 3 }, [
72-
ui.button({ id: "1", label: "1" }),
73-
ui.button({ id: "2", label: "2" }),
74-
ui.button({ id: "3", label: "3" }),
75-
ui.button({ id: "4", label: "4" }),
76-
ui.button({ id: "5", label: "5" }),
77-
ui.button({ id: "6", label: "6" }),
81+
ui.grid({ columns: 3 }, [
82+
ui.button({ id: "1", label: "1" }),
83+
ui.button({ id: "2", label: "2" }),
84+
ui.button({ id: "3", label: "3" }),
85+
ui.button({ id: "4", label: "4" }),
86+
ui.button({ id: "5", label: "5" }),
87+
ui.button({ id: "6", label: "6" }),
88+
]),
7889
])
7990
// Left/Right moves horizontally
8091
// Up/Down moves between rows
@@ -111,8 +122,10 @@ ui.focusZone({
111122
onEnter: () => showSearchHint(),
112123
onExit: () => hideSearchHint(),
113124
}, [
114-
ui.input({ id: "query", value: state.query }),
115-
ui.button({ id: "search", label: "Search" }),
125+
ui.row({ gap: 1 }, [
126+
ui.input({ id: "query", value: state.query }),
127+
ui.button({ id: "search", label: "Search" }),
128+
]),
116129
])
117130
```
118131

@@ -123,17 +136,21 @@ Organize a form into logical sections:
123136
```typescript
124137
ui.column({ gap: 2 }, [
125138
ui.focusZone({ id: "credentials", tabIndex: 0 }, [
126-
ui.field({ label: "Username", children:
127-
ui.input({ id: "user", value: state.user })
128-
}),
129-
ui.field({ label: "Password", children:
130-
ui.input({ id: "pass", value: state.pass })
131-
}),
139+
ui.column({ gap: 1 }, [
140+
ui.field({ label: "Username", children:
141+
ui.input({ id: "user", value: state.user })
142+
}),
143+
ui.field({ label: "Password", children:
144+
ui.input({ id: "pass", value: state.pass })
145+
}),
146+
]),
132147
]),
133148

134149
ui.focusZone({ id: "actions", tabIndex: 1 }, [
135-
ui.button({ id: "login", label: "Login" }),
136-
ui.button({ id: "cancel", label: "Cancel" }),
150+
ui.row({ gap: 1 }, [
151+
ui.button({ id: "login", label: "Login" }),
152+
ui.button({ id: "cancel", label: "Cancel" }),
153+
]),
137154
]),
138155
])
139156
```

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

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,99 @@ describe("WidgetRenderer integration battery", () => {
805805
assert.equal(renderer.getFocusedId(), "z3");
806806
});
807807

808+
test("focusZone preserves single-line input arrow editing while active", () => {
809+
const backend = createNoopBackend();
810+
const renderer = new WidgetRenderer<void>({
811+
backend,
812+
requestRender: () => {},
813+
});
814+
815+
const values: string[] = [];
816+
const vnode = ui.column({}, [
817+
ui.focusZone({ id: "zone", navigation: "linear", wrapAround: true }, [
818+
ui.input({
819+
id: "name",
820+
value: "ab",
821+
onInput: (value) => values.push(value),
822+
}),
823+
ui.button({ id: "next", label: "Next" }),
824+
]),
825+
]);
826+
827+
const res = renderer.submitFrame(
828+
() => vnode,
829+
undefined,
830+
{ cols: 40, rows: 10 },
831+
defaultTheme,
832+
noRenderHooks(),
833+
);
834+
assert.ok(res.ok);
835+
836+
renderer.routeEngineEvent(keyEvent(3 /* TAB */));
837+
assert.equal(renderer.getFocusedId(), "name");
838+
839+
const left = renderer.routeEngineEvent(keyEvent(22 /* LEFT */));
840+
assert.equal(left.action, undefined);
841+
assert.equal(renderer.getFocusedId(), "name");
842+
843+
const typed = renderer.routeEngineEvent(textEvent(120 /* x */));
844+
assert.deepEqual(typed.action, {
845+
id: "name",
846+
action: "input",
847+
value: "axb",
848+
cursor: 2,
849+
});
850+
assert.deepEqual(values, ["axb"]);
851+
assert.equal(renderer.getFocusedId(), "name");
852+
});
853+
854+
test("focusZone preserves textarea vertical editing while active", () => {
855+
const backend = createNoopBackend();
856+
const renderer = new WidgetRenderer<void>({
857+
backend,
858+
requestRender: () => {},
859+
});
860+
861+
const values: string[] = [];
862+
const vnode = ui.column({}, [
863+
ui.focusZone({ id: "zone", navigation: "linear", wrapAround: true }, [
864+
ui.textarea({
865+
id: "notes",
866+
value: "abcd\nef",
867+
rows: 4,
868+
onInput: (value) => values.push(value),
869+
}),
870+
ui.button({ id: "submit", label: "Submit" }),
871+
]),
872+
]);
873+
874+
const res = renderer.submitFrame(
875+
() => vnode,
876+
undefined,
877+
{ cols: 40, rows: 10 },
878+
defaultTheme,
879+
noRenderHooks(),
880+
);
881+
assert.ok(res.ok);
882+
883+
renderer.routeEngineEvent(keyEvent(3 /* TAB */));
884+
assert.equal(renderer.getFocusedId(), "notes");
885+
886+
const up = renderer.routeEngineEvent(keyEvent(20 /* UP */));
887+
assert.equal(up.action, undefined);
888+
assert.equal(renderer.getFocusedId(), "notes");
889+
890+
const typed = renderer.routeEngineEvent(textEvent(120 /* x */));
891+
assert.deepEqual(typed.action, {
892+
id: "notes",
893+
action: "input",
894+
value: "abxcd\nef",
895+
cursor: 3,
896+
});
897+
assert.deepEqual(values, ["abxcd\nef"]);
898+
assert.equal(renderer.getFocusedId(), "notes");
899+
});
900+
808901
test("link is focusable and fires onPress with Enter/Space", () => {
809902
const backend = createNoopBackend();
810903
const renderer = new WidgetRenderer<void>({

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

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,23 @@ export function routeEngineEventImpl(
642642
treeStore: ctx.treeStore,
643643
}) || localNeedsRender;
644644

645+
if (event.kind === "key") {
646+
const inputEditingRoute = routeInputEditingEvent(event, {
647+
focusedId: state.focusState.focusedId,
648+
enabledById,
649+
inputById: ctx.inputById,
650+
inputCursorByInstanceId: ctx.inputCursorByInstanceId,
651+
inputSelectionByInstanceId: ctx.inputSelectionByInstanceId,
652+
inputWorkingValueByInstanceId: ctx.inputWorkingValueByInstanceId,
653+
inputUndoByInstanceId: ctx.inputUndoByInstanceId,
654+
writeSelectedTextToClipboard: ctx.writeSelectedTextToClipboard,
655+
onInputCallbackError: (error) => {
656+
ctx.reportInputCallbackError("onInput", error);
657+
},
658+
});
659+
if (inputEditingRoute) return inputEditingRoute as InputEditingRoutingOutcome;
660+
}
661+
645662
const res: RoutingResult & { nextZoneId?: string | null } =
646663
event.kind === "key"
647664
? routeKeyWithZones(event, {
@@ -722,19 +739,22 @@ export function routeEngineEventImpl(
722739
return Object.freeze({ needsRender, action: res.action });
723740
}
724741

725-
const inputEditingRoute = routeInputEditingEvent(event, {
726-
focusedId: state.focusState.focusedId,
727-
enabledById,
728-
inputById: ctx.inputById,
729-
inputCursorByInstanceId: ctx.inputCursorByInstanceId,
730-
inputSelectionByInstanceId: ctx.inputSelectionByInstanceId,
731-
inputWorkingValueByInstanceId: ctx.inputWorkingValueByInstanceId,
732-
inputUndoByInstanceId: ctx.inputUndoByInstanceId,
733-
writeSelectedTextToClipboard: ctx.writeSelectedTextToClipboard,
734-
onInputCallbackError: (error) => {
735-
ctx.reportInputCallbackError("onInput", error);
736-
},
737-
});
742+
const inputEditingRoute =
743+
event.kind === "key"
744+
? null
745+
: routeInputEditingEvent(event, {
746+
focusedId: state.focusState.focusedId,
747+
enabledById,
748+
inputById: ctx.inputById,
749+
inputCursorByInstanceId: ctx.inputCursorByInstanceId,
750+
inputSelectionByInstanceId: ctx.inputSelectionByInstanceId,
751+
inputWorkingValueByInstanceId: ctx.inputWorkingValueByInstanceId,
752+
inputUndoByInstanceId: ctx.inputUndoByInstanceId,
753+
writeSelectedTextToClipboard: ctx.writeSelectedTextToClipboard,
754+
onInputCallbackError: (error) => {
755+
ctx.reportInputCallbackError("onInput", error);
756+
},
757+
});
738758
if (inputEditingRoute) return inputEditingRoute as InputEditingRoutingOutcome;
739759

740760
return Object.freeze({ needsRender });

0 commit comments

Comments
 (0)