@uniflowed/ui 0.0.0-alpha.4 → 0.0.0-alpha.40

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 (52) hide show
  1. package/accordion.js +360 -0
  2. package/alert-dialog.js +282 -0
  3. package/alert.js +142 -0
  4. package/avatar.js +276 -0
  5. package/breadcrumb.js +138 -0
  6. package/calendar.js +547 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +216 -31
  9. package/collapsible.js +169 -0
  10. package/combobox.js +209 -40
  11. package/context-menu.js +206 -0
  12. package/date-picker.js +346 -0
  13. package/dialog.js +229 -197
  14. package/drawer.js +490 -0
  15. package/field.js +257 -42
  16. package/hover-card.js +330 -0
  17. package/index.js +1548 -24
  18. package/input-otp.js +218 -0
  19. package/interactions.js +2323 -0
  20. package/internal/anchor.js +565 -0
  21. package/internal/date-grid.js +260 -0
  22. package/internal/disclosure.js +298 -0
  23. package/internal/focus.js +64 -0
  24. package/internal/form-value.js +83 -0
  25. package/internal/hover-intent.js +259 -0
  26. package/internal/menu-tree.js +228 -0
  27. package/internal/merge-props.js +206 -7
  28. package/internal/range.js +147 -0
  29. package/internal/roving-focus.js +205 -11
  30. package/menu.js +521 -336
  31. package/menubar.js +288 -0
  32. package/navigation-menu.js +251 -0
  33. package/package.json +8 -12
  34. package/pagination.js +209 -0
  35. package/popover.js +344 -0
  36. package/progress.js +91 -0
  37. package/radio-group.js +302 -0
  38. package/resizable.js +447 -0
  39. package/scroll-area.js +283 -0
  40. package/select.js +888 -0
  41. package/separator.js +97 -0
  42. package/sheet.js +189 -0
  43. package/sidebar.js +313 -0
  44. package/skeleton.js +159 -0
  45. package/slider.js +405 -0
  46. package/switch.js +43 -34
  47. package/table.js +520 -0
  48. package/tabs.js +99 -96
  49. package/toast.js +592 -0
  50. package/toggle-group.js +282 -0
  51. package/toggle.js +105 -0
  52. package/tooltip.js +400 -0
@@ -8,7 +8,7 @@
8
8
  // twelve-item menu something a keyboard user passes in one Tab press instead of
9
9
  // twelve, and it is the part hand-written components leave out.
10
10
  //
11
- // Four rules make it up, and each one has a way of being got wrong that no
11
+ // Five rules make it up, and each one has a way of being got wrong that no
12
12
  // screenshot shows:
13
13
  //
14
14
  // * **Document order, read from the document.** Items are found by querying
@@ -20,14 +20,20 @@
20
20
  // * **Nesting.** A submenu's items are inside its parent menu's element, so
21
21
  // "the items of this menu" cannot be `querySelectorAll` alone. An item
22
22
  // belongs to the nearest container of its own kind.
23
- // * **Disabled is skipped, not landed on.** And the direction to keep
24
- // searching in cannot be inferred from the target index: `End` aims at the
25
- // last item and, if that one is disabled, has to walk *backwards*. Guessing
26
- // "forwards, because the target is ahead of us" wrapped `End` around to the
27
- // first item.
23
+ // * **Disabled is skipped, not landed on** — in a set with a roving tab stop,
24
+ // which is not all of them; `moveTo` says which and why. And the direction
25
+ // to keep searching in cannot be inferred from the target index: `End` aims
26
+ // at the last item and, if that one is disabled, has to walk *backwards*.
27
+ // Guessing "forwards, because the target is ahead of us" wrapped `End`
28
+ // around to the first item.
28
29
  // * **Typeahead.** Pressing `r` in a menu goes to Refresh. Without it a menu
29
30
  // of thirty items is thirty arrow presses, and every native menu on every
30
31
  // platform has had this since before the web.
32
+ // * **The horizontal arrows point at the reader's "next", not at the west.**
33
+ // In a right-to-left page the first item of a row is the rightmost one, so
34
+ // `ArrowLeft` is *next* and `ArrowRight` is *previous*. Hard-coding the
35
+ // left-to-right answer renders identically and walks an Arabic, Hebrew,
36
+ // Persian or Urdu reader backwards through every set in this package.
31
37
  //
