@uniflowed/ui 0.0.0-alpha.12 → 0.0.0-alpha.14
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 +21 -3
- package/calendar.js +550 -0
- package/checkbox.js +188 -10
- package/collapsible.js +29 -12
- package/combobox.js +176 -5
- package/context-menu.js +198 -0
- package/date-picker.js +346 -0
- package/field.js +192 -25
- package/hover-card.js +3 -3
- package/index.js +196 -10
- package/internal/anchor.js +71 -6
- package/internal/date-grid.js +260 -0
- package/internal/disclosure.js +201 -0
- package/internal/menu-tree.js +228 -0
- package/menu.js +309 -163
- package/menubar.js +285 -0
- package/package.json +8 -3
- package/popover.js +21 -8
- package/resizable.js +149 -9
- package/select.js +29 -0
- package/switch.js +5 -3
- package/toggle.js +3 -2
- package/tooltip.js +3 -3
package/menubar.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A row of menus that behaves as one control: File, Edit, View.
|
|
4
|
+
//
|
|
5
|
+
// It is not a row of `Menu.Root`s, and the reason is that the *set* has a
|
|
6
|
+
// keyboard map of its own — one that only exists because the menus are next to
|
|
7
|
+
// each other:
|
|
8
|
+
//
|
|
9
|
+
// * The whole bar takes **one** stop in the page's tab order, so `Tab` past an
|
|
10
|
+
// application menu is one press rather than six.
|
|
11
|
+
// * `ArrowLeft` / `ArrowRight` move between the top-level menus, mirrored in a
|
|
12
|
+
// right-to-left page because they are the inline axis.
|
|
13
|
+
// * `ArrowDown` opens the menu under the cursor and lands on its first item;
|
|
14
|
+
// `ArrowUp` opens it onto its last, which is the same argument `Menu.Trigger`
|
|
15
|
+
// makes about the destructive command at the bottom of a long menu.
|
|
16
|
+
// * `Home` / `End` go to the first and last menu.
|
|
17
|
+
// * And the part that is always missing: `ArrowLeft` / `ArrowRight` **while a
|
|
18
|
+
// menu is open** close it and open the adjacent one, so a reader can walk
|
|
19
|
+
// File → Edit → View without pressing `Escape` between them. A menubar
|
|
20
|
+
// without it makes the arrow keys mean two different things depending on
|
|
21
|
+
// whether a menu happens to be showing.
|
|
22
|
+
// * `Escape` closes the open menu and leaves focus on its trigger, in the bar,
|
|
23
|
+
// which `Menu.Body` already does — a menubar trigger is the menu's trigger.
|
|
24
|
+
//
|
|
25
|
+
// Everything inside a menu is `menu.js`: the arrow keys within it, typeahead,
|
|
26
|
+
// submenus, the checkable items and the roving tab stop of the menu itself.
|
|
27
|
+
// `Menubar.Body` is `Menu.Body` itself rather than a wrapper around it, because
|
|
28
|
+
// a bar's menu *is* a root menu — it hangs off a button and drops from it — and
|
|
29
|
+
// `Menu.Body` already places a root menu on the bottom, aligned to the start. A
|
|
30
|
+
// wrapper would have been a second component with the same defaults written out
|
|
31
|
+
// again, and a second place for them to drift.
|
|
32
|
+
//
|
|
33
|
+
// # How the bar finds its own triggers
|
|
34
|
+
//
|
|
35
|
+
// `internal/roving-focus.js`'s `itemsOf(container, item, owner)` takes the owner
|
|
36
|
+
// selector as a parameter for exactly this: a set says what owns it. A menu
|
|
37
|
+
// passes `[role="menu"]`, and a menubar has to pass **both** — an open menu is a
|
|
38
|
+
// DOM descendant of the bar, and its items are `role="menuitem"` too, so a bar
|
|
39
|
+
// that only asked "menu items inside me" would step into the open menu's items
|
|
40
|
+
// with `ArrowRight`. Naming the two owners makes `closest` stop at the menu for
|
|
41
|
+
// an item inside one and at the bar for a trigger, which is the distinction, and
|
|
42
|
+
// it is settled by the roles the two containers already carry.
|
|
43
|
+
//
|
|
44
|
+
// Turning a trigger *element* back into the menu it opens is the one thing the
|
|
45
|
+
// roles cannot say, and `data-uf-menubar-value` is that and nothing more. The
|
|
46
|
+
// alternative is a registry of refs, which `roving-focus.js` explains at length
|
|
47
|
+
// is a second opinion about document order.
|
|
48
|
+
//
|
|
49
|
+
// # Which menu is open is the bar's state
|
|
50
|
+
//
|
|
51
|
+
// One value, `string | null`, rather than a boolean per menu. Two menus open at
|
|
52
|
+
// once is the state this component exists to prevent, and a per-menu boolean is
|
|
53
|
+
// a set of booleans somebody has to keep exclusive; `accordion.js` makes the
|
|
54
|
+
// same argument about `single`. It is also what makes "close this one and open
|
|
55
|
+
// the next" a single assignment rather than a pair of them that render twice.
|
|
56
|
+
|
|
57
|
+
"use client";
|
|
58
|
+
|
|
59
|
+
import * as React from "@uniflowed/react";
|
|
60
|
+
import {
|
|
61
|
+
createContext,
|
|
62
|
+
useCallback,
|
|
63
|
+
useContext,
|
|
64
|
+
useMemo,
|
|
65
|
+
useRef,
|
|
66
|
+
useState,
|
|
67
|
+
} from "@uniflowed/react";
|
|
68
|
+
|
|
69
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
70
|
+
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
71
|
+
import type { RovingSet } from "./internal/roving-focus.js";
|
|
72
|
+
import {
|
|
73
|
+
directionOf,
|
|
74
|
+
indexOfActive,
|
|
75
|
+
itemsOf,
|
|
76
|
+
movementFor,
|
|
77
|
+
moveTo,
|
|
78
|
+
useFirstItem,
|
|
79
|
+
} from "./internal/roving-focus.js";
|
|
80
|
+
import { MenuLevel, useMenu } from "./internal/menu-tree.js";
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The bar's own items, and the two things that may own one.
|
|
84
|
+
*
|
|
85
|
+
* See the module header: an open menu is inside the bar and its items wear the
|
|
86
|
+
* same role, so the owner selector names both and `closest` settles it.
|
|
87
|
+
*/
|
|
88
|
+
const TRIGGERS: RovingSet = {
|
|
89
|
+
item: '[role="menuitem"]',
|
|
90
|
+
owner: '[role="menu"], [role="menubar"]',
|
|
91
|
+
orientation: "horizontal",
|
|
92
|
+
wrap: true,
|
|
93
|
+
skipDisabled: true,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
type MenubarState = {|
|
|
97
|
+
/** Which menu is showing, by the `value` its `Menubar.Menu` was given. */
|
|
98
|
+
readonly open: string | null,
|
|
99
|
+
readonly setOpen: (value: string | null) => void,
|
|
100
|
+
/** Which trigger holds the bar's single tab stop, or null for "the first". */
|
|
101
|
+
readonly active: string | null,
|
|
102
|
+
readonly setActive: (value: string) => void,
|
|
103
|
+
readonly firstId: string | null,
|
|
104
|
+
|};
|
|
105
|
+
|
|
106
|
+
const MenubarContext: React.Context<MenubarState | null> = createContext(null);
|
|
107
|
+
|
|
108
|
+
/** The `value` of the `Menubar.Menu` a trigger belongs to. */
|
|
109
|
+
const MenubarMenuContext: React.Context<string | null> = createContext(null);
|
|
110
|
+
|
|
111
|
+
hook useMenubar(part: string): MenubarState {
|
|
112
|
+
const state = useContext(MenubarContext);
|
|
113
|
+
if (state == null) {
|
|
114
|
+
throw new Error(`${part} must be rendered inside a Menubar.Root`);
|
|
115
|
+
}
|
|
116
|
+
return state;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The bar: `role="menubar"`, one tab stop, and the arrows between the menus.
|
|
121
|
+
*
|
|
122
|
+
* `aria-label` is the caller's and matters more here than on most containers —
|
|
123
|
+
* a page with an application menubar and a formatting toolbar has two, and
|
|
124
|
+
* "menu bar" twice tells a reader nothing about which is which.
|
|
125
|
+
*/
|
|
126
|
+
export component MenubarRoot(children: renders* MenubarMenu, ...rest: Rest) {
|
|
127
|
+
const barRef = useRef<HTMLElement | null>(null);
|
|
128
|
+
const [open, setOpenValue] = useState<string | null>(null);
|
|
129
|
+
const [active, setActive] = useState<string | null>(null);
|
|
130
|
+
// Only while nothing has been focused or opened. Once a trigger holds the tab
|
|
131
|
+
// stop, asking the document which one comes first is work with no reader.
|
|
132
|
+
const firstId = useFirstItem(barRef, TRIGGERS, active == null);
|
|
133
|
+
|
|
134
|
+
const setOpen = useCallback((value: string | null) => {
|
|
135
|
+
setOpenValue(value);
|
|
136
|
+
if (value != null) {
|
|
137
|
+
setActive(value);
|
|
138
|
+
}
|
|
139
|
+
}, []);
|
|
140
|
+
|
|
141
|
+
const state = useMemo(
|
|
142
|
+
() => ({ open, setOpen, active, setActive, firstId }),
|
|
143
|
+
[open, setOpen, active, firstId],
|
|
144
|
+
);
|
|
145
|
+
const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<MenubarContext.Provider value={state}>
|
|
149
|
+
<div
|
|
150
|
+
{...passed}
|
|
151
|
+
aria-orientation="horizontal"
|
|
152
|
+
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
153
|
+
const bar: $FlowFixMe = event.currentTarget;
|
|
154
|
+
const movement = movementFor(event.key, "horizontal", directionOf(bar));
|
|
155
|
+
if (movement == null) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const triggers = itemsOf(bar, TRIGGERS.item, TRIGGERS.owner);
|
|
159
|
+
// With a menu open, focus is on one of *its* items rather than on a
|
|
160
|
+
// trigger, so "where am I in the bar" is the expanded trigger. This
|
|
161
|
+
// is the whole of walking File → Edit → View without pressing Escape.
|
|
162
|
+
const focused = indexOfActive(triggers, bar.ownerDocument?.activeElement);
|
|
163
|
+
const at =
|
|
164
|
+
focused >= 0
|
|
165
|
+
? focused
|
|
166
|
+
: triggers.findIndex((each) => each.getAttribute("aria-expanded") === "true");
|
|
167
|
+
const next = moveTo(triggers, at, movement, TRIGGERS.wrap, TRIGGERS.skipDisabled);
|
|
168
|
+
if (next == null) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
// Claimed before focus moves, or the browser scrolls the page under
|
|
172
|
+
// the trigger that has just taken it; `moveOnKey` says the same.
|
|
173
|
+
event.preventDefault();
|
|
174
|
+
event.stopPropagation();
|
|
175
|
+
const value = next.getAttribute("data-uf-menubar-value");
|
|
176
|
+
if (open != null && value != null) {
|
|
177
|
+
// Swap which menu is showing. Focus lands on the new menu's first
|
|
178
|
+
// item through `Menu.Body`'s own opening effect, so nothing here
|
|
179
|
+
// moves it: focusing the trigger as well would be two focus moves
|
|
180
|
+
// in one commit and the reader would see the second.
|
|
181
|
+
setOpen(value);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
next.focus();
|
|
185
|
+
if (value != null) {
|
|
186
|
+
setActive(value);
|
|
187
|
+
}
|
|
188
|
+
})}
|
|
189
|
+
ref={composeRefs(rest.ref, (element) => {
|
|
190
|
+
barRef.current = element;
|
|
191
|
+
})}
|
|
192
|
+
role="menubar"
|
|
193
|
+
>
|
|
194
|
+
{children}
|
|
195
|
+
</div>
|
|
196
|
+
</MenubarContext.Provider>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* One menu of the bar, named by the `value` the bar opens and closes it with.
|
|
202
|
+
*
|
|
203
|
+
* Renders no element of its own, for the reason `Menu.Root` gives: a trigger and
|
|
204
|
+
* its body are siblings in whatever layout the caller wrote.
|
|
205
|
+
*/
|
|
206
|
+
export component MenubarMenu(children: React.Node, value: string) {
|
|
207
|
+
const bar = useMenubar("Menubar.Menu");
|
|
208
|
+
const setOpen = bar.setOpen;
|
|
209
|
+
const onOpenChange = useCallback(
|
|
210
|
+
(next: boolean) => setOpen(next ? value : null),
|
|
211
|
+
[setOpen, value],
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<MenubarMenuContext.Provider value={value}>
|
|
216
|
+
<MenuLevel
|
|
217
|
+
defaultOpen={false}
|
|
218
|
+
onOpenChange={onOpenChange}
|
|
219
|
+
open={bar.open === value}
|
|
220
|
+
parent={null}
|
|
221
|
+
>
|
|
222
|
+
{children}
|
|
223
|
+
</MenuLevel>
|
|
224
|
+
</MenubarMenuContext.Provider>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The button that opens one of the bar's menus.
|
|
230
|
+
*
|
|
231
|
+
* `role="menuitem"` rather than a plain button, because it *is* an item of the
|
|
232
|
+
* menubar — a reader is told "File, menu item, has popup, 1 of 3" — and it is
|
|
233
|
+
* what makes the bar's arrow keys agree with what they were told is in it.
|
|
234
|
+
*/
|
|
235
|
+
export component MenubarTrigger(children: React.Node, ...rest: Rest) {
|
|
236
|
+
const bar = useMenubar("Menubar.Trigger");
|
|
237
|
+
const menu = useMenu("Menubar.Trigger");
|
|
238
|
+
const value = useContext(MenubarMenuContext);
|
|
239
|
+
if (value == null) {
|
|
240
|
+
throw new Error("Menubar.Trigger must be rendered inside a Menubar.Menu");
|
|
241
|
+
}
|
|
242
|
+
const passed = withoutComposed(rest, ["onClick", "onFocus", "onKeyDown", "ref"]);
|
|
243
|
+
const id = `${menu.base}-trigger`;
|
|
244
|
+
// The bar's single tab stop. Before anything has been focused or opened it
|
|
245
|
+
// belongs to the first trigger, which is a fact about the document rather
|
|
246
|
+
// than about this component — `useFirstItem` reads it in the bar.
|
|
247
|
+
const stop = bar.active == null ? bar.firstId === id : bar.active === value;
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<button
|
|
251
|
+
{...passed}
|
|
252
|
+
aria-controls={menu.open ? `${menu.base}-body` : undefined}
|
|
253
|
+
aria-expanded={menu.open ? "true" : "false"}
|
|
254
|
+
aria-haspopup="menu"
|
|
255
|
+
// How the bar's keyboard turns a trigger element back into the menu it
|
|
256
|
+
// opens; the module header says why this is an attribute and the rest of
|
|
257
|
+
// the bar's arithmetic is not.
|
|
258
|
+
data-uf-menubar-value={value}
|
|
259
|
+
id={id}
|
|
260
|
+
onClick={composeHandlers(rest.onClick, () => bar.setOpen(menu.open ? null : value))}
|
|
261
|
+
onFocus={composeHandlers(rest.onFocus, () => bar.setActive(value))}
|
|
262
|
+
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
263
|
+
const end = match (event.key) {
|
|
264
|
+
"ArrowDown" => "first",
|
|
265
|
+
"ArrowUp" => "last",
|
|
266
|
+
_ => null,
|
|
267
|
+
};
|
|
268
|
+
if (end == null) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
event.preventDefault();
|
|
272
|
+
menu.pendingFocus.current = end;
|
|
273
|
+
bar.setOpen(value);
|
|
274
|
+
})}
|
|
275
|
+
ref={composeRefs(rest.ref, (element) => {
|
|
276
|
+
menu.triggerRef.current = element;
|
|
277
|
+
})}
|
|
278
|
+
role="menuitem"
|
|
279
|
+
tabIndex={stop ? 0 : -1}
|
|
280
|
+
type="button"
|
|
281
|
+
>
|
|
282
|
+
{children}
|
|
283
|
+
</button>
|
|
284
|
+
);
|
|
285
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/ui",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.14",
|
|
4
4
|
"description": "Headless, accessible React components whose composition Flow checks, part of the Unified Toolchain for Flow.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,16 +14,20 @@
|
|
|
14
14
|
".": "./index.js",
|
|
15
15
|
"./accordion": "./accordion.js",
|
|
16
16
|
"./alert-dialog": "./alert-dialog.js",
|
|
17
|
+
"./calendar": "./calendar.js",
|
|
17
18
|
"./carousel": "./carousel.js",
|
|
18
19
|
"./checkbox": "./checkbox.js",
|
|
19
20
|
"./collapsible": "./collapsible.js",
|
|
20
21
|
"./combobox": "./combobox.js",
|
|
22
|
+
"./context-menu": "./context-menu.js",
|
|
23
|
+
"./date-picker": "./date-picker.js",
|
|
21
24
|
"./dialog": "./dialog.js",
|
|
22
25
|
"./drawer": "./drawer.js",
|
|
23
26
|
"./field": "./field.js",
|
|
24
27
|
"./hover-card": "./hover-card.js",
|
|
25
28
|
"./input-otp": "./input-otp.js",
|
|
26
29
|
"./menu": "./menu.js",
|
|
30
|
+
"./menubar": "./menubar.js",
|
|
27
31
|
"./navigation-menu": "./navigation-menu.js",
|
|
28
32
|
"./pagination": "./pagination.js",
|
|
29
33
|
"./popover": "./popover.js",
|
|
@@ -48,8 +52,9 @@
|
|
|
48
52
|
"internal"
|
|
49
53
|
],
|
|
50
54
|
"dependencies": {
|
|
51
|
-
"@uniflowed/
|
|
52
|
-
"@uniflowed/
|
|
55
|
+
"@uniflowed/core": "0.0.0-alpha.14",
|
|
56
|
+
"@uniflowed/hooks": "0.0.0-alpha.14",
|
|
57
|
+
"@uniflowed/react": "0.0.0-alpha.14"
|
|
53
58
|
},
|
|
54
59
|
"peerDependencies": {
|
|
55
60
|
"react": ">=19"
|
package/popover.js
CHANGED
|
@@ -58,7 +58,7 @@ import {
|
|
|
58
58
|
} from "@uniflowed/react";
|
|
59
59
|
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
60
60
|
|
|
61
|
-
import type { Align,
|
|
61
|
+
import type { Align, LogicalSide } from "./internal/anchor.js";
|
|
62
62
|
import type { Rest } from "./internal/merge-props.js";
|
|
63
63
|
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
64
64
|
import { focusable } from "./internal/focus.js";
|
|
@@ -66,7 +66,7 @@ import { useAnchor } from "./internal/anchor.js";
|
|
|
66
66
|
import { useControlled } from "./internal/controlled-state.js";
|
|
67
67
|
import { usePresence } from "./internal/disclosure.js";
|
|
68
68
|
|
|
69
|
-
export type { Align, Side } from "./internal/anchor.js";
|
|
69
|
+
export type { Align, LogicalSide, Side } from "./internal/anchor.js";
|
|
70
70
|
|
|
71
71
|
type PopoverState = {|
|
|
72
72
|
readonly base: string,
|
|
@@ -188,7 +188,15 @@ export component PopoverBody(
|
|
|
188
188
|
alignOffset?: number = 0,
|
|
189
189
|
avoidCollisions?: boolean = true,
|
|
190
190
|
collisionPadding?: number = 0,
|
|
191
|
-
|
|
191
|
+
/**
|
|
192
|
+
* Where focus lands when it opens, when the first focus stop is the wrong
|
|
193
|
+
* answer. The same prop `Dialog.Body` takes, deliberately spelled the same
|
|
194
|
+
* way: `DatePicker.Calendar` fills it with the day that holds the grid's tab
|
|
195
|
+
* stop, because a reader who opened a date picker is looking for the date and
|
|
196
|
+
* not for the button that steps back a month.
|
|
197
|
+
*/
|
|
198
|
+
initialFocus?: { current: HTMLElement | null },
|
|
199
|
+
side?: LogicalSide = "bottom",
|
|
192
200
|
sideOffset?: number = 0,
|
|
193
201
|
...rest: Rest
|
|
194
202
|
) {
|
|
@@ -257,10 +265,15 @@ export component PopoverBody(
|
|
|
257
265
|
};
|
|
258
266
|
document.addEventListener("focusin", onFocusMoved, true);
|
|
259
267
|
|
|
260
|
-
//
|
|
261
|
-
// nothing focusable
|
|
262
|
-
// the handler below.
|
|
263
|
-
|
|
268
|
+
// Where the caller said, then the first thing worth acting on, then the
|
|
269
|
+
// popover itself when it holds nothing focusable - so focus is inside it
|
|
270
|
+
// whichever of the three answers, and Escape reaches the handler below.
|
|
271
|
+
//
|
|
272
|
+
// The named element has to still be *in* this popover, for the reason
|
|
273
|
+
// `dialog.js` gives at the same line: a ref left from a previous opening
|
|
274
|
+
// would move focus somewhere the reader did not open.
|
|
275
|
+
const named = initialFocus?.current ?? null;
|
|
276
|
+
((named != null && body.contains(named) ? named : focusable(body)[0]) ?? body).focus();
|
|
264
277
|
|
|
265
278
|
return () => {
|
|
266
279
|
document.removeEventListener("pointerdown", onOutsidePress, true);
|
|
@@ -278,7 +291,7 @@ export component PopoverBody(
|
|
|
278
291
|
opener?.focus?.();
|
|
279
292
|
}
|
|
280
293
|
};
|
|
281
|
-
}, [popover.open, triggerRef, close]);
|
|
294
|
+
}, [popover.open, triggerRef, close, initialFocus]);
|
|
282
295
|
|
|
283
296
|
if (!popover.open) {
|
|
284
297
|
return null;
|
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
|
|
14
|
-
//
|
|
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.
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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
|
|
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, [
|
|
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}
|
|
@@ -265,6 +373,38 @@ export component ResizableHandle(label?: string = "Resize", ...rest: Rest) {
|
|
|
265
373
|
moveBy(amount);
|
|
266
374
|
}
|
|
267
375
|
})}
|
|
376
|
+
onPointerCancel={composeHandlers(rest.onPointerCancel, endDrag)}
|
|
377
|
+
onPointerDown={composeHandlers(rest.onPointerDown, (event: $FlowFixMe) => {
|
|
378
|
+
if (group.disabled) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
// Otherwise the press selects the text in the panes either side on the
|
|
382
|
+
// way past, so a drag paints half the page blue.
|
|
383
|
+
event.preventDefault();
|
|
384
|
+
// Which also means the browser will not focus this element, and a
|
|
385
|
+
// reader who has just dragged the splitter is the reader most likely to
|
|
386
|
+
// want an arrow key next.
|
|
387
|
+
event.currentTarget?.focus?.();
|
|
388
|
+
event.currentTarget?.setPointerCapture?.(event.pointerId);
|
|
389
|
+
dragging.current = true;
|
|
390
|
+
// A drag is a move, so the pane `Enter` would put back is no longer
|
|
391
|
+
// where it was. Leaving it would make the next `Enter` restore a size
|
|
392
|
+
// from before the drag.
|
|
393
|
+
restoreTo.current = null;
|
|
394
|
+
// Deliberately no value change: taking hold of the handle is not asking
|
|
395
|
+
// it to move. The module header says what a press does on a track
|
|
396
|
+
// instead, and why the two are not the same gesture.
|
|
397
|
+
})}
|
|
398
|
+
onPointerMove={composeHandlers(rest.onPointerMove, (event: $FlowFixMe) => {
|
|
399
|
+
if (!dragging.current) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const share = shareAt(event);
|
|
403
|
+
if (share != null) {
|
|
404
|
+
group.setValue(share);
|
|
405
|
+
}
|
|
406
|
+
})}
|
|
407
|
+
onPointerUp={composeHandlers(rest.onPointerUp, endDrag)}
|
|
268
408
|
role="separator"
|
|
269
409
|
// A separator that is not in the tab sequence is the WCAG 2.1.1 failure
|
|
270
410
|
// this module exists to avoid.
|
package/select.js
CHANGED
|
@@ -146,6 +146,8 @@ import {
|
|
|
146
146
|
} from "@uniflowed/react";
|
|
147
147
|
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
148
148
|
|
|
149
|
+
import type { Align, LogicalSide } from "./internal/anchor.js";
|
|
150
|
+
import { useAnchor } from "./internal/anchor.js";
|
|
149
151
|
import type { Rest } from "./internal/merge-props.js";
|
|
150
152
|
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
151
153
|
import type { Movement } from "./internal/roving-focus.js";
|
|
@@ -153,6 +155,8 @@ import { isTypeaheadKey, itemsOf, moveTo, useTypeahead } from "./internal/roving
|
|
|
153
155
|
import { useControlled } from "./internal/controlled-state.js";
|
|
154
156
|
import { FormValue } from "./internal/form-value.js";
|
|
155
157
|
|
|
158
|
+
export type { Align, LogicalSide, Side } from "./internal/anchor.js";
|
|
159
|
+
|
|
156
160
|
const OPTION_SELECTOR = '[role="option"]';
|
|
157
161
|
const LISTBOX_SELECTOR = '[role="listbox"]';
|
|
158
162
|
|
|
@@ -613,6 +617,12 @@ export component SelectValue(children?: React.Node, placeholder?: React.Node, ..
|
|
|
613
617
|
*/
|
|
614
618
|
export component SelectList(
|
|
615
619
|
children: renders* (SelectOption | SelectGroup | SelectSeparator),
|
|
620
|
+
align?: Align = "start",
|
|
621
|
+
alignOffset?: number = 0,
|
|
622
|
+
avoidCollisions?: boolean = true,
|
|
623
|
+
collisionPadding?: number = 0,
|
|
624
|
+
side?: LogicalSide = "bottom",
|
|
625
|
+
sideOffset?: number = 0,
|
|
616
626
|
...rest: Rest
|
|
617
627
|
) {
|
|
618
628
|
const select = useSelect("Select.List");
|
|
@@ -622,6 +632,23 @@ export component SelectList(
|
|
|
622
632
|
select.setActiveId(null);
|
|
623
633
|
});
|
|
624
634
|
|
|
635
|
+
// The popup a select opens is the one case where the trigger's *width* is
|
|
636
|
+
// part of the design rather than a detail: a list narrower than the button it
|
|
637
|
+
// came out of reads as a different control. `--uf-anchor-trigger-width` is
|
|
638
|
+
// written on this element for a stylesheet to use, which is why the
|
|
639
|
+
// measurement is here and not in the caller.
|
|
640
|
+
const anchored = useAnchor({
|
|
641
|
+
align,
|
|
642
|
+
alignOffset,
|
|
643
|
+
anchorRef: triggerRef,
|
|
644
|
+
avoidCollisions,
|
|
645
|
+
collisionPadding,
|
|
646
|
+
open: select.open,
|
|
647
|
+
overlayRef: listRef,
|
|
648
|
+
side,
|
|
649
|
+
sideOffset,
|
|
650
|
+
});
|
|
651
|
+
|
|
625
652
|
// No dependency list, for the reason `combobox.js` gives: what this reads is
|
|
626
653
|
// the *rendered* options, and a caller may render different ones on any
|
|
627
654
|
// render — a change to `children` that no dependency list can describe. Every
|
|
@@ -701,6 +728,8 @@ export component SelectList(
|
|
|
701
728
|
<div
|
|
702
729
|
{...passed}
|
|
703
730
|
aria-labelledby={select.labelled ? `${select.base}-label` : undefined}
|
|
731
|
+
data-align={anchored.align}
|
|
732
|
+
data-side={anchored.side}
|
|
704
733
|
id={`${select.base}-list`}
|
|
705
734
|
ref={composeRefs(rest.ref, (element) => {
|
|
706
735
|
listRef.current = element;
|