@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/accordion.js +335 -0
- package/alert-dialog.js +256 -0
- package/carousel.js +410 -0
- package/checkbox.js +80 -0
- package/collapsible.js +147 -0
- package/combobox.js +557 -0
- package/dialog.js +499 -0
- package/drawer.js +458 -0
- package/field.js +170 -0
- package/hover-card.js +334 -0
- package/index.js +1012 -0
- package/input-otp.js +218 -0
- package/internal/anchor.js +500 -0
- package/internal/controlled-state.js +65 -0
- package/internal/disclosure.js +97 -0
- package/internal/focus.js +64 -0
- package/internal/form-value.js +83 -0
- package/internal/hover-intent.js +259 -0
- package/internal/merge-props.js +201 -0
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +430 -0
- package/menu.js +654 -0
- package/navigation-menu.js +251 -0
- package/package.json +57 -0
- package/pagination.js +197 -0
- package/popover.js +326 -0
- package/progress.js +86 -0
- package/radio-group.js +298 -0
- package/resizable.js +307 -0
- package/scroll-area.js +283 -0
- package/select.js +855 -0
- package/sheet.js +165 -0
- package/sidebar.js +300 -0
- package/slider.js +405 -0
- package/switch.js +73 -0
- package/table.js +479 -0
- package/tabs.js +280 -0
- package/toast.js +624 -0
- package/toggle-group.js +280 -0
- package/toggle.js +91 -0
- package/tooltip.js +411 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A value in a range: the arithmetic, and which way the arrow keys move it.
|
|
4
|
+
//
|
|
5
|
+
// Three components in this package report a number between two others —
|
|
6
|
+
// `slider.js`, `resizable.js` and `progress.js` — and all three make the same
|
|
7
|
+
// four promises through `aria-valuemin`, `aria-valuemax`, `aria-valuenow` and
|
|
8
|
+
// `aria-valuetext`. A reader is told a number, and the number has to be true:
|
|
9
|
+
// a thumb announced as 73 that the next `ArrowRight` moves to 75 has told them
|
|
10
|
+
// the step is 2 when it is 1, and a value announced outside its own bounds has
|
|
11
|
+
// told them the control is broken.
|
|
12
|
+
//
|
|
13
|
+
// So the arithmetic lives once. It is four small functions, and each of them
|
|
14
|
+
// is a rule that was got wrong somewhere before it was written down:
|
|
15
|
+
//
|
|
16
|
+
// * **Snapping is measured from the minimum, not from zero.** A slider from
|
|
17
|
+
// 5 to 100 in steps of 10 has values 5, 15, 25 — not 10, 20, 30. Rounding
|
|
18
|
+
// `value / step` produces the second list, and the reader who presses
|
|
19
|
+
// `Home` then `ArrowRight` lands on 10 from a minimum of 5, which is a
|
|
20
|
+
// first step of 5 on a slider that says its step is 10.
|
|
21
|
+
// * **Clamping happens after snapping.** Snapping a value near the top can
|
|
22
|
+
// push it past the maximum, and a slider whose `aria-valuenow` is greater
|
|
23
|
+
// than its `aria-valuemax` is a contradiction a screen reader reads out
|
|
24
|
+
// loud.
|
|
25
|
+
// * **Floating point has to be cleaned up.** `0.1 + 0.2` is not `0.3`, and a
|
|
26
|
+
// slider stepping by `0.1` announces `0.30000000000000004` — which is not
|
|
27
|
+
// a rounding error to the person hearing it, it is the control being
|
|
28
|
+
// absurd. The number is rounded to the precision the step implies.
|
|
29
|
+
// * **A fraction of an empty range is not a division.** `min === max` is a
|
|
30
|
+
// legal range with one value in it, and dividing by its width is `NaN`,
|
|
31
|
+
// which reaches the page as `left: NaN%` and lays the whole control out at
|
|
32
|
+
// the origin.
|
|
33
|
+
//
|
|
34
|
+
// # Which way is forward
|
|
35
|
+
//
|
|
36
|
+
// `ArrowRight` adds a step in a left-to-right page and subtracts one in a
|
|
37
|
+
// right-to-left page, because the reader is asking for "further along" and
|
|
38
|
+
// further along is the other way. `isReversed` answers that from the element
|
|
39
|
+
// the key arrived on, which is the answer `packages/ui/index.js` prescribes:
|
|
40
|
+
// the direction is something the DOM knows, so it is read in an event handler
|
|
41
|
+
// rather than provided through a context a caller has to remember to render.
|
|
42
|
+
//
|
|
43
|
+
// Both a `dir` attribute and the computed `direction` are consulted, in that
|
|
44
|
+
// order, and neither alone is enough. An attribute walk misses a page that
|
|
45
|
+
// sets `direction` only in CSS. The computed value misses a page whose host
|
|
46
|
+
// does not compute inherited `direction` at all, which includes the DOM these
|
|
47
|
+
// tests run on — so a control that trusted it alone would pass every test and
|
|
48
|
+
// walk the wrong way in the one place it mattered. The one arrangement not
|
|
49
|
+
// answered is a `dir="rtl"` an author then contradicts with a CSS
|
|
50
|
+
// `direction: ltr`, which is a page disagreeing with itself.
|
|
51
|
+
//
|
|
52
|
+
// The same question is open for `movementFor` in `internal/roving-focus.js`,
|
|
53
|
+
// where a horizontal `Tabs.List` still walks the wrong way for an RTL reader —
|
|
54
|
+
// ubugeeei-prod/uf#253, which now has this to call rather than a second copy
|
|
55
|
+
// of it to write.
|
|
56
|
+
//
|
|
57
|
+
// # Why this is `internal/` and not a subpath
|
|
58
|
+
//
|
|
59
|
+
// `clamp` and a stepping function are the two most generic names in
|
|
60
|
+
// programming, and exporting them from a UI package would be publishing a
|
|
61
|
+
// numeric utility library under the wrong name. What is here is narrower than
|
|
62
|
+
// it looks: it is the arithmetic that keeps this package's four ARIA value
|
|
63
|
+
// attributes true about each other, and it is worth nothing to anyone who is
|
|
64
|
+
// not writing one of those components.
|
|
65
|
+
|
|
66
|
+
import type { Orientation } from "./roving-focus.js";
|
|
67
|
+
|
|
68
|
+
/** `value`, held inside `[lower, upper]`. */
|
|
69
|
+
export function clamp(value: number, lower: number, upper: number): number {
|
|
70
|
+
if (value < lower) {
|
|
71
|
+
return lower;
|
|
72
|
+
}
|
|
73
|
+
return value > upper ? upper : value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `value`, moved onto the nearest step and then held inside the range.
|
|
78
|
+
*
|
|
79
|
+
* The steps start at `lower`, not at zero. A `step` of zero or less means the
|
|
80
|
+
* value is continuous, which is what a slider bound to a pixel measurement
|
|
81
|
+
* wants, and dividing by it would be the other kind of infinity.
|
|
82
|
+
*/
|
|
83
|
+
export function snap(value: number, lower: number, upper: number, step: number): number {
|
|
84
|
+
if (step <= 0) {
|
|
85
|
+
return clamp(value, lower, upper);
|
|
86
|
+
}
|
|
87
|
+
const stepped = lower + Math.round((value - lower) / step) * step;
|
|
88
|
+
// Snapping can overshoot the top when the range is not a whole number of
|
|
89
|
+
// steps — 0 to 95 by 10 rounds 95 up to 100 — and an `aria-valuenow` above
|
|
90
|
+
// `aria-valuemax` is a contradiction a screen reader reads out loud.
|
|
91
|
+
return clamp(round(stepped, step), lower, upper);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* `value` as a fraction of the range, for a caller to draw with.
|
|
96
|
+
*
|
|
97
|
+
* `0` for an empty range rather than the `NaN` the division gives, because
|
|
98
|
+
* `min === max` is a legal range with exactly one value in it and `NaN`
|
|
99
|
+
* reaches the page as `left: NaN%`.
|
|
100
|
+
*/
|
|
101
|
+
export function fraction(value: number, lower: number, upper: number): number {
|
|
102
|
+
const width = upper - lower;
|
|
103
|
+
return width <= 0 ? 0 : clamp((value - lower) / width, 0, 1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Whether a positive step moves *backwards* along `orientation` on this page.
|
|
108
|
+
*
|
|
109
|
+
* Only ever true for a horizontal control in a right-to-left page: vertical
|
|
110
|
+
* axes are not mirrored by writing direction, and `Home` and `End` are the
|
|
111
|
+
* first and last value in both directions rather than the left and right one.
|
|
112
|
+
*/
|
|
113
|
+
export function isReversed(element: HTMLElement | null, orientation: Orientation): boolean {
|
|
114
|
+
if (element == null || orientation !== "horizontal") {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
// The attribute first, because it is the answer every host agrees on.
|
|
118
|
+
// Inherited `direction` is something a DOM implementation may decline to
|
|
119
|
+
// compute — uf's own test DOM reports `ltr` for an element inside
|
|
120
|
+
// `dir="rtl"` — and a control that reads only the computed value walks the
|
|
121
|
+
// wrong way there while looking correct everywhere it was tried by hand.
|
|
122
|
+
const declared = element.closest("[dir]")?.getAttribute("dir")?.toLowerCase();
|
|
123
|
+
if (declared === "rtl" || declared === "ltr") {
|
|
124
|
+
return declared === "rtl";
|
|
125
|
+
}
|
|
126
|
+
// No `dir` anywhere above it, so the page either is left-to-right or said so
|
|
127
|
+
// in CSS, and only the computed value can tell the two apart.
|
|
128
|
+
const view: $FlowFixMe = element.ownerDocument?.defaultView;
|
|
129
|
+
return view?.getComputedStyle?.(element)?.direction === "rtl";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `value` with the digits `step` cannot reach removed.
|
|
134
|
+
*
|
|
135
|
+
* Stepping by `0.1` from `0` reaches `0.30000000000000004`, which a screen
|
|
136
|
+
* reader says in full. The number of decimals `step` implies is how many the
|
|
137
|
+
* value is allowed to have.
|
|
138
|
+
*/
|
|
139
|
+
function round(value: number, step: number): number {
|
|
140
|
+
const text = String(step);
|
|
141
|
+
const point = text.indexOf(".");
|
|
142
|
+
if (point < 0) {
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
const decimals = text.length - point - 1;
|
|
146
|
+
return Number(value.toFixed(decimals));
|
|
147
|
+
}
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// The keyboard pattern shared by every list of things in this package.
|
|
4
|
+
//
|
|
5
|
+
// A tab list, a menu and a listbox look nothing alike and behave identically at
|
|
6
|
+
// the keyboard, because WAI-ARIA says they must: the *set* takes one stop in the
|
|
7
|
+
// page's tab order, and the arrow keys move within it. That is what makes a
|
|
8
|
+
// twelve-item menu something a keyboard user passes in one Tab press instead of
|
|
9
|
+
// twelve, and it is the part hand-written components leave out.
|
|
10
|
+
//
|
|
11
|
+
// Five rules make it up, and each one has a way of being got wrong that no
|
|
12
|
+
// screenshot shows:
|
|
13
|
+
//
|
|
14
|
+
// * **Document order, read from the document.** Items are found by querying
|
|
15
|
+
// the container at the moment a key is pressed, not from a registry the
|
|
16
|
+
// items push themselves into as they mount. Mount order is not document
|
|
17
|
+
// order the moment a list is filtered, reordered, or has a conditional item
|
|
18
|
+
// in the middle of it — and a registry that disagrees with the page sends
|
|
19
|
+
// the arrow keys somewhere the reader is not.
|
|
20
|
+
// * **Nesting.** A submenu's items are inside its parent menu's element, so
|
|
21
|
+
// "the items of this menu" cannot be `querySelectorAll` alone. An item
|
|
22
|
+
// belongs to the nearest container of its own kind.
|
|
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.
|
|
29
|
+
// * **Typeahead.** Pressing `r` in a menu goes to Refresh. Without it a menu
|
|
30
|
+
// of thirty items is thirty arrow presses, and every native menu on every
|
|
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.
|
|
37
|
+
//
|
|
38
|
+
// # Why this is `internal/` and not a subpath
|
|
39
|
+
//
|
|
40
|
+
// It is a description of DOM structure this package owns — that a tab lives
|
|
41
|
+
// under `[role="tablist"]`, that a menu item's owner is `[role="menu"]` — and
|
|
42
|
+
// those relationships are only guaranteed because the components in this
|
|
43
|
+
// package build them. Handed to a consumer it would be a set of selectors that
|
|
44
|
+
// happen to work today, which is a different and much weaker promise than the
|
|
45
|
+
// one the components make.
|
|
46
|
+
|
|
47
|
+
import { useCallback, useEffect, useRef, useState } from "@uniflowed/react";
|
|
48
|
+
|
|
49
|
+
/** Which way a key asks the focus to move within a set. */
|
|
50
|
+
export type Movement = "previous" | "next" | "first" | "last";
|
|
51
|
+
|
|
52
|
+
/** The axis a set's arrow keys run along. */
|
|
53
|
+
export type Orientation = "horizontal" | "vertical";
|
|
54
|
+
|
|
55
|
+
/** Which way the inline axis runs where a set sits: the reader's direction. */
|
|
56
|
+
export type Direction = "ltr" | "rtl";
|
|
57
|
+
|
|
58
|
+
/** How long a typeahead buffer survives without another key, in milliseconds. */
|
|
59
|
+
const TYPEAHEAD_WINDOW = 500;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The items directly belonging to `container`, in document order.
|
|
63
|
+
*
|
|
64
|
+
* `owner` names the container's own kind — `[role="menu"]` for a menu — so an
|
|
65
|
+
* item inside a *nested* container of that kind is left to the nested one. A
|
|
66
|
+
* plain `querySelectorAll` returns a submenu's items as if they were the parent
|
|
67
|
+
* menu's, which makes `ArrowDown` in the parent step into a menu the reader
|
|
68
|
+
* cannot see.
|
|
69
|
+
*/
|
|
70
|
+
export function itemsOf(container: HTMLElement, item: string, owner: string): Array<HTMLElement> {
|
|
71
|
+
return Array.from(container.querySelectorAll(item)).filter(
|
|
72
|
+
(element: $FlowFixMe) => element.closest(owner) === container,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether the keyboard may land on this item.
|
|
78
|
+
*
|
|
79
|
+
* Both spellings, because the two mean different things and this package uses
|
|
80
|
+
* both: a native `disabled` takes an element out of the accessibility tree's
|
|
81
|
+
* reach, while `aria-disabled` leaves it announced — which is what a menu item
|
|
82
|
+
* or a tab wants, so a reader can tell the option exists and is unavailable
|
|
83
|
+
* rather than finding a gap where it used to be.
|
|
84
|
+
*/
|
|
85
|
+
export function isEnabled(element: HTMLElement): boolean {
|
|
86
|
+
return (
|
|
87
|
+
(element as $FlowFixMe).disabled !== true && element.getAttribute("aria-disabled") !== "true"
|
|
88
|
+
);
|
|
89
|
+
}
|
|
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
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The movement a key asks for along `orientation`, or nothing if it is not ours.
|
|
138
|
+
*
|
|
139
|
+
* The unhandled keys matter as much as the handled ones. `ArrowDown` inside a
|
|
140
|
+
* *horizontal* tab list belongs to the page — it scrolls — and a component that
|
|
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.
|
|
148
|
+
*/
|
|
149
|
+
export function movementFor(
|
|
150
|
+
key: string,
|
|
151
|
+
orientation: Orientation,
|
|
152
|
+
direction: Direction,
|
|
153
|
+
): Movement | null {
|
|
154
|
+
const rtl = direction === "rtl";
|
|
155
|
+
return match (key) {
|
|
156
|
+
"Home" => "first",
|
|
157
|
+
"End" => "last",
|
|
158
|
+
"ArrowUp" => orientation === "vertical" ? "previous" : null,
|
|
159
|
+
"ArrowDown" => orientation === "vertical" ? "next" : null,
|
|
160
|
+
"ArrowLeft" => orientation === "horizontal" ? (rtl ? "next" : "previous") : null,
|
|
161
|
+
"ArrowRight" => orientation === "horizontal" ? (rtl ? "previous" : "next") : null,
|
|
162
|
+
_ => null,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The item `movement` reaches from `from`, skipping disabled ones.
|
|
168
|
+
*
|
|
169
|
+
* `from` may be `-1` for "nothing is focused yet", which is what makes
|
|
170
|
+
* `ArrowDown` on a freshly opened menu land on the first item. `wrap` is false
|
|
171
|
+
* for a set where running off the end should stop rather than cycle.
|
|
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
|
+
*
|
|
182
|
+
* Returns null when every item is disabled, or when the ends are closed and
|
|
183
|
+
* there is nothing further in that direction — in both cases the caller should
|
|
184
|
+
* leave focus where it is rather than move it somewhere arbitrary.
|
|
185
|
+
*/
|
|
186
|
+
export function moveTo(
|
|
187
|
+
items: $ReadOnlyArray<HTMLElement>,
|
|
188
|
+
from: number,
|
|
189
|
+
movement: Movement,
|
|
190
|
+
wrap: boolean,
|
|
191
|
+
skipDisabled?: boolean = true,
|
|
192
|
+
): HTMLElement | null {
|
|
193
|
+
const count = items.length;
|
|
194
|
+
if (count === 0) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
// Two things this expression is careful about, each of which was a bug.
|
|
198
|
+
//
|
|
199
|
+
// The direction is part of the answer rather than derived from it: `last`
|
|
200
|
+
// aims at the end and searches *backwards* from there, and deriving
|
|
201
|
+
// "forwards" from the target being ahead of `from` sent `End` past the end
|
|
202
|
+
// and around to the first item whenever the last one was disabled.
|
|
203
|
+
//
|
|
204
|
+
// And `from` is -1 when nothing is focused yet, which the two directions read
|
|
205
|
+
// differently: "next" from nowhere is the first item, and "previous" from
|
|
206
|
+
// nowhere is the *last* one. Letting -1 fall through the arithmetic aimed
|
|
207
|
+
// `previous` at -2, which wraps to `count - 2` — so `ArrowUp` on a freshly
|
|
208
|
+
// opened list landed one short of the end, and on a two-item list landed on
|
|
209
|
+
// the first item.
|
|
210
|
+
const aim = match (movement) {
|
|
211
|
+
"previous" => [from < 0 ? count - 1 : from - 1, -1],
|
|
212
|
+
"next" => [from + 1, 1],
|
|
213
|
+
"first" => [0, 1],
|
|
214
|
+
"last" => [count - 1, -1],
|
|
215
|
+
};
|
|
216
|
+
const [target, direction] = aim;
|
|
217
|
+
|
|
218
|
+
for (let tried = 0; tried < count; tried += 1) {
|
|
219
|
+
const at = target + tried * direction;
|
|
220
|
+
if (!wrap && (at < 0 || at >= count)) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
const candidate = items[((at % count) + count) % count];
|
|
224
|
+
if (!skipDisabled || isEnabled(candidate)) {
|
|
225
|
+
return candidate;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The index of the focused item, or `-1` when focus is elsewhere. */
|
|
232
|
+
export function indexOfActive(items: $ReadOnlyArray<HTMLElement>, active: mixed): number {
|
|
233
|
+
return items.findIndex((item) => item === active);
|
|
234
|
+
}
|
|
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
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Match items by the characters a reader types, the way every native menu does.
|
|
357
|
+
*
|
|
358
|
+
* The returned function is stable, so a component may pass it straight to a key
|
|
359
|
+
* handler without re-subscribing anything. The buffer lives in a ref and is only
|
|
360
|
+
* ever touched from an event handler — never during a render, where a value that
|
|
361
|
+
* depends on how many times React chose to render is a bug waiting for
|
|
362
|
+
* Strict Mode to find it.
|
|
363
|
+
*
|
|
364
|
+
* Two behaviours people notice when they are missing:
|
|
365
|
+
*
|
|
366
|
+
* * Typing `s`, `a`, `v` within half a second looks for "sav", not for three
|
|
367
|
+
* separate items starting with `s`, `a` and `v`.
|
|
368
|
+
* * Pressing the *same* letter repeatedly cycles through the items starting
|
|
369
|
+
* with it, which is how a reader reaches the second "Save as…".
|
|
370
|
+
*/
|
|
371
|
+
export hook useTypeahead(): (
|
|
372
|
+
items: $ReadOnlyArray<HTMLElement>,
|
|
373
|
+
from: number,
|
|
374
|
+
key: string,
|
|
375
|
+
) => HTMLElement | null {
|
|
376
|
+
const buffer = useRef<{| text: string, at: number |}>({ text: "", at: 0 });
|
|
377
|
+
|
|
378
|
+
return useCallback(
|
|
379
|
+
(items: $ReadOnlyArray<HTMLElement>, from: number, key: string): HTMLElement | null => {
|
|
380
|
+
const now = Date.now();
|
|
381
|
+
const text = now - buffer.current.at > TYPEAHEAD_WINDOW ? key : buffer.current.text + key;
|
|
382
|
+
buffer.current = { text, at: now };
|
|
383
|
+
|
|
384
|
+
const repeated = text.length > 1 && text.split("").every((each) => each === text[0]);
|
|
385
|
+
const needle = (repeated ? text[0] : text).toLowerCase();
|
|
386
|
+
// A single character — or the same one again — moves on from where we
|
|
387
|
+
// are. A longer buffer starts *at* the current item, so typing "sa" after
|
|
388
|
+
// "s" can keep the item "s" already found.
|
|
389
|
+
const start = repeated || text.length === 1 ? from + 1 : Math.max(from, 0);
|
|
390
|
+
|
|
391
|
+
for (let tried = 0; tried < items.length; tried += 1) {
|
|
392
|
+
const candidate = items[(((start + tried) % items.length) + items.length) % items.length];
|
|
393
|
+
if (isEnabled(candidate) && labelOf(candidate).startsWith(needle)) {
|
|
394
|
+
return candidate;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return null;
|
|
398
|
+
},
|
|
399
|
+
[],
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Whether a key press is a character a reader meant to type.
|
|
405
|
+
*
|
|
406
|
+
* Modifier combinations are excluded because `Ctrl+P` is the browser's, and a
|
|
407
|
+
* component that treats it as "the letter p" both steals the shortcut and jumps
|
|
408
|
+
* the selection somewhere the reader did not ask for.
|
|
409
|
+
*/
|
|
410
|
+
export function isTypeaheadKey(event: {
|
|
411
|
+
readonly key: string,
|
|
412
|
+
readonly altKey?: boolean,
|
|
413
|
+
readonly ctrlKey?: boolean,
|
|
414
|
+
readonly metaKey?: boolean,
|
|
415
|
+
...
|
|
416
|
+
}): boolean {
|
|
417
|
+
return (
|
|
418
|
+
event.key.length === 1 &&
|
|
419
|
+
event.key !== " " &&
|
|
420
|
+
event.altKey !== true &&
|
|
421
|
+
event.ctrlKey !== true &&
|
|
422
|
+
event.metaKey !== true
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** What a reader hears for this item, lower-cased for matching. */
|
|
427
|
+
function labelOf(element: HTMLElement): string {
|
|
428
|
+
const spoken = element.getAttribute("aria-label") ?? element.textContent ?? "";
|
|
429
|
+
return spoken.replace(/\s+/g, " ").trim().toLowerCase();
|
|
430
|
+
}
|