32
38
  // # Why this is `internal/` and not a subpath
33
39
  //
@@ -38,7 +44,7 @@
38
44
  // happen to work today, which is a different and much weaker promise than the
39
45
  // one the components make.
40
46
 
41
- import { useCallback, useRef } from "@uniflowed/react";
47
+ import { useCallback, useEffect, useRef, useState } from "@uniflowed/react";
42
48
 
43
49
  /** Which way a key asks the focus to move within a set. */
44
50
  export type Movement = "previous" | "next" | "first" | "last";
@@ -46,6 +52,9 @@ export type Movement = "previous" | "next" | "first" | "last";
46
52
  /** The axis a set's arrow keys run along. */
47
53
  export type Orientation = "horizontal" | "vertical";
48
54
 
55
+ /** Which way the inline axis runs where a set sits: the reader's direction. */
56
+ export type Direction = "ltr" | "rtl";
57
+
49
58
  /** How long a typeahead buffer survives without another key, in milliseconds. */
50
59
  const TYPEAHEAD_WINDOW = 500;
51
60
 
@@ -79,21 +88,77 @@ export function isEnabled(element: HTMLElement): boolean {
79
88
  );
80
89
  }
81
90
 
91
+ /**
92
+ * Which way the page reads where `element` sits.
93
+ *
94
+ * Every caller asks from inside a `keydown` handler, which is a moment where
95
+ * reading the document is legitimate — so no direction prop, no context and no
96
+ * provider: the answer is already in the DOM, and asking it there means a
97
+ * component nested under someone else's `dir="rtl"` is right without anybody
98
+ * having had to thread a value down to it.
99
+ *
100
+ * # Two questions, because one of them is not answered everywhere
101
+ *
102
+ * `getComputedStyle(element).direction` is the whole answer in a browser: the
103
+ * HTML user-agent stylesheet carries `[dir="rtl" i] { direction: rtl }`, so the
104
+ * computed value accounts for a `dir` attribute on any ancestor *and* for a CSS
105
+ * `direction` a caller wrote, which an attribute walk alone would miss. It is
106
+ * what this was written as first, and it is wrong on its own here: `happy-dom`,
107
+ * the DOM this package's own tests run in, ships no user-agent stylesheet for
108
+ * `[dir]`, so a button inside `<div dir="rtl">` computes `ltr` and every RTL
109
+ * test passed for the wrong reason. `element.matches(":dir(rtl)")` — the
110
+ * pseudo-class HTML defines directionality against — answers `false` there too,
111
+ * silently, which makes it the worse of the two to rely on.
112
+ *
113
+ * So the `dir` attribute is asked first and the computed style second, and the
114
+ * order is the useful one rather than a workaround: `dir` is HTML's own
115
+ * statement about directionality and the thing an RTL page actually sets, while
116
+ * `closest` stops at the *nearest* ancestor that carries one, so a `dir="ltr"`
117
+ * island inside a `dir="rtl"` page reads as `ltr`. `dir="auto"` is deliberately
118
+ * not an answer — it means "work it out from the content", which only the
119
+ * layout engine can do — so it falls through to the computed style, where a
120
+ * browser has already worked it out.
121
+ *
122
+ * The cost is one `closest` per arrow key press, plus one `getComputedStyle` on
123
+ * a page that declares no `dir` at all — which is most left-to-right pages.
124
+ * Both are paid at the rate a person presses arrow keys, so neither was worth
125
+ * caching behind a context that could then be stale.
126
+ */
127
+ export function directionOf(element: HTMLElement): Direction {
128
+ const declared = element.closest("[dir]")?.getAttribute("dir")?.toLowerCase();
129
+ if (declared === "rtl" || declared === "ltr") {
130
+ return declared;
131
+ }
132
+ const style: $FlowFixMe = element.ownerDocument?.defaultView?.getComputedStyle?.(element);
133
+ return style?.direction === "rtl" ? "rtl" : "ltr";
134
+ }
135
+
82
136
  /**
83
137
  * The movement a key asks for along `orientation`, or nothing if it is not ours.
84
138
  *
85
139
  * The unhandled keys matter as much as the handled ones. `ArrowDown` inside a
86
140
  * *horizontal* tab list belongs to the page — it scrolls — and a component that
87
141
  * swallows it has taken a key away from every reader who uses it to read.
142
+ *
143
+ * `direction` mirrors the horizontal pair and nothing else. `ArrowUp` and
144
+ * `ArrowDown` are unaffected because a right-to-left page still runs top to
145
+ * bottom, and `Home` and `End` are unaffected because they name the first and
146
+ * last item in *reading* order, which is what `moveTo` already walks: in an RTL
147
+ * row the first item is the rightmost one, and `Home` should go to it.
88
148
  */
