@uniflowed/ui 0.0.0-alpha.8 → 0.1.0

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.
Files changed (64) hide show
  1. package/accordion.js +84 -57
  2. package/alert-dialog.js +284 -0
  3. package/alert.js +142 -0
  4. package/avatar.js +280 -0
  5. package/breadcrumb.js +138 -0
  6. package/calendar.js +560 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +215 -31
  9. package/collapsible.js +72 -48
  10. package/color-picker.js +172 -0
  11. package/combobox.js +216 -39
  12. package/context-menu.js +215 -0
  13. package/date-field.js +9 -0
  14. package/date-picker.js +357 -0
  15. package/date-range-picker.js +120 -0
  16. package/dialog.js +235 -198
  17. package/drag-drop.js +125 -0
  18. package/drawer.js +504 -0
  19. package/field.js +260 -43
  20. package/grid-list.js +8 -0
  21. package/hover-card.js +334 -0
  22. package/i18n-provider.js +89 -0
  23. package/index.js +1254 -32
  24. package/input-otp.js +218 -0
  25. package/interactions.js +2327 -0
  26. package/internal/anchor.js +565 -0
  27. package/internal/collection.js +395 -0
  28. package/internal/date-grid.js +260 -0
  29. package/internal/date-range.js +26 -0
  30. package/internal/disclosure.js +201 -0
  31. package/internal/focus.js +64 -0
  32. package/internal/hover-intent.js +259 -0
  33. package/internal/menu-tree.js +228 -0
  34. package/internal/merge-props.js +117 -1
  35. package/internal/roving-focus.js +15 -4
  36. package/internal/segmented-field.js +316 -0
  37. package/list-box.js +13 -0
  38. package/menu.js +553 -361
  39. package/menubar.js +295 -0
  40. package/number-field.js +263 -0
  41. package/package.json +8 -25
  42. package/pagination.js +34 -22
  43. package/popover.js +367 -0
  44. package/progress.js +21 -16
  45. package/radio-group.js +81 -75
  46. package/range-calendar.js +78 -0
  47. package/resizable.js +155 -9
  48. package/scroll-area.js +283 -0
  49. package/select.js +83 -37
  50. package/separator.js +97 -0
  51. package/sheet.js +189 -0
  52. package/sidebar.js +320 -0
  53. package/skeleton.js +163 -0
  54. package/slider.js +95 -89
  55. package/switch.js +42 -34
  56. package/table.js +112 -71
  57. package/tabs.js +100 -91
  58. package/tag-group.js +8 -0
  59. package/time-field.js +8 -0
  60. package/toast.js +36 -66
  61. package/toggle-group.js +53 -49
  62. package/toggle.js +41 -27
  63. package/tooltip.js +404 -0
  64. package/tree.js +8 -0
package/radio-group.js CHANGED
@@ -76,8 +76,13 @@
76
76
  import * as React from "@uniflowed/react";
77
77
  import { createContext, useCallback, useContext, useId, useMemo, useRef } from "@uniflowed/react";
78
78
 
79
- import type { Rest } from "./internal/merge-props.js";
80
- import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
79
+ import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
80
+ import {
81
+ composeHandlers,
82
+ composeRefs,
83
+ withoutComposed,
84
+ withProps,
85
+ } from "./internal/merge-props.js";
81
86
  import { moveOnKey, useFirstItem } from "./internal/roving-focus.js";
82
87
  import type { Orientation, RovingSet } from "./internal/roving-focus.js";
83
88
  import { useControlled } from "./internal/controlled-state.js";
