@uniflowed/ui 0.0.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/radio-group.js ADDED
@@ -0,0 +1,298 @@
1
+ // @flow
2
+ //
3
+ // A radio group: several answers, where choosing one unchooses the rest.
4
+ //
5
+ // The group is the control, not the buttons in it. That is the sentence the
6
+ // whole component follows from: a reader is told "Plan, radio group" and then
7
+ // "Pro, radio button, 2 of 3, selected", the *set* takes one stop in the page's
8
+ // tab order, and the arrow keys move inside it. Twelve hand-written radios take
9
+ // twelve Tab presses to get past and announce themselves as twelve unrelated
10
+ // buttons, which is a different widget wearing the same paint.
11
+ //
12
+ // # It is a tab list with automatic activation, under another name
13
+ //
14
+ // `tabs.js` already implements almost all of this: one tab stop, arrows that
15
+ // move, and `activationMode="automatic"` where moving to an item selects it. A
16
+ // radio group is that with `role="radio"` in place of `role="tab"` and no
17
+ // manual mode at all — arrows in a radio group *always* check, because that is
18
+ // what the pattern says, and a radio group whose arrows only moved focus would
19
+ // leave a reader believing they had answered when they had not.
20
+ //
21
+ // The one thing it needs that no other set here does is **the initial tab
22
+ // stop**. `Tabs.Tab` computes `tabIndex={active ? 0 : -1}` from the selection,
23
+ // which is right for both — but `Tabs` always has a selection, because
24
+ // `defaultValue` is required, and a radio group with nothing chosen is the
25
+ // state every unanswered form starts in. With the tab stop derived from the
26
+ // selection alone, nothing carries `tabindex="0"`, and an unanswered radio
27
+ // group is unreachable from the keyboard: not hard to reach, not awkward —
28
+ // absent. `internal/roving-focus.js`'s `useFirstItem` is the answer, and it is
29
+ // there rather than here because a toggle group nobody has focused yet has the
30
+ // same hole.
31
+ //
32
+ // # Which arrow keys, and a deviation stated rather than hidden
33
+ //
34
+ // The WAI-ARIA practices list both pairs for a radio group: `ArrowDown` and
35
+ // `ArrowRight` both move to the next radio. This package gates on `orientation`
36
+ // instead, as its tab list and its menu do, and the reason is the one
37
+ // `internal/roving-focus.js` gives about the keys a component does *not* claim:
38
+ // `ArrowDown` scrolls the page, and a horizontal row of three radios that
39
+ // swallows it has taken reading away from everyone who reads with the keyboard
40
+ // to buy a second way to do what `ArrowRight` already does. `aria-orientation`
41
+ // says which pair is live, so a reader is told rather than left to guess, and
42
+ // the default is `vertical` because that is how a radio group is nearly always
43
+ // laid out.
44
+ //
45
+ // # Naming the group
46
+ //
47
+ // A radio group with no name is announced as "radio group" and nothing else,
48
+ // which tells a reader that three answers exist and not what the question was.
49
+ // There is no `RadioGroup.Label` part because `Field` already is one:
50
+ //
51
+ // <Field.Root>
52
+ // <Field.Label>Plan</Field.Label>
53
+ // <Field.Control
54
+ // render={(props) => (
55
+ // <RadioGroup.Root {...props} defaultValue="free">…</RadioGroup.Root>
56
+ // )}
57
+ // />
58
+ // </Field.Root>
59
+ //
60
+ // `Field.Control` hands over `aria-labelledby` pointing at the label it
61
+ // rendered, which is the wiring `Field` exists to get right, and a second
62
+ // spelling of it here would be a second thing to keep in step.
63
+ //
64
+ // # Space, and the key that is not handled
65
+ //
66
+ // `Space` checks the focused radio, and prevents its default so the page does
67
+ // not scroll and the browser's own click does not arrive afterwards and check
68
+ // it a second time. `Enter` is not handled: it reaches these items as the
69
+ // browser's own click on a `<button>` and checks them, which is the least
70
+ // surprising thing for it to do. Claiming it in order to *stop* it would leave
71
+ // the key dead — `type="button"` cannot submit a form either — which is worse
72
+ // than the practices being quiet about it.
73
+
74
+ "use client";
75
+
76
+ import * as React from "@uniflowed/react";
77
+ import { createContext, useCallback, useContext, useId, useMemo, useRef } from "@uniflowed/react";
78
+
79
+ import type { Rest } from "./internal/merge-props.js";
80
+ import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
81
+ import { moveOnKey, useFirstItem } from "./internal/roving-focus.js";
82
+ import type { Orientation, RovingSet } from "./internal/roving-focus.js";
83
+ import { useControlled } from "./internal/controlled-state.js";
84
+
85
+ /**
86
+ * What the keyboard steps across in a radio group, and what owns one.
87
+ *
88
+ * By role rather than by a `data-*` attribute of this package's own, because
89
+ * that is the promise the component makes to a reader: whatever produced a
90
+ * `role="radio"` inside this group is one of the answers, and the arrow keys
91
+ * have to reach it. `ToggleGroup type="single"` renders through here and is the
92
+ * reason that matters in practice rather than in principle.
93
+ */
94
+ export function radioSet(orientation: Orientation): RovingSet {
95
+ return {
96
+ item: '[role="radio"]',
97
+ owner: '[role="radiogroup"]',
98
+ orientation,
99
+ wrap: true,
100
+ skipDisabled: true,
101
+ };
102
+ }
103
+
104
+ type RadioGroupState = {|
105
+ readonly selected: string | null,
106
+ readonly select: (value: string) => void,
107
+ /** The item holding the tab stop while nothing is chosen; see `useFirstItem`. */
108
+ readonly firstId: string | null,
109
+ |};
110
+
111
+ const RadioGroupContext: React.Context<RadioGroupState | null> = createContext(null);
112
+
113
+ /** Whether the surrounding item is the chosen one, for `RadioGroup.Indicator`. */
114
+ type RadioItemState = {| readonly checked: boolean |};
115
+
116
+ const RadioItemContext: React.Context<RadioItemState | null> = createContext(null);
117
+
118
+ hook useRadioGroup(part: string): RadioGroupState {
119
+ const state = useContext(RadioGroupContext);
120
+ if (state == null) {
121
+ throw new Error(`${part} must be rendered inside a RadioGroup.Root`);
122
+ }
123
+ return state;
124
+ }
125
+
126
+ /**
127
+ * The group, which is the control a reader is told about.
128
+ *
129
+ * `children` is `React.Node` rather than `renders* RadioGroupItem`, and the
130
+ * reason is worth stating rather than leaving as an omission. `ToggleGroup
131
+ * type="single"` is this component wearing segments: it renders a
132
+ * `RadioGroup.Root` and passes its own items through. A `renders*` constraint
133
+ * is a promise about the element a child produces, and a `ToggleGroup.Item`
134
+ * cannot make it — it produces a radio in one mode and a toggle button in the
135
+ * other — while naming both kinds here would make this module import the module
136
+ * that imports it. `Tabs.List` keeps the tighter promise because nothing else
137
+ * in this package renders a `tab`.
138
+ *
139
+ * `name` puts the answer where a form can submit it; see the hidden input
140
+ * below.
141
+ */
142
+ export component RadioGroupRoot(
143
+ children: React.Node,
144
+ defaultValue?: string | null = null,
145
+ value?: string | null,
146
+ onValueChange?: (value: string) => void,
147
+ orientation?: Orientation = "vertical",
148
+ name?: string,
149
+ ...rest: Rest
150
+ ) {
151
+ // `onValueChange` promises a `string` while the group's *state* is
152
+ // `string | null`, and the two meet here rather than being flattened into one
153
+ // type that lies in one direction or the other: "nothing chosen yet" is a
154
+ // state a radio group starts in, and it is not an event it can ever report,
155
+ // because no gesture inside a radio group unchooses an answer. Widening the
156
+ // prop to `string | null` would also make every `(plan: string) => void` a
157
+ // caller already has a type error at the call site.
158
+ const report = useCallback(
159
+ (next: string | null) => {
160
+ if (next != null) {
161
+ onValueChange?.(next);
162
+ }
163
+ },
164
+ [onValueChange],
165
+ );
166
+ const [selected, select] = useControlled<string | null>(value, defaultValue, report);
167
+ const rootRef = useRef<HTMLElement | null>(null);
168
+ // Only while nothing is chosen. Once there is an answer it holds the tab
169
+ // stop, and asking the document which item comes first is work with no reader.
170
+ const firstId = useFirstItem(rootRef, radioSet(orientation), selected == null);
171
+
172
+ const state = useMemo(() => ({ selected, select, firstId }), [selected, select, firstId]);
173
+ const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
174
+
175
+ return (
176
+ <RadioGroupContext.Provider value={state}>
177
+ <div
178
+ {...passed}
179
+ // A reader is told which axis this runs along, and it is also what says
180
+ // which pair of arrow keys is live.
181
+ aria-orientation={orientation}
182
+ onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
183
+ const group: $FlowFixMe = event.currentTarget;
184
+ const next = moveOnKey(event, group, radioSet(orientation));
185
+ if (next != null) {
186
+ // Checking in the same key press is not a shortcut, it is the
187
+ // pattern: a radio group whose arrows moved focus without checking
188
+ // leaves a reader believing they have answered when they have not.
189
+ select(next.getAttribute("data-value") ?? "");
190
+ }
191
+ })}
192
+ ref={composeRefs(rest.ref, (element) => {
193
+ rootRef.current = element;
194
+ })}
195
+ role="radiogroup"
196
+ >
197
+ {children}
198
+ {/*
199
+ A form submits `<input>` elements, and none of the parts above is one.
200
+ Without this the group is a control a reader can operate and a form
201
+ cannot read, which is the same hole `Combobox` still has.
202
+
203
+ `type="hidden"` rather than a visually hidden real radio, because the
204
+ buttons above already carry the whole of the accessible semantics: a
205
+ second set of native radios would be announced as a second set of
206
+ answers, and hiding them from the accessibility tree to stop that
207
+ leaves elements a form's own validation would then point its
208
+ "please choose one" at.
209
+ */}
210
+ {name == null ? null : <input name={name} type="hidden" value={selected ?? ""} />}
211
+ </div>
212
+ </RadioGroupContext.Provider>
213
+ );
214
+ }
215
+
216
+ /**
217
+ * One answer.
218
+ *
219
+ * A disabled item is `aria-disabled` rather than `disabled`, so it stays in the
220
+ * accessibility tree: a reader is told "Enterprise, radio button, dimmed, 3 of
221
+ * 3" and learns that the answer exists and is unavailable, where a native
222
+ * `disabled` leaves a gap they cannot ask about. The arrow keys step over it
223
+ * either way, and so does the search for the item that holds the tab stop.
224
+ */
225
+ export component RadioGroupItem(
226
+ value: string,
227
+ children?: React.Node,
228
+ disabled?: boolean = false,
229
+ ...rest: Rest
230
+ ) {
231
+ const group = useRadioGroup("RadioGroup.Item");
232
+ const id = useId();
233
+ const checked = group.selected === value;
234
+ const item = useMemo(() => ({ checked }), [checked]);
235
+ const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
236
+
237
+ return (
238
+ <RadioItemContext.Provider value={item}>
239
+ <button
240
+ {...passed}
241
+ aria-checked={checked ? "true" : "false"}
242
+ aria-disabled={disabled ? "true" : undefined}
243
+ // Read by the group's key handler, which finds items in the document
244
+ // rather than in a registry and so needs each one to carry its value.
245
+ data-value={value}
246
+ id={id}
247
+ onClick={composeHandlers(rest.onClick, () => {
248
+ if (!disabled) {
249
+ group.select(value);
250
+ }
251
+ })}
252
+ onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
253
+ if (disabled || event.key !== " ") {
254
+ return;
255
+ }
256
+ // Stops `Space` scrolling the page — which is what makes a
257
+ // hand-written radio feel broken even when it works — and stops the
258
+ // browser's own click arriving afterwards to check this again.
259
+ event.preventDefault();
260
+ group.select(value);
261
+ })}
262
+ role="radio"
263
+ // The roving tab stop: the chosen answer, or the first one while there
264
+ // is no answer, so `Tab` reaches the group in either state and leaves
265
+ // it in one press.
266
+ tabIndex={checked || (group.selected == null && group.firstId === id) ? 0 : -1}
267
+ type="button"
268
+ >
269
+ {children}
270
+ </button>
271
+ </RadioItemContext.Provider>
272
+ );
273
+ }
274
+
275
+ /**
276
+ * The mark inside the chosen answer, rendered only while it is chosen.
277
+ *
278
+ * `aria-hidden` because the item it sits in already says `aria-checked`: a dot
279
+ * that also announced itself would have a reader hear the answer's state twice,
280
+ * once as a state and once as a stray element. It exists so a caller can style
281
+ * a mark that appears and disappears without reaching for
282
+ * `[aria-checked="true"] > *`, and so the "only while chosen" part is not
283
+ * something each caller reimplements.
284
+ */
285
+ export component RadioGroupIndicator(children?: React.Node, ...rest: Rest) {
286
+ const item = useContext(RadioItemContext);
287
+ if (item == null) {
288
+ throw new Error("RadioGroup.Indicator must be rendered inside a RadioGroup.Item");
289
+ }
290
+ if (!item.checked) {
291
+ return null;
292
+ }
293
+ return (
294
+ <span {...rest} aria-hidden="true">
295
+ {children}
296
+ </span>
297
+ );
298
+ }
package/resizable.js ADDED
@@ -0,0 +1,307 @@
1
+ // @flow
2
+ //
3
+ // Two panes and the handle between them — a slider wearing a different role.
4
+ //
5
+ // The APG calls this a **window splitter**, and it defines it as a separator
6
+ // that behaves like a slider: `aria-valuenow`, `aria-valuemin` and
7
+ // `aria-valuemax` say how much of the space the primary pane has, and the
8
+ // arrow keys change it. That is why it is next to `slider.js` and shares
9
+ // `internal/range.js` with it rather than living with the layout components.
10
+ //
11
+ // Almost every resizable panel on the web is pointer-only, which is a WCAG
12
+ // 2.1.1 failure — no keyboard operation at all — and a 2.5.7 one on top of it.
13
+ // If uf ships one, the keyboard is the feature, so the keyboard is what this
14
+ // module is: there is no drag here yet, and there is a complete key map.
15
+ //
16
+ // # The two separators in this package are not the same thing
17
+ //
18
+ // A reader who greps for `separator` finds this and `menu.js`, and they are
19
+ // unrelated:
20
+ //
21
+ // * `Menu.Separator` is a rule between groups of items. It is not focusable,
22
+ // it has no value, and it exists so a reader moving through a menu is told
23
+ // the group changed.
24
+ // * `Resizable.Handle` is a *window splitter*. It is focusable, it carries a
25
+ // value, and operating it changes the layout.
26
+ //
27
+ // ARIA gives both the same role because both are separators; only the second
28
+ // one is a control. The difference is `tabindex` and `aria-valuenow`, which is
29
+ // also how a screen reader tells them apart.
30
+ //
31
+ // # `aria-orientation` is the separator's, not the layout's
32
+ //
33
+ // Two panes side by side are divided by a **vertical** line, so a
34
+ // `PanelGroup` whose `orientation` is `"horizontal"` renders a handle whose
35
+ // `aria-orientation` is `"vertical"`. That inversion is easy to get backwards
36
+ // and worth stating: `aria-orientation` on a separator describes the separator,
37
+ // and ARIA's default for the role is `horizontal` — so a vertical splitter
38
+ // that says nothing is announced as a horizontal rule.
39
+ //
40
+ // The APG's own pattern page does not mention `aria-orientation` at all, which
41
+ // is why implementations differ. This follows the role's definition rather
42
+ // than the pattern's silence.
43
+ //
44
+ // # Two panes, which is the pattern and not a limitation
45
+ //
46
+ // The window splitter is defined between two panes: a primary one whose size
47
+ // is the value, and the rest. A group of five panels is a layout-constraint
48
+ // problem — every handle's range depends on every other panel's minimum — and
49
+ // it is a different component with a different core, not a bigger version of
50
+ // this one. What is here is the pattern, complete.
51
+ //
52
+ // # Drawing it
53
+ //
54
+ // Each pane carries its share as `--uf-resizable-size`, a percentage, and the
55
+ // caller's stylesheet decides whether that is a width, a height, a `flex-basis`
56
+ // or nothing at all. There is no drag: the pointer half is tracked work, and a
57
+ // keyboard-only splitter is a working splitter, where a pointer-only one is
58
+ // not.
59
+
60
+ "use client";
61
+
62
+ import * as React from "@uniflowed/react";
63
+ import {
64
+ createContext,
65
+ useContext,
66
+ useEffect,
67
+ useId,
68
+ useMemo,
69
+ useRef,
70
+ useState,
71
+ } from "@uniflowed/react";
72
+
73
+ import type { Rest } from "./internal/merge-props.js";
74
+ import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
75
+ import type { Orientation } from "./internal/roving-focus.js";
76
+ import { clamp, isReversed } from "./internal/range.js";
77
+ import { useControlled } from "./internal/controlled-state.js";
78
+
79
+ type ResizableState = {|
80
+ readonly base: string,
81
+ /** The primary pane's share of the group, as a percentage. */
82
+ readonly value: number,
83
+ readonly setValue: (value: number) => void,
84
+ readonly min: number,
85
+ readonly max: number,
86
+ readonly step: number,
87
+ /** How the panes are laid out; the handle's own orientation is the other one. */
88
+ readonly orientation: Orientation,
89
+ readonly disabled: boolean,
90
+ readonly hasPrimary: boolean,
91
+ readonly registerPrimary: (present: boolean) => void,
92
+ |};
93
+
94
+ const ResizableContext: React.Context<ResizableState | null> = createContext(null);
95
+
96
+ hook useResizable(part: string): ResizableState {
97
+ const state = useContext(ResizableContext);
98
+ if (state == null) {
99
+ throw new Error(`${part} must be rendered inside a Resizable.PanelGroup`);
100
+ }
101
+ return state;
102
+ }
103
+
104
+ /**
105
+ * The two panes and their handle.
106
+ *
107
+ * `value` is the primary pane's percentage of the group, which is what the
108
+ * handle announces — the APG's "a decimal value representing the current
109
+ * position of the separator", where 0 is collapsed and 100 is as large as it
110
+ * is allowed to be.
111
+ *
112
+ * `min` is the size the primary pane collapses to. It is 0 by default, so
113
+ * `Enter` collapses the pane entirely; a group whose primary pane should never
114
+ * disappear gives it a floor.
115
+ */
116
+ export component ResizablePanelGroup(
117
+ children: React.Node,
118
+ value?: number,
119
+ defaultValue?: number = 50,
120
+ onValueChange?: (value: number) => void,
121
+ min?: number = 0,
122
+ max?: number = 100,
123
+ step?: number = 10,
124
+ orientation?: Orientation = "horizontal",
125
+ disabled?: boolean = false,
126
+ ...rest: Rest
127
+ ) {
128
+ const base = useId();
129
+ const [share, setShare] = useControlled(value, defaultValue, onValueChange);
130
+ const [hasPrimary, setHasPrimary] = useState(false);
131
+
132
+ const state = useMemo(
133
+ () => ({
134
+ base,
135
+ value: clamp(share, min, max),
136
+ setValue: setShare,
137
+ min,
138
+ max,
139
+ step,
140
+ orientation,
141
+ disabled,
142
+ hasPrimary,
143
+ registerPrimary: setHasPrimary,
144
+ }),
145
+ [base, share, setShare, min, max, step, orientation, disabled, hasPrimary],
146
+ );
147
+
148
+ return (
149
+ <ResizableContext.Provider value={state}>
150
+ <div {...rest}>{children}</div>
151
+ </ResizableContext.Provider>
152
+ );
153
+ }
154
+
155
+ /**
156
+ * One pane.
157
+ *
158
+ * `primary` marks the one whose size is the value, and the one the handle
159
+ * names with `aria-controls`. Exactly one pane in a group is primary; the
160
+ * other takes what is left. It is a prop rather than "the first one", because
161
+ * the first one in the document is not the first one to mount the moment a
162
+ * caller renders a pane conditionally, and a handle pointing at the wrong pane
163
+ * is a handle that announces someone else's size.
164
+ */
165
+ export component ResizablePanel(children: React.Node, primary?: boolean = false, ...rest: Rest) {
166
+ const group = useResizable("Resizable.Panel");
167
+ const passed = withoutComposed(rest, ["style"]);
168
+ const register = group.registerPrimary;
169
+
170
+ useEffect(() => {
171
+ if (!primary) {
172
+ return;
173
+ }
174
+ register(true);
175
+ return () => register(false);
176
+ }, [primary, register]);
177
+
178
+ return (
179
+ <div
180
+ {...passed}
181
+ id={primary ? `${group.base}-primary` : undefined}
182
+ style={{
183
+ ...(rest.style as $FlowFixMe),
184
+ "--uf-resizable-size": `${String(primary ? group.value : 100 - group.value)}%`,
185
+ }}
186
+ >
187
+ {children}
188
+ </div>
189
+ );
190
+ }
191
+
192
+ /**
193
+ * The splitter: a separator that behaves like a slider.
194
+ *
195
+ * `label` is its accessible name and has a default, because a splitter is a
196
+ * bare bar with no text in it every time — and a focusable separator with no
197
+ * name is announced as "separator", which tells a reader there is a control
198
+ * here and nothing about what it does.
199
+ */
200
+ export component ResizableHandle(label?: string = "Resize", ...rest: Rest) {
201
+ const group = useResizable("Resizable.Handle");
202
+ const passed = withoutComposed(rest, ["onKeyDown"]);
203
+ // Where the pane was before `Enter` collapsed it. A ref because nothing
204
+ // renders it: it is a fact about the last keystroke, not about the layout.
205
+ const restoreTo = useRef<number | null>(null);
206
+
207
+ const moveBy = (amount: number) => {
208
+ restoreTo.current = null;
209
+ group.setValue(clamp(group.value + amount, group.min, group.max));
210
+ };
211
+
212
+ return (
213
+ <div
214
+ {...passed}
215
+ aria-label={label}
216
+ // Only while a primary pane is in the document, for the reason every
217
+ // part of this package repeats: an `aria-controls` naming an id nothing
218
+ // has is worse than saying nothing at all.
219
+ aria-controls={group.hasPrimary ? `${group.base}-primary` : undefined}
220
+ aria-disabled={group.disabled ? "true" : undefined}
221
+ // The separator's own orientation, which is the other axis from the one
222
+ // the panes are laid out along. See the module header.
223
+ aria-orientation={group.orientation === "horizontal" ? "vertical" : "horizontal"}
224
+ aria-valuemax={group.max}
225
+ aria-valuemin={group.min}
226
+ aria-valuenow={group.value}
227
+ onKeyDown={composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
228
+ if (group.disabled) {
229
+ return;
230
+ }
231
+ if (event.key === "Enter") {
232
+ event.preventDefault();
233
+ // Collapse, or put it back where it was. The APG gives `Enter` both
234
+ // jobs, and a collapse with no way back is a pane a keyboard reader
235
+ // has thrown away.
236
+ const previous = restoreTo.current;
237
+ if (previous != null) {
238
+ restoreTo.current = null;
239
+ group.setValue(previous);
240
+ return;
241
+ }
242
+ if (group.value === group.min) {
243
+ return;
244
+ }
245
+ restoreTo.current = group.value;
246
+ group.setValue(group.min);
247
+ return;
248
+ }
249
+
250
+ if (event.key === "Home" || event.key === "End") {
251
+ event.preventDefault();
252
+ restoreTo.current = null;
253
+ group.setValue(event.key === "Home" ? group.min : group.max);
254
+ return;
255
+ }
256
+
257
+ const amount = stepFor(
258
+ event.key,
259
+ group.step,
260
+ group.orientation,
261
+ isReversed(event.currentTarget, group.orientation),
262
+ );
263
+ if (amount != null) {
264
+ event.preventDefault();
265
+ moveBy(amount);
266
+ }
267
+ })}
268
+ role="separator"
269
+ // A separator that is not in the tab sequence is the WCAG 2.1.1 failure
270
+ // this module exists to avoid.
271
+ tabIndex={group.disabled ? -1 : 0}
272
+ />
273
+ );
274
+ }
275
+
276
+ /**
277
+ * How far a key moves the splitter, or nothing when the key is not ours.
278
+ *
279
+ * Only the keys along the axis the panes are laid out on: `ArrowUp` in a group
280
+ * of side-by-side panes is the page's, and swallowing it takes a scroll key
281
+ * away from every reader who uses one.
282
+ *
283
+ * The primary pane is the one before the handle, so moving the handle towards
284
+ * the end of the axis makes it larger — and in a right-to-left page the end of
285
+ * a horizontal axis is on the left, so the arrows mirror.
286
+ */
287
+ function stepFor(
288
+ key: string,
289
+ step: number,
290
+ orientation: Orientation,
291
+ reversed: boolean,
292
+ ): number | null {
293
+ const move = step <= 0 ? 1 : step;
294
+ if (orientation === "vertical") {
295
+ return match (key) {
296
+ "ArrowDown" => move,
297
+ "ArrowUp" => -move,
298
+ _ => null,
299
+ };
300
+ }
301
+ const forward = reversed ? -1 : 1;
302
+ return match (key) {
303
+ "ArrowRight" => move * forward,
304
+ "ArrowLeft" => -move * forward,
305
+ _ => null,
306
+ };
307
+ }