@uniflowed/ui 0.0.0-alpha.9 → 0.2.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.
- package/accordion.js +84 -57
- package/alert-dialog.js +284 -0
- package/alert.js +142 -0
- package/avatar.js +280 -0
- package/breadcrumb.js +138 -0
- package/calendar.js +587 -0
- package/carousel.js +410 -0
- package/checkbox.js +215 -31
- package/collapsible.js +72 -48
- package/color-picker.js +172 -0
- package/combobox.js +216 -39
- package/context-menu.js +215 -0
- package/date-field.js +9 -0
- package/date-picker.js +357 -0
- package/date-range-picker.js +120 -0
- package/dialog.js +243 -178
- package/drag-drop.js +125 -0
- package/drawer.js +504 -0
- package/field.js +260 -43
- package/grid-list.js +8 -0
- package/hover-card.js +52 -52
- package/i18n-provider.js +89 -0
- package/index.js +1177 -31
- package/input-otp.js +218 -0
- package/interactions.js +2327 -0
- package/internal/anchor.js +71 -6
- package/internal/collection.js +562 -0
- package/internal/date-grid.js +260 -0
- package/internal/date-range.js +26 -0
- package/internal/disclosure.js +201 -0
- package/internal/menu-tree.js +228 -0
- package/internal/merge-props.js +85 -1
- package/internal/roving-focus.js +15 -4
- package/internal/segmented-field.js +317 -0
- package/internal/selection.js +171 -0
- package/internal/visually-hidden-style.js +41 -0
- package/list-box.js +13 -0
- package/menu.js +553 -361
- package/menubar.js +295 -0
- package/number-field.js +263 -0
- package/package.json +8 -28
- package/pagination.js +34 -22
- package/popover.js +116 -75
- package/progress.js +21 -16
- package/radio-group.js +81 -75
- package/range-calendar.js +79 -0
- package/resizable.js +155 -9
- package/scroll-area.js +283 -0
- package/select.js +83 -37
- package/separator.js +97 -0
- package/sheet.js +189 -0
- package/sidebar.js +320 -0
- package/skeleton.js +163 -0
- package/slider.js +95 -89
- package/switch.js +42 -34
- package/table.js +100 -71
- package/tabs.js +100 -91
- package/tag-group.js +8 -0
- package/time-field.js +8 -0
- package/toast.js +36 -66
- package/toggle-group.js +53 -49
- package/toggle.js +41 -27
- package/tooltip.js +48 -55
- package/tree.js +8 -0
- package/visually-hidden.js +259 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// What every menu in this package is a menu *of*.
|
|
4
|
+
//
|
|
5
|
+
// `menu.js` was one module because there was one menu. shadcn ships four
|
|
6
|
+
// components on this behaviour — a dropdown menu, a context menu, a menubar and
|
|
7
|
+
// the checkable items all three of them share — and the three that are not the
|
|
8
|
+
// dropdown differ from it in exactly two places: what opens the menu, and where
|
|
9
|
+
// the menu goes. Everything between those two — the tree of open levels, what
|
|
10
|
+
// `Escape` closes, what "choosing an item" dismisses, which selector finds an
|
|
11
|
+
// item and which finds its owner — is one set of rules, and this is it.
|
|
12
|
+
//
|
|
13
|
+
// Written down here rather than exported from `menu.js` for the reason every
|
|
14
|
+
// other module in `internal/` gives: these are relationships the components
|
|
15
|
+
// build, not a menu-building kit. `MENU_SELECTOR` is only true because
|
|
16
|
+
// `Menu.Body` renders `role="menu"`; handed to a consumer it would be a
|
|
17
|
+
// selector that happens to work.
|
|
18
|
+
//
|
|
19
|
+
// # A menu is a chain, not a flag
|
|
20
|
+
//
|
|
21
|
+
// A submenu is a menu whose parent is another menu, and almost every rule that
|
|
22
|
+
// distinguishes the two is a statement about that chain:
|
|
23
|
+
//
|
|
24
|
+
// * `Escape` closes *one* level, so it needs to know which one it is in.
|
|
25
|
+
// * Choosing an item closes the whole chain, because leaving the parent menu
|
|
26
|
+
// open after a command has run is a state no native menu has been in.
|
|
27
|
+
// * Only the outermost level listens for a press outside, because a submenu
|
|
28
|
+
// closes with the tree and two listeners would each answer one press.
|
|
29
|
+
//
|
|
30
|
+
// So the chain is the shape the context carries, and `parent` is what a level
|
|
31
|
+
// is given rather than something it works out.
|
|
32
|
+
//
|
|
33
|
+
// # Where a menu goes, when it is not against its trigger
|
|
34
|
+
//
|
|
35
|
+
// A context menu opens at the pointer, which is a *point* and not an element.
|
|
36
|
+
// `MenuAnchorContext` is how a level says so: a rectangle that replaces the
|
|
37
|
+
// trigger's own measurement inside `internal/anchor.js`, and nothing else — the
|
|
38
|
+
// trigger element is still what the writing direction is read from and what
|
|
39
|
+
// focus goes back to. A level with no provider above it measures its trigger,
|
|
40
|
+
// which is every menu that hangs off a button.
|
|
41
|
+
|
|
42
|
+
"use client";
|
|
43
|
+
|
|
44
|
+
import * as React from "@uniflowed/react";
|
|
45
|
+
import {
|
|
46
|
+
createContext,
|
|
47
|
+
useContext,
|
|
48
|
+
useEffect,
|
|
49
|
+
useId,
|
|
50
|
+
useMemo,
|
|
51
|
+
useRef,
|
|
52
|
+
useState,
|
|
53
|
+
} from "@uniflowed/react";
|
|
54
|
+
|
|
55
|
+
import type { Rect } from "./anchor.js";
|
|
56
|
+
import type { Direction } from "./roving-focus.js";
|
|
57
|
+
import { useControlled } from "./controlled-state.js";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Anything that plays the part of a menu item, including the two checkable
|
|
61
|
+
* kinds. The keyboard has to move between all of them, so the selector names
|
|
62
|
+
* all of them rather than only the plain command.
|
|
63
|
+
*/
|
|
64
|
+
export const ITEM_SELECTOR: string =
|
|
65
|
+
'[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]';
|
|
66
|
+
|
|
67
|
+
/** What owns an item: the nearest menu, so a submenu keeps its own. */
|
|
68
|
+
export const MENU_SELECTOR: string = '[role="menu"]';
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Which arrow key opens a submenu, and which closes it.
|
|
72
|
+
*
|
|
73
|
+
* The WAI-ARIA menu pattern puts a submenu on the *inline end*, so it opens to
|
|
74
|
+
* the right of a left-to-right menu and to the left of a right-to-left one, and
|
|
75
|
+
* the key that opens it is the one pointing at it. Written out as
|
|
76
|
+
* `ArrowRight` to open and `ArrowLeft` to close, an RTL reader pressed the key
|
|
77
|
+
* aimed at the submenu and closed the menu they were standing in — which is
|
|
78
|
+
* worse than nothing happening, because it loses their place.
|
|
79
|
+
*/
|
|
80
|
+
export function submenuKeys(direction: Direction): {|
|
|
81
|
+
readonly open: string,
|
|
82
|
+
readonly close: string,
|
|
83
|
+
|} {
|
|
84
|
+
return direction === "rtl"
|
|
85
|
+
? { open: "ArrowLeft", close: "ArrowRight" }
|
|
86
|
+
: { open: "ArrowRight", close: "ArrowLeft" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type MenuState = {|
|
|
90
|
+
readonly base: string,
|
|
91
|
+
readonly open: boolean,
|
|
92
|
+
readonly setOpen: (open: boolean) => void,
|
|
93
|
+
/** What opened this menu, and what focus goes back to when it closes. */
|
|
94
|
+
readonly triggerRef: { current: HTMLElement | null },
|
|
95
|
+
/**
|
|
96
|
+
* Which end the menu should open onto, written by whatever opened it.
|
|
97
|
+
*
|
|
98
|
+
* A ref rather than state because it is an instruction for the next commit,
|
|
99
|
+
* not a value anything renders: `ArrowUp` on a closed menu opens it *and*
|
|
100
|
+
* lands on the last item, and re-rendering the trigger to say so would be a
|
|
101
|
+
* render whose only purpose is to carry a message to an effect.
|
|
102
|
+
*/
|
|
103
|
+
readonly pendingFocusRef: { current: "first" | "last" | null },
|
|
104
|
+
/** The menu this one hangs off, or null for the outermost. */
|
|
105
|
+
readonly parent: MenuState | null,
|
|
106
|
+
/**
|
|
107
|
+
* Whether a trigger is rendered *and* is something worth naming the menu
|
|
108
|
+
* after, so the body only claims a name that exists.
|
|
109
|
+
*
|
|
110
|
+
* A menu opened by `defaultOpen` in a page that never renders a trigger is a
|
|
111
|
+
* real arrangement, and an `aria-labelledby` pointing at the id that trigger
|
|
112
|
+
* *would* have had makes a screen reader announce nothing at all. A context
|
|
113
|
+
* menu's trigger is arbitrary content rather than a label, and registers
|
|
114
|
+
* itself as a trigger without registering itself as a name.
|
|
115
|
+
*/
|
|
116
|
+
readonly triggered: boolean,
|
|
117
|
+
readonly registerTrigger: (present: boolean) => void,
|
|
118
|
+
|};
|
|
119
|
+
|
|
120
|
+
export const MenuContext: React.Context<MenuState | null> = createContext(null);
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The roving tab stop of one open menu.
|
|
124
|
+
*
|
|
125
|
+
* Provided by `Menu.Body` rather than by the root, because a submenu is a
|
|
126
|
+
* second list with a tab stop of its own: nesting the provider is what stops
|
|
127
|
+
* the parent menu and the submenu from fighting over which item is `tabindex=0`.
|
|
128
|
+
*/
|
|
129
|
+
export type MenuListState = {|
|
|
130
|
+
readonly activeId: string | null,
|
|
131
|
+
readonly setActiveId: (id: string | null) => void,
|
|
132
|
+
|};
|
|
133
|
+
|
|
134
|
+
export const MenuListContext: React.Context<MenuListState | null> = createContext(null);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A box a menu body is placed against instead of its trigger's own.
|
|
138
|
+
*
|
|
139
|
+
* `null`, and no provider at all, both mean "measure the trigger". See the
|
|
140
|
+
* module header, and `AnchorRequest.anchorRect` for what it replaces.
|
|
141
|
+
*/
|
|
142
|
+
export const MenuAnchorContext: React.Context<Rect | null> = createContext(null);
|
|
143
|
+
|
|
144
|
+
export hook useMenu(part: string): MenuState {
|
|
145
|
+
const state = useContext(MenuContext);
|
|
146
|
+
if (state == null) {
|
|
147
|
+
throw new Error(`${part} must be rendered inside a Menu.Root`);
|
|
148
|
+
}
|
|
149
|
+
return state;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Tell the menu that something worth naming it after is in the document.
|
|
154
|
+
*
|
|
155
|
+
* `Menu.Body` names its trigger with `aria-labelledby`, and it may only do that
|
|
156
|
+
* while there is one to name — a menu opened by `defaultOpen` in a page with no
|
|
157
|
+
* trigger would otherwise point at an id nothing has, and a screen reader given
|
|
158
|
+
* a dangling `aria-labelledby` announces nothing at all rather than falling back
|
|
159
|
+
* to the element's own content.
|
|
160
|
+
*/
|
|
161
|
+
export hook useTriggerRegistration(menu: MenuState): void {
|
|
162
|
+
const register = menu.registerTrigger;
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
register(true);
|
|
165
|
+
return () => register(false);
|
|
166
|
+
}, [register]);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Every menu from `menu` outwards, innermost first. */
|
|
170
|
+
export function ancestry(menu: MenuState): Array<MenuState> {
|
|
171
|
+
const chain = [];
|
|
172
|
+
let at: MenuState | null = menu;
|
|
173
|
+
while (at != null) {
|
|
174
|
+
chain.push(at);
|
|
175
|
+
at = at.parent;
|
|
176
|
+
}
|
|
177
|
+
return chain;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Close this menu and every menu it hangs off.
|
|
182
|
+
*
|
|
183
|
+
* Choosing an item in a submenu dismisses the whole thing — leaving the parent
|
|
184
|
+
* menu open after a command has run is a state no native menu has ever been in,
|
|
185
|
+
* and it leaves the reader looking at a menu whose action already happened.
|
|
186
|
+
*/
|
|
187
|
+
export function closeTree(menu: MenuState): void {
|
|
188
|
+
for (const each of ancestry(menu)) {
|
|
189
|
+
each.setOpen(false);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* One level of the menu tree.
|
|
195
|
+
*
|
|
196
|
+
* Shared by `Menu.Root`, `Menu.Sub`, `ContextMenu.Root` and `Menubar.Menu`,
|
|
197
|
+
* which differ only in what they hand it: a submenu knows its parent, and a
|
|
198
|
+
* menubar's menu is opened and closed by the bar rather than by itself.
|
|
199
|
+
*/
|
|
200
|
+
export component MenuLevel(
|
|
201
|
+
children: React.Node,
|
|
202
|
+
parent: MenuState | null,
|
|
203
|
+
defaultOpen: boolean,
|
|
204
|
+
open?: boolean,
|
|
205
|
+
onOpenChange?: (open: boolean) => void,
|
|
206
|
+
) {
|
|
207
|
+
const base = useId();
|
|
208
|
+
const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
|
|
209
|
+
const triggerRef = useRef<HTMLElement | null>(null);
|
|
210
|
+
const pendingFocusRef = useRef<"first" | "last" | null>(null);
|
|
211
|
+
const [triggered, setTriggered] = useState(false);
|
|
212
|
+
|
|
213
|
+
const state = useMemo(
|
|
214
|
+
() => ({
|
|
215
|
+
base,
|
|
216
|
+
open: isOpen,
|
|
217
|
+
setOpen,
|
|
218
|
+
triggerRef,
|
|
219
|
+
pendingFocusRef,
|
|
220
|
+
parent,
|
|
221
|
+
triggered,
|
|
222
|
+
registerTrigger: setTriggered,
|
|
223
|
+
}),
|
|
224
|
+
[base, isOpen, setOpen, parent, triggered],
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
return <MenuContext.Provider value={state}>{children}</MenuContext.Provider>;
|
|
228
|
+
}
|
package/internal/merge-props.js
CHANGED
|
@@ -20,6 +20,16 @@
|
|
|
20
20
|
// a caller legitimately wants *both* — event handlers and refs — they are
|
|
21
21
|
// composed rather than one replacing the other.
|
|
22
22
|
//
|
|
23
|
+
// # The other half of the rule: which element the props land on
|
|
24
|
+
//
|
|
25
|
+
// Everything above decides what goes onto the element. `RenderProp` is what
|
|
26
|
+
// decides *which element*, and it is here rather than in a module of its own
|
|
27
|
+
// because it is the same policy read from the other end: a part computes one
|
|
28
|
+
// props object, and either puts it on the element it would have chosen or hands
|
|
29
|
+
// it to the caller to put on theirs. Two ways of composing a caller's props
|
|
30
|
+
// would be two chances to get the order wrong; one shape, stated once, is what
|
|
31
|
+
// keeps a part that renders somebody else's element from being a weaker part.
|
|
32
|
+
//
|
|
23
33
|
// # Why this is `internal/` and not a subpath
|
|
24
34
|
//
|
|
25
35
|
// It is not a "props utils" module and there is nothing else in it. It is the
|
|
@@ -28,6 +38,8 @@
|
|
|
28
38
|
// consumer to build a part that spreads `rest` last, which is the failure this
|
|
29
39
|
// exists to prevent — so it stays unreachable from outside the package.
|
|
30
40
|
|
|
41
|
+
import type { Node } from "@uniflowed/react";
|
|
42
|
+
|
|
31
43
|
/**
|
|
32
44
|
* Props on their way onto an element: what a caller hands a part, and what
|
|
33
45
|
* `Field.Control` hands back for a caller to spread.
|
|
@@ -75,6 +87,78 @@
|
|
|
75
87
|
*/
|
|
76
88
|
export type Rest = { readonly key?: empty, readonly [string]: mixed };
|
|
77
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The escape hatch: a caller's element in place of the part's own.
|
|
92
|
+
*
|
|
93
|
+
* `@uniflowed/ui` has no copy step — `packages/ui/index.js`'s header argues
|
|
94
|
+
* that at length — and the thing a copy step is *for* is changing the markup. A
|
|
95
|
+
* part that always renders a `<button>` cannot be the link a menu of links
|
|
96
|
+
* needs; a heading fixed at `<h2>` is wrong inside an accordion. This is what
|
|
97
|
+
* replaces owning the source: the part still computes every attribute, every
|
|
98
|
+
* composed handler and every id, and hands them to the caller to put on
|
|
99
|
+
* whatever element they wanted.
|
|
100
|
+
*
|
|
101
|
+
* One name and one signature everywhere, which is the point. `asChild` clones a
|
|
102
|
+
* child and hopes its props survive; this hands the props over explicitly, so a
|
|
103
|
+
* caller can see what they are getting, decide the order themselves, and drop
|
|
104
|
+
* one deliberately. The part is still the part — `Menu.Body`'s `renders*` still
|
|
105
|
+
* rejects a `<div>` where a `Menu.Item` belongs, because the escape hatch
|
|
106
|
+
* changes the element the item renders and not what the item *is*. That
|
|
107
|
+
* constraint is exactly what a copied source loses.
|
|
108
|
+
*
|
|
109
|
+
* # What is in the props, and what is not
|
|
110
|
+
*
|
|
111
|
+
* Everything the part would have put on its own element, in the order
|
|
112
|
+
* `withProps` fixes: the caller's `rest` underneath, the part's own semantics on
|
|
113
|
+
* top, handlers and refs composed rather than replaced. `children` is in there
|
|
114
|
+
* too, so `render={(props) => <a href={to} {...props} />}` renders what was
|
|
115
|
+
* written between the tags — a part whose children were silently dropped
|
|
116
|
+
* because the caller spread the props and forgot them is the kind of quiet
|
|
117
|
+
* wrongness this package exists to not have. JSX children win over a spread, so
|
|
118
|
+
* `<a {...props}>Other</a>` still says what it says.
|
|
119
|
+
*
|
|
120
|
+
* What is *not* in there is anything true of the element rather than of the
|
|
121
|
+
* part: `type="button"` is the only one in practice, and it stays on the
|
|
122
|
+
* `<button>` branch. Handing it to a caller rendering an `<a>` would put an
|
|
123
|
+
* attribute the HTML has no meaning for on their link.
|
|
124
|
+
*/
|
|
125
|
+
export type RenderProp = (props: Rest) => Node;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* What a part's own handler reads of the event it is handed.
|
|
129
|
+
*
|
|
130
|
+
* Inexact, and named rather than inferred, for the same reason `menu.js`'s
|
|
131
|
+
* `MenuSelect` is: what arrives is React's synthetic event, uf does not merge
|
|
132
|
+
* Flow's `jsx.js` environment so `lib/react.js` models no such thing, and these
|
|
133
|
+
* are the members the handlers in this package actually read.
|
|
134
|
+
*
|
|
135
|
+
* It exists because of `RenderProp`. A handler written inside a JSX attribute
|
|
136
|
+
* gets its parameter's type from the attribute, which for an intrinsic is
|
|
137
|
+
* `any`; the escape hatch has to build the props *before* there is an element
|
|
138
|
+
* to put them on, so the same handler in an object literal has an indexer's
|
|
139
|
+
* `mixed` for context and Flow asks for an annotation. This is that annotation,
|
|
140
|
+
* written once rather than at every handler in the package.
|
|
141
|
+
*
|
|
142
|
+
* One shape for keys and for presses, which is the one thing it is not honest
|
|
143
|
+
* about: `key` and the modifiers belong to a keyboard event and a click has no
|
|
144
|
+
* `key`. It is a parameter annotation for handlers this package writes rather
|
|
145
|
+
* than a description of an event, nothing widens `mixed` into it, and the day
|
|
146
|
+
* `$JSXIntrinsics` is real — the day `Rest` becomes `React.PropsOf`, which its
|
|
147
|
+
* own comment is waiting for — is the day this is React's event types instead.
|
|
148
|
+
*/
|
|
149
|
+
export type PartEvent = {
|
|
150
|
+
readonly defaultPrevented: boolean,
|
|
151
|
+
readonly key: string,
|
|
152
|
+
readonly altKey: boolean,
|
|
153
|
+
readonly ctrlKey: boolean,
|
|
154
|
+
readonly metaKey: boolean,
|
|
155
|
+
readonly shiftKey: boolean,
|
|
156
|
+
readonly currentTarget: mixed,
|
|
157
|
+
readonly preventDefault: () => mixed,
|
|
158
|
+
readonly stopPropagation: () => mixed,
|
|
159
|
+
...
|
|
160
|
+
};
|
|
161
|
+
|
|
78
162
|
/**
|
|
79
163
|
* A caller's props on their way to another *part of this package*, rather than
|
|
80
164
|
* onto an intrinsic element.
|
|
@@ -116,7 +200,7 @@ export function forwarded(rest: Rest): $FlowFixMe {
|
|
|
116
200
|
* `defaultPrevented` is the caller's way of saying "I handled this", which is
|
|
117
201
|
* the same contract the DOM uses.
|
|
118
202
|
*/
|
|
119
|
-
export function composeHandlers<TEvent extends { readonly defaultPrevented?: boolean }>(
|
|
203
|
+
export function composeHandlers<TEvent extends { readonly defaultPrevented?: boolean, ... }>(
|
|
120
204
|
theirs: mixed,
|
|
121
205
|
ours: (event: TEvent) => mixed,
|
|
122
206
|
): (event: TEvent) => mixed {
|
package/internal/roving-focus.js
CHANGED
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
// one the components make.
|
|
46
46
|
|
|
47
47
|
import { useCallback, useEffect, useRef, useState } from "@uniflowed/react";
|
|
48
|
+
import { useLocale, startsWithLocale } from "../i18n-provider.js";
|
|
48
49
|
|
|
49
50
|
/** Which way a key asks the focus to move within a set. */
|
|
50
51
|
export type Movement = "previous" | "next" | "first" | "last";
|
|
@@ -346,6 +347,8 @@ export hook useFirstItem(
|
|
|
346
347
|
false,
|
|
347
348
|
set.skipDisabled,
|
|
348
349
|
);
|
|
350
|
+
// The first item is only knowable after the caller's items are in the DOM.
|
|
351
|
+
// uf-lint-disable-next-line react-compiler/set-state-in-effect
|
|
349
352
|
setFirst(landing?.id ?? null);
|
|
350
353
|
});
|
|
351
354
|
|
|
@@ -373,6 +376,7 @@ export hook useTypeahead(): (
|
|
|
373
376
|
from: number,
|
|
374
377
|
key: string,
|
|
375
378
|
) => HTMLElement | null {
|
|
379
|
+
const { locale } = useLocale();
|
|
376
380
|
const buffer = useRef<{| text: string, at: number |}>({ text: "", at: 0 });
|
|
377
381
|
|
|
378
382
|
return useCallback(
|
|
@@ -382,7 +386,7 @@ export hook useTypeahead(): (
|
|
|
382
386
|
buffer.current = { text, at: now };
|
|
383
387
|
|
|
384
388
|
const repeated = text.length > 1 && text.split("").every((each) => each === text[0]);
|
|
385
|
-
const needle =
|
|
389
|
+
const needle = repeated ? text[0] : text;
|
|
386
390
|
// A single character — or the same one again — moves on from where we
|
|
387
391
|
// are. A longer buffer starts *at* the current item, so typing "sa" after
|
|
388
392
|
// "s" can keep the item "s" already found.
|
|
@@ -390,13 +394,20 @@ export hook useTypeahead(): (
|
|
|
390
394
|
|
|
391
395
|
for (let tried = 0; tried < items.length; tried += 1) {
|
|
392
396
|
const candidate = items[(((start + tried) % items.length) + items.length) % items.length];
|
|
393
|
-
if (
|
|
397
|
+
if (
|
|
398
|
+
isEnabled(candidate) &&
|
|
399
|
+
startsWithLocale(
|
|
400
|
+
labelOf(candidate),
|
|
401
|
+
needle,
|
|
402
|
+
candidate.closest("[lang]")?.getAttribute("lang") || locale,
|
|
403
|
+
)
|
|
404
|
+
) {
|
|
394
405
|
return candidate;
|
|
395
406
|
}
|
|
396
407
|
}
|
|
397
408
|
return null;
|
|
398
409
|
},
|
|
399
|
-
[],
|
|
410
|
+
[locale],
|
|
400
411
|
);
|
|
401
412
|
}
|
|
402
413
|
|
|
@@ -426,5 +437,5 @@ export function isTypeaheadKey(event: {
|
|
|
426
437
|
/** What a reader hears for this item, lower-cased for matching. */
|
|
427
438
|
function labelOf(element: HTMLElement): string {
|
|
428
439
|
const spoken = element.getAttribute("aria-label") ?? element.textContent ?? "";
|
|
429
|
-
return spoken.replace(/\s+/g, " ").trim()
|
|
440
|
+
return spoken.replace(/\s+/g, " ").trim();
|
|
430
441
|
}
|