@@ -146,6 +151,7 @@ export component RadioGroupRoot(
146
151
  onValueChange?: (value: string) => void,
147
152
  orientation?: Orientation = "vertical",
148
153
  name?: string,
154
+ render?: RenderProp,
149
155
  ...rest: Rest
150
156
  ) {
151
157
  // `onValueChange` promises a `string` while the group's *state* is
@@ -171,44 +177,48 @@ export component RadioGroupRoot(
171
177
 
172
178
  const state = useMemo(() => ({ selected, select, firstId }), [selected, select, firstId]);
173
179
  const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
180
+ const content = (
181
+ <>
182
+ {children}
183
+ {/*
184
+ A form submits `<input>` elements, and none of the parts above is one.
185
+ Without this the group is a control a reader can operate and a form
186
+ cannot read, which is the same hole `Combobox` still has.
187
+
188
+ `type="hidden"` rather than a visually hidden real radio, because the
189
+ buttons above already carry the whole of the accessible semantics: a
190
+ second set of native radios would be announced as a second set of
191
+ answers, and hiding them from the accessibility tree to stop that
192
+ leaves elements a form's own validation would then point its
193
+ "please choose one" at.
194
+ */}
195
+ {name == null ? null : <input name={name} type="hidden" value={selected ?? ""} />}
196
+ </>
197
+ );
198
+ const props = withProps(passed, {
199
+ "aria-orientation": orientation,
200
+ children: content,
201
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
202
+ const group: $FlowFixMe = event.currentTarget;
203
+ const next = moveOnKey(event, group, radioSet(orientation));
204
+ if (next != null) {
205
+ // Checking in the same key press is not a shortcut, it is the
206
+ // pattern: a radio group whose arrows moved focus without checking
207
+ // leaves a reader believing they have answered when they have not.
208
+ select(next.getAttribute("data-value") ?? "");
209
+ }
210
+ }),
211
+ // React calls callback refs during commit; keyboard handlers read it later.
212
+ // uf-lint-disable-next-line react-compiler/refs
213
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
214
+ rootRef.current = element;
215
+ }),
216
+ role: "radiogroup",
217
+ });
174
218
 
175
219
  return (
176
220
  <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>
221
+ {render == null ? <div {...props} /> : render(props)}
212
222
  </RadioGroupContext.Provider>
213
223
  );
214
224
  }
@@ -226,6 +236,7 @@ export component RadioGroupItem(
226
236
  value: string,
227
237
  children?: React.Node,
228
238
  disabled?: boolean = false,
239
+ render?: RenderProp,
229
240
  ...rest: Rest
230
241
  ) {
231
242
  const group = useRadioGroup("RadioGroup.Item");
@@ -233,41 +244,39 @@ export component RadioGroupItem(
233
244
  const checked = group.selected === value;
234
245
  const item = useMemo(() => ({ checked }), [checked]);
235
246
  const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
247
+ const props = withProps(passed, {
248
+ "aria-checked": checked ? "true" : "false",
249
+ "aria-disabled": disabled ? "true" : undefined,
250
+ // Read by the group's key handler, which finds items in the document
251
+ // rather than in a registry and so needs each one to carry its value.
252
+ "data-value": value,
253
+ children,
254
+ id,
255
+ onClick: composeHandlers(rest.onClick, (_event: PartEvent) => {
256
+ if (!disabled) {
257
+ group.select(value);
258
+ }
259
+ }),
260
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
261
+ if (disabled || event.key !== " ") {
262
+ return;
263
+ }
264
+ // Stops `Space` scrolling the page — which is what makes a
265
+ // hand-written radio feel broken even when it works — and stops the
266
+ // browser's own click arriving afterwards to check this again.
267
+ event.preventDefault();
268
+ group.select(value);
269
+ }),
270
+ role: "radio",
271
+ // The roving tab stop: the chosen answer, or the first one while there
272
+ // is no answer, so `Tab` reaches the group in either state and leaves
273
+ // it in one press.
274
+ tabIndex: checked || (group.selected == null && group.firstId === id) ? 0 : -1,
275
+ });
236
276
 
237
277
  return (
238
278
  <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>
279
+ {render == null ? <button {...props} type="button" /> : render(props)}
271
280
  </RadioItemContext.Provider>
272
281
  );
273
282
  }