89
- export function movementFor(key: string, orientation: Orientation): Movement | null {
149
+ export function movementFor(
150
+ key: string,
151
+ orientation: Orientation,
152
+ direction: Direction,
153
+ ): Movement | null {
154
+ const rtl = direction === "rtl";
90
155
  return match (key) {
91
156
  "Home" => "first",
92
157
  "End" => "last",
93
158
  "ArrowUp" => orientation === "vertical" ? "previous" : null,
94
159
  "ArrowDown" => orientation === "vertical" ? "next" : null,
95
- "ArrowLeft" => orientation === "horizontal" ? "previous" : null,
96
- "ArrowRight" => orientation === "horizontal" ? "next" : null,
160
+ "ArrowLeft" => orientation === "horizontal" ? (rtl ? "next" : "previous") : null,
161
+ "ArrowRight" => orientation === "horizontal" ? (rtl ? "previous" : "next") : null,
97
162
  _ => null,
98
163
  };
99
164
  }
@@ -105,6 +170,15 @@ export function movementFor(key: string, orientation: Orientation): Movement | n
105
170
  * `ArrowDown` on a freshly opened menu land on the first item. `wrap` is false
106
171
  * for a set where running off the end should stop rather than cycle.
107
172
  *
173
+ * `skipDisabled` is true for every set with a roving tab stop, where an
174
+ * unavailable item is announced and stepped over. It is false for an accordion,
175
+ * and that is not a preference: an accordion's headers are ordinary buttons in
176
+ * the page's tab order, so `Tab` reaches every one of them, and arrow keys that
177
+ * stepped over one would disagree with `Tab` about which headers exist. The
178
+ * item they would step over is the open section's own header, which
179
+ * `aria-disabled` marks as "pressing this closes nothing" rather than "there is
180
+ * nothing here".
181
+ *
108
182
  * Returns null when every item is disabled, or when the ends are closed and
109
183
  * there is nothing further in that direction — in both cases the caller should
110
184
  * leave focus where it is rather than move it somewhere arbitrary.
@@ -114,6 +188,7 @@ export function moveTo(
114
188
  from: number,
115
189
  movement: Movement,
116
190
  wrap: boolean,
191
+ skipDisabled?: boolean = true,
117
192
  ): HTMLElement | null {
118
193
  const count = items.length;
119
194
  if (count === 0) {
@@ -146,7 +221,7 @@ export function moveTo(
146
221
  return null;
147
222
  }
148
223
  const candidate = items[((at % count) + count) % count];
149
- if (isEnabled(candidate)) {
224
+ if (!skipDisabled || isEnabled(candidate)) {
150
225
  return candidate;
151
226
  }
152
227
  }
@@ -158,6 +233,125 @@ export function indexOfActive(items: $ReadOnlyArray<HTMLElement>, active: mixed)
158
233
  return items.findIndex((item) => item === active);
159
234
  }
160
235
 
236
+ /**
237
+ * Which items a container owns and how the keyboard runs across them.
238
+ *
239
+ * The two selectors are the pair `itemsOf` needs — what an item is, and what
240
+ * owns one — kept together because giving a set only the first of them is how a
241
+ * nested set steals its parent's items.
242
+ */
243
+ export type RovingSet = {|
244
+ readonly item: string,
245
+ readonly owner: string,
246
+ readonly orientation: Orientation,
247
+ /** Whether running off the end cycles or stops. */
248
+ readonly wrap: boolean,
249
+ /** Whether an `aria-disabled` item is stepped over; see `moveTo`. */
250
+ readonly skipDisabled: boolean,
251
+ |};
252
+
253
+ /** The part of a key event a set reads, and the right to claim the key. */
254
+ type KeyPress = {
255
+ readonly key: string,
256
+ readonly preventDefault: () => mixed,
257
+ ...
258
+ };
259
+
260
+ /**
261
+ * Move focus within `container` for one key press, and say where it went.
262
+ *
263
+ * Returns the item focus moved to, or null when the key was not one of the
264
+ * set's — `ArrowDown` in a horizontal set, a letter, `Tab` — or when there was
265
+ * nowhere for it to go. A null answer is a key the caller has not claimed, so
266
+ * the page still gets it.
267
+ *
268
+ * This is the whole of the container half of a roving tab stop, written once
269
+ * because the order of the last three lines is not obvious and getting it wrong
270
+ * is invisible: the key has to be claimed *before* focus moves, or the browser
271
+ * scrolls the page under the item that has just taken focus, and the reader
272
+ * ends up looking somewhere else entirely. Every set in this package that is
273
+ * only arrows — a tab list, a radio group, a toggle group — is this function
274
+ * plus what it does with the answer. `Menu.Body` is deliberately not: its keys
275
+ * interleave with `Escape`, `Tab` and typeahead, and it has to stop events
276
+ * propagating between nested menus, which is a different job.
277
+ */
278
+ export function moveOnKey(
279
+ event: KeyPress,
280
+ container: HTMLElement,
281
+ set: RovingSet,
282
+ ): HTMLElement | null {
283
+ const movement = movementFor(event.key, set.orientation, directionOf(container));
284
+ if (movement == null) {
285
+ return null;
286
+ }
287
+ const items = itemsOf(container, set.item, set.owner);
288
+ const next = moveTo(
289
+ items,
290
+ indexOfActive(items, container.ownerDocument?.activeElement),
291
+ movement,
292
+ set.wrap,
293
+ set.skipDisabled,
294
+ );
295
+ if (next == null) {
296
+ return null;
297
+ }
298
+ event.preventDefault();
299
+ next.focus();
300
+ return next;
301
+ }
302
+
303
+ /**
304
+ * The id of the first item the keyboard may land on, or null for none.
305
+ *
306
+ * This answers the one question a roving set cannot answer during a render:
307
+ * which item holds the tab stop before anything has claimed it. A tab list
308
+ * never has that state, because a selection is required — but a radio group
309
+ * with nothing chosen does, and a toggle group nobody has focused does, and
310
+ * getting it wrong is not a cosmetic loss: with no item at `tabindex="0"` the
311
+ * whole set is unreachable by `Tab`, which is the failure worth the machinery.
312
+ *
313
+ * It is a fact about the document, so it is read from the document in an effect
314
+ * and put in state because a render depends on the answer — the rule
315
+ * `index.js` states for the package. `wanted` turns it off: the moment
316
+ * something is chosen or focused, that item holds the tab stop and this is work
317
+ * with no reader.
318
+ *
319
+ * The effect has no dependency array on purpose. What comes first changes when
320
+ * the caller renders a different set of items or disables one, and neither of
321
+ * those is anything this hook is handed — a dependency list here would be a
322
+ * claim about when the document changes that only the caller could keep, and it
323
+ * would be wrong exactly when a caller made their first item conditional. The
324
+ * cost is one `querySelectorAll` over a set that is small by construction, only
325
+ * while nothing is chosen; `setState` with an unchanged id renders nothing.
326
+ */
327
+ export hook useFirstItem(
328
+ container: { current: HTMLElement | null },
329
+ set: RovingSet,
330
+ wanted: boolean,
331
+ ): string | null {
332
+ const [first, setFirst] = useState<string | null>(null);
333
+
334
+ useEffect(() => {
335
+ const root = container.current;
336
+ if (!wanted || root == null) {
337
+ return;
338
+ }
339
+ // `moveTo` rather than `items[0]`, so a disabled first item is stepped over
340
+ // here exactly as the arrow keys step over it: a group whose first choice
341
+ // is unavailable must still be reachable.
342
+ const landing = moveTo(
343
+ itemsOf(root, set.item, set.owner),
344
+ -1,
345
+ "first",
346
+ false,
347
+ set.skipDisabled,
348
+ );
349
+ setFirst(landing?.id ?? null);
350
+ });
351
+
352
+ return wanted ? first : null;
353
+ }
354
+
161
355
  /**
162
356
  * Match items by the characters a reader types, the way every native menu does.
163
357
  *