@@ -282,7 +291,7 @@ export component RadioGroupItem(
282
291
  * `[aria-checked="true"] > *`, and so the "only while chosen" part is not
283
292
  * something each caller reimplements.
284
293
  */
285
- export component RadioGroupIndicator(children?: React.Node, ...rest: Rest) {
294
+ export component RadioGroupIndicator(children?: React.Node, render?: RenderProp, ...rest: Rest) {
286
295
  const item = useContext(RadioItemContext);
287
296
  if (item == null) {
288
297
  throw new Error("RadioGroup.Indicator must be rendered inside a RadioGroup.Item");
@@ -290,9 +299,6 @@ export component RadioGroupIndicator(children?: React.Node, ...rest: Rest) {
290
299
  if (!item.checked) {
291
300
  return null;
292
301
  }
293
- return (
294
- <span {...rest} aria-hidden="true">
295
- {children}
296
- </span>
297
- );
302
+ const props = withProps(rest, { "aria-hidden": "true", children });
303
+ return render == null ? <span {...props} /> : render(props);
298
304
  }
@@ -0,0 +1,78 @@
1
+ // @flow
2
+ "use client";
3
+ import * as React from "@uniflowed/react";
4
+ import { useState } from "@uniflowed/react";
5
+ import type { PlainDate } from "@uniflowed/core/temporal";
6
+ import { useControlled } from "./internal/controlled-state.js";
7
+ import type { Rest } from "./internal/merge-props.js";
8
+ import type { DateRange } from "./internal/date-range.js";
9
+ import { validateRange, unavailableInRange } from "./internal/date-range.js";
10
+ import { CalendarRoot, CalendarMonth } from "./calendar.js";
11
+ export type { DateRange } from "./internal/date-range.js";
12
+
13
+ export component RangeCalendarRoot(
14
+ children?: React.Node = <CalendarMonth />,
15
+ value?: DateRange | null,
16
+ defaultValue?: DateRange | null = null,
17
+ onValueChange?: (value: DateRange | null) => void,
18
+ minValue?: string,
19
+ maxValue?: string,
20
+ isDateDisabled?: (date: PlainDate) => boolean,
21
+ defaultFocused?: string,
22
+ today?: string,
23
+ locale?: string,
24
+ focusedDayRef?: { current: HTMLElement | null },
25
+ ...rest: Rest
26
+ ) {
27
+ const [range, setRange] = useControlled(value, defaultValue, onValueChange);
28
+ const [anchor, setAnchor] = useState<string | null>(null);
29
+ const [announcement, announce] = useState("");
30
+ validateRange(range);
31
+ const disabled = (date: PlainDate) =>
32
+ (minValue != null && date.toString() < minValue) ||
33
+ (maxValue != null && date.toString() > maxValue) ||
34
+ isDateDisabled?.(date) === true;
35
+ const choose = (date: PlainDate) => {
36
+ const iso = date.toString();
37
+ if (disabled(date)) return;
38
+ if (anchor == null) {
39
+ setAnchor(iso);
40
+ announce(`Start ${iso}. Choose an end date.`);
41
+ return;
42
+ }
43
+ const start = anchor < iso ? anchor : iso,
44
+ end = anchor < iso ? iso : anchor;
45
+ if (unavailableInRange(start, end, isDateDisabled)) {
46
+ announce("The range contains an unavailable date");
47
+ return;
48
+ }
49
+ setRange({ start, end });
50
+ setAnchor(null);
51
+ announce(`Selected ${start} to ${end}`);
52
+ };
53
+ const selected = (date: PlainDate) => {
54
+ const iso = date.toString();
55
+ return anchor != null
56
+ ? iso === anchor
57
+ : range != null && iso >= range.start && iso <= range.end;
58
+ };
59
+ return (
60
+ <div {...rest}>
61
+ <CalendarRoot
62
+ value={anchor ?? range?.start ?? null}
63
+ defaultFocused={defaultFocused}
64
+ today={today}
65
+ locale={locale}
66
+ focusedDayRef={focusedDayRef}
67
+ isDateDisabled={disabled}
68
+ isDateSelected={selected}
69
+ onValueChange={choose}
70
+ >
71
+ {children}
72
+ </CalendarRoot>
73
+ <span role="status" aria-live="polite">
74
+ {announcement}
75
+ </span>
76
+ </div>
77
+ );
78
+ }
package/resizable.js CHANGED
@@ -10,8 +10,42 @@
10
10
  //
11
11
  // Almost every resizable panel on the web is pointer-only, which is a WCAG
12
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.
13
+ // If uf ships one the keyboard is the feature, so the keyboard was written
14
+ // first and shipped on its own: a keyboard-only splitter is a working
15
+ // splitter, where a pointer-only one is not.
16
+ //
17
+ // It was not the finished control. A bar between two panes is a bar that
18
+ // approximately everybody takes hold of first, and this one could not be
19
+ // moved that way at all. Both halves are here now, and the key map is exactly
20
+ // what it was — a drag that had quietly replaced it would be the first failure
21
+ // in the other direction.
22
+ //
23
+ // # The drag, and the step it does not use
24
+ //
25
+ // It is `slider.js`'s `Slider.Track` arithmetic measured against the *group's*
26
+ // box rather than a track's. `pointerdown` captures the pointer, so a drag
27
+ // that wanders off a bar four pixels wide — which every drag does — keeps
28
+ // arriving at the handle instead of being lost to whatever it wandered over;
29
+ // `pointermove` turns the position into a percentage from
30
+ // `getBoundingClientRect`; and the percentage goes through `internal/range.js`
31
+ // like every other value here, so a drag cannot leave the pane anywhere an
32
+ // arrow key could not put it, and `aria-valuenow` is true about it while it
33
+ // moves.
34
+ //
35
+ // Pressing the handle does not move it. A press on a *track* means "put the
36
+ // value here", which is what `Slider.Track` does with one and why it is half
37
+ // of WCAG 2.5.7 there; a press on a handle means "take hold of this". A
38
+ // splitter that also jumped by the distance between the pointer and its own
39
+ // centre would move a little every time it was clicked, which is the one thing
40
+ // a person who clicked it did not ask for.
41
+ //
42
+ // `step` stays the keyboard's, and the pointer has one of its own. `step`
43
+ // defaults to 10 because ten presses of an arrow key ought to cross the pane,
44
+ // and 10 is an absurd granularity for a bar being dragged under a pointer that
45
+ // moves smoothly. The pointer's is one percent, and it is a constant rather
46
+ // than a second prop because the value is already a percentage of the group:
47
+ // one is the smallest move that means anything, and a splitter announcing
48
+ // 47.382 would be reading out its arithmetic rather than its size.
15
49
  //
16
50
  // # The two separators in this package are not the same thing
17
51
  //
@@ -53,9 +87,9 @@
53
87
  //
54
88
  // Each pane carries its share as `--uf-resizable-size`, a percentage, and the
55
89
  // 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.
90
+ // or nothing at all. The group is measured rather than drawn — the drag reads
91
+ // its box and never writes to it — so a caller whose panes are flex children,
92
+ // grid tracks or absolutely positioned gets the same splitter.
59
93
 
60
94
  "use client";
61
95
 
@@ -71,11 +105,21 @@ import {
71
105
  } from "@uniflowed/react";
72
106
 
73
107
  import type { Rest } from "./internal/merge-props.js";
74
- import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
108
+ import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
75
109
  import type { Orientation } from "./internal/roving-focus.js";
76
- import { clamp, isReversed } from "./internal/range.js";
110
+ import { clamp, isReversed, snap } from "./internal/range.js";
77
111
  import { useControlled } from "./internal/controlled-state.js";
78
112
 
113
+ /**
114
+ * How finely a drag may move the splitter, in percentage points.
115
+ *
116
+ * Not `step`, which is the keyboard's and defaults to ten; the module header
117
+ * says why the two cannot be the same number. One is a constant rather than a
118
+ * prop because the value is already a percentage of the group, so one is the
119
+ * smallest move that means anything.
120
+ */
121
+ const POINTER_STEP = 1;
122
+
79
123
  type ResizableState = {|
80
124
  readonly base: string,
81
125
  /** The primary pane's share of the group, as a percentage. */
@@ -89,6 +133,14 @@ type ResizableState = {|
89
133
  readonly disabled: boolean,
90
134
  readonly hasPrimary: boolean,
91
135
  readonly registerPrimary: (present: boolean) => void,
136
+ /**
137
+ * The element a drag is measured against.
138
+ *
139
+ * The group rather than the handle, because the value is the primary pane's
140
+ * share *of the group* — the handle is a few pixels wide and has no idea how
141
+ * much space there is to divide.
142
+ */
143
+ readonly groupRef: { current: HTMLElement | null },
92
144
  |};
93
145
 
94
146
  const ResizableContext: React.Context<ResizableState | null> = createContext(null);
@@ -128,6 +180,7 @@ export component ResizablePanelGroup(
128
180
  const base = useId();
129
181
  const [share, setShare] = useControlled(value, defaultValue, onValueChange);
130
182
  const [hasPrimary, setHasPrimary] = useState(false);
183
+ const groupRef = useRef<HTMLElement | null>(null);
131
184
 
132
185
  const state = useMemo(
133
186
  () => ({
@@ -141,13 +194,23 @@ export component ResizablePanelGroup(
141
194
  disabled,
142
195
  hasPrimary,
143
196
  registerPrimary: setHasPrimary,
197
+ groupRef,
144
198
  }),
145
199
  [base, share, setShare, min, max, step, orientation, disabled, hasPrimary],
146
200
  );
147
201
 
202
+ const passed = withoutComposed(rest, ["ref"]);
203
+
148
204
  return (
149
205
  <ResizableContext.Provider value={state}>
150
- <div {...rest}>{children}</div>
206
+ <div
207
+ {...passed}
208
+ ref={composeRefs(rest.ref, (element) => {
209
+ groupRef.current = element;
210
+ })}
211
+ >
212
+ {children}
213
+ </div>
151
214
  </ResizableContext.Provider>
152
215
  );
153
216
  }
@@ -199,16 +262,61 @@ export component ResizablePanel(children: React.Node, primary?: boolean = false,
199
262
  */
200
263
  export component ResizableHandle(label?: string = "Resize", ...rest: Rest) {
201
264
  const group = useResizable("Resizable.Handle");
202
- const passed = withoutComposed(rest, ["onKeyDown"]);
265
+ const passed = withoutComposed(rest, [
266
+ "onKeyDown",
267
+ "onPointerCancel",
268
+ "onPointerDown",
269
+ "onPointerMove",
270
+ "onPointerUp",
271
+ ]);
203
272
  // Where the pane was before `Enter` collapsed it. A ref because nothing
204
273
  // renders it: it is a fact about the last keystroke, not about the layout.
205
274
  const restoreTo = useRef<number | null>(null);
275
+ // Whether the pointer is down on this handle. Also a ref, and for the same
276
+ // reason: it changes between renders and no render depends on it.
277
+ const dragging = useRef(false);
206
278
 
207
279
  const moveBy = (amount: number) => {
208
280
  restoreTo.current = null;
209
281
  group.setValue(clamp(group.value + amount, group.min, group.max));
210
282
  };
211
283
 
284
+ /** The primary pane's share at the pointer, or null with no box to read. */
285
+ const shareAt = (event: $FlowFixMe): number | null => {
286
+ const element = group.groupRef.current;
287
+ if (element == null) {
288
+ return null;
289
+ }
290
+ const box = element.getBoundingClientRect();
291
+ const vertical = group.orientation === "vertical";
292
+ const size = vertical ? box.height : box.width;
293
+ if (size <= 0) {
294
+ // A group with no box has no percentages in it, and dividing by its
295
+ // width would put `Infinity` into `aria-valuenow`.
296
+ return null;
297
+ }
298
+ // From the top for stacked panes, where `Slider.Track` reads from the
299
+ // bottom: a slider's minimum is at the bottom of its track, and a group's
300
+ // primary pane is the one *before* the handle, which is the top one.
301
+ const along = vertical ? event.clientY - box.top : event.clientX - box.left;
302
+ const part = clamp(along / size, 0, 1);
303
+ // In a right-to-left page the pane before the handle is the one on the
304
+ // right, so the reading runs the other way. `isReversed` mirrors nothing on
305
+ // a stacked group, because writing direction does not flip the vertical
306
+ // axis.
307
+ const forward = isReversed(element, group.orientation) ? 1 - part : part;
308
+ // The value *is* the percentage, so the position becomes one directly
309
+ // rather than being mapped across `min`–`max` the way a slider's is: those
310
+ // two are bounds on how far the pane may be dragged, not the ends of a
311
+ // scale. Mapping them would put the handle somewhere the pointer is not.
312
+ return snap(forward * 100, group.min, group.max, POINTER_STEP);
313
+ };
314
+
315
+ const endDrag = (event: $FlowFixMe) => {
316
+ dragging.current = false;
317
+ event.currentTarget?.releasePointerCapture?.(event.pointerId);
318
+ };
319
+
212
320
  return (
213
321
  <div
214
322
  {...passed}
@@ -224,6 +332,8 @@ export component ResizableHandle(label?: string = "Resize", ...rest: Rest) {
224
332
  aria-valuemax={group.max}
225
333
  aria-valuemin={group.min}
226
334
  aria-valuenow={group.value}
335
+ // Key and pointer handlers keep gesture state in refs between events.
336
+ // uf-lint-disable-next-line react-compiler/refs
227
337
  onKeyDown={composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
228
338
  if (group.disabled) {
229
339
  return;
@@ -265,6 +375,42 @@ export component ResizableHandle(label?: string = "Resize", ...rest: Rest) {
265
375
  moveBy(amount);
266
376
  }
267
377
  })}
378
+ // uf-lint-disable-next-line react-compiler/refs
379
+ onPointerCancel={composeHandlers(rest.onPointerCancel, endDrag)}
380
+ // uf-lint-disable-next-line react-compiler/refs
381
+ onPointerDown={composeHandlers(rest.onPointerDown, (event: $FlowFixMe) => {
382
+ if (group.disabled) {
383
+ return;
384
+ }
385
+ // Otherwise the press selects the text in the panes either side on the
386
+ // way past, so a drag paints half the page blue.
387
+ event.preventDefault();
388
+ // Which also means the browser will not focus this element, and a
389
+ // reader who has just dragged the splitter is the reader most likely to
390
+ // want an arrow key next.
391
+ event.currentTarget?.focus?.();
392
+ event.currentTarget?.setPointerCapture?.(event.pointerId);
393
+ dragging.current = true;
394
+ // A drag is a move, so the pane `Enter` would put back is no longer
395
+ // where it was. Leaving it would make the next `Enter` restore a size
396
+ // from before the drag.
397
+ restoreTo.current = null;
398
+ // Deliberately no value change: taking hold of the handle is not asking
399
+ // it to move. The module header says what a press does on a track
400
+ // instead, and why the two are not the same gesture.
401
+ })}
402
+ // uf-lint-disable-next-line react-compiler/refs
403
+ onPointerMove={composeHandlers(rest.onPointerMove, (event: $FlowFixMe) => {
404
+ if (!dragging.current) {
405
+ return;
406
+ }
407
+ const share = shareAt(event);
408
+ if (share != null) {
409
+ group.setValue(share);
410
+ }
411
+ })}
412
+ // uf-lint-disable-next-line react-compiler/refs
413
+ onPointerUp={composeHandlers(rest.onPointerUp, endDrag)}
268
414
  role="separator"
269
415
  // A separator that is not in the tab sequence is the WCAG 2.1.1 failure
270
416
  // this module exists to avoid.