@uniflowed/ui 0.8.0 → 0.10.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 +8 -5
- package/collapsible.js +21 -10
- package/combobox.js +7 -2
- package/dialog.js +29 -5
- package/hover-card.js +25 -8
- package/internal/anchor.js +10 -1
- package/internal/collection.js +52 -11
- package/internal/disclosure.js +84 -22
- package/internal/presence.js +249 -0
- package/internal/segmented-field.js +40 -16
- package/menu.js +9 -6
- package/navigation-menu.js +22 -6
- package/number-field.js +40 -10
- package/package.json +5 -5
- package/popover.js +10 -9
- package/select.js +7 -2
- package/tabs.js +126 -5
- package/toast.js +136 -7
- package/tooltip.js +7 -3
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Keeping a closing part on the page until its exit transition has finished.
|
|
4
|
+
//
|
|
5
|
+
// Every overlay part in this package is on the page only while it is open:
|
|
6
|
+
// `Dialog.Body`, `Popover.Body`, `Tooltip.Body`, a menu, a listbox and a toast
|
|
7
|
+
// render `null` once they have closed. That is the right default for
|
|
8
|
+
// accessibility, because a closed dialog that stays in the document is one a
|
|
9
|
+
// screen reader can still wander into. Rendering `null` at the moment of
|
|
10
|
+
// closing, though, leaves a stylesheet nothing to animate: by the time a
|
|
11
|
+
// `[data-state=closed]` rule could apply, the element is gone.
|
|
12
|
+
//
|
|
13
|
+
// `usePresence` is the smallest change that gives a stylesheet an exit and
|
|
14
|
+
// keeps the accessibility default. A part asks it whether to render. The
|
|
15
|
+
// answer stays yes for a while after `open` goes false, and during that time
|
|
16
|
+
// the part is marked `data-state="closed"`, so the stylesheet can transition
|
|
17
|
+
// it out. The hook watches the part's own transitions and says no once they
|
|
18
|
+
// have all finished.
|
|
19
|
+
//
|
|
20
|
+
// # What it owns, and what it does not
|
|
21
|
+
//
|
|
22
|
+
// It owns **one decision: whether a part is on the page.** Everything else
|
|
23
|
+
// about closing stays keyed on `open`, and so it happens at the moment of
|
|
24
|
+
// closing, not after the transition:
|
|
25
|
+
//
|
|
26
|
+
// * Focus goes back to the trigger when `open` goes false. A reader should
|
|
27
|
+
// not wait 200ms for the keyboard to answer.
|
|
28
|
+
// * The scroll lock lifts when `open` goes false, so the page can be
|
|
29
|
+
// scrolled while the panel fades.
|
|
30
|
+
// * A closing part is `inert`: it cannot be clicked, focused or found by a
|
|
31
|
+
// screen reader while it fades. That is the caller's job, so the caller
|
|
32
|
+
// reads `state` and writes `inert` itself, beside the `data-state` it
|
|
33
|
+
// already writes. The hook renders nothing.
|
|
34
|
+
//
|
|
35
|
+
// # How it knows the exit has finished
|
|
36
|
+
//
|
|
37
|
+
// In the layout effect of the render that closed the part, before the browser
|
|
38
|
+
// paints it, the hook asks the element for `getAnimations({ subtree: true })`
|
|
39
|
+
// and waits for every one of them to finish. It does not use a timer, a
|
|
40
|
+
// duration read from the stylesheet, or `transitionend`:
|
|
41
|
+
//
|
|
42
|
+
// * A **timer** would be a second copy of a duration the stylesheet already
|
|
43
|
+
// holds, and the two drift apart the first time a theme changes one.
|
|
44
|
+
// * **`transitionend`** does not fire for a transition that was never
|
|
45
|
+
// started, such as under reduced motion or a property that did not
|
|
46
|
+
// change. It also fires once per property, and it bubbles up from every
|
|
47
|
+
// child.
|
|
48
|
+
// * **`getAnimations()`** returns exactly what is running, CSS transitions
|
|
49
|
+
// and keyframe animations alike. It brings style up to date first, so the
|
|
50
|
+
// transitions this commit started are already in the list.
|
|
51
|
+
//
|
|
52
|
+
// An empty list means there is nothing to wait for: no stylesheet, `0s` under
|
|
53
|
+
// reduced motion, or a test DOM that runs no CSS. The part is then removed in
|
|
54
|
+
// the same commit, before anything is painted, which is exactly how it
|
|
55
|
+
// behaved before this hook existed. That is why this is a layout effect: a
|
|
56
|
+
// passive one would paint one frame of a closed part that has no exit to play,
|
|
57
|
+
// and a synchronous test would see a part that is gone in a browser.
|
|
58
|
+
//
|
|
59
|
+
// Two kinds of animation are not waited for, because they will not end on
|
|
60
|
+
// their own: one that repeats for ever (a spinner inside a dialog) and one
|
|
61
|
+
// that is paused. Waiting on either would keep a closed part on the page for
|
|
62
|
+
// as long as the page is open. An animation that is *cancelled* rather than
|
|
63
|
+
// finished counts as finished. Its `finished` promise rejects when the
|
|
64
|
+
// element's style changes under it or it is removed, and a part must never
|
|
65
|
+
// stay on the page because of an animation that will not end.
|
|
66
|
+
//
|
|
67
|
+
// While it waits, the part is `inert`, and an inert element is not hit by the
|
|
68
|
+
// pointer. A part that lingers at `opacity: 0` because something in it is
|
|
69
|
+
// still animating is invisible and cannot be pressed, so it costs the reader
|
|
70
|
+
// nothing.
|
|
71
|
+
//
|
|
72
|
+
// # Reopening part-way through
|
|
73
|
+
//
|
|
74
|
+
// Suppose the reader closes and reopens before the exit has finished. `open`
|
|
75
|
+
// is true again, so the part is simply open: `state` is `"open"` and the part
|
|
76
|
+
// was never removed. The pending wait belongs to an effect that has been
|
|
77
|
+
// cleaned up, so when that exit's animations settle, nothing happens. In CSS
|
|
78
|
+
// the transition reverses from wherever it had got to, which is what a
|
|
79
|
+
// transition does when its target changes.
|
|
80
|
+
//
|
|
81
|
+
// # The Rules of React
|
|
82
|
+
//
|
|
83
|
+
// * **Nothing is read from the DOM while rendering.** `ref.current` is read
|
|
84
|
+
// only in the effect. Rendering depends on `open` and on the hook's own
|
|
85
|
+
// state, so this is safe under concurrent rendering and the React Compiler.
|
|
86
|
+
// * **The previous `open` is state, not a ref.** When `open` changes, the
|
|
87
|
+
// hook updates its state during render. React documents this pattern
|
|
88
|
+
// ("storing information from previous renders"): it re-renders immediately
|
|
89
|
+
// without committing the stale answer. So the render that closes a part
|
|
90
|
+
// already says `present: true`, and the part never disappears for a frame.
|
|
91
|
+
// * **State is set in the layout effect only when there is nothing to wait
|
|
92
|
+
// for.** That is the measure-then-render pattern React documents for layout
|
|
93
|
+
// effects: the answer depends on the DOM, and it has to be known before the
|
|
94
|
+
// browser paints. Otherwise the effect only subscribes to promises and sets
|
|
95
|
+
// state in their callbacks.
|
|
96
|
+
// * **StrictMode's double effect is harmless.** The first run is cleaned up
|
|
97
|
+
// before its promise settles. The second run waits on the same animations
|
|
98
|
+
// and alone removes the part.
|
|
99
|
+
// * **On the server** there are no effects. An open part renders as open, a
|
|
100
|
+
// closed one renders nothing, and there is nothing to wait for.
|
|
101
|
+
|
|
102
|
+
import { useLayoutEffect, useState } from "@uniflowed/react";
|
|
103
|
+
|
|
104
|
+
/** Where a part is in its life: showing, or on its way out. */
|
|
105
|
+
export type PresenceState = "open" | "closed";
|
|
106
|
+
|
|
107
|
+
/** What `usePresence` answers with. */
|
|
108
|
+
export type Presence = {
|
|
109
|
+
/**
|
|
110
|
+
* Whether the part should be rendered at all. True while `open`, and for as
|
|
111
|
+
* long after it as the part's exit animations run.
|
|
112
|
+
*/
|
|
113
|
+
readonly present: boolean,
|
|
114
|
+
/**
|
|
115
|
+
* What the part should write as `data-state`: `"closed"` whenever `open` is
|
|
116
|
+
* false. For a part that renders nothing once it is not `present`, that is
|
|
117
|
+
* exactly while its exit plays. `presenceProps` adds `inert` for that time.
|
|
118
|
+
*/
|
|
119
|
+
readonly state: PresenceState,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Whether a part that shows while `open` should still be on the page, and
|
|
124
|
+
* the state to mark it with.
|
|
125
|
+
*
|
|
126
|
+
* `open` is the part's own open state, such as `dialog.open`. `ref` is the
|
|
127
|
+
* element whose animations decide when a closing part may go. It must be the
|
|
128
|
+
* element the part renders, and it only needs to be set while `present` is
|
|
129
|
+
* true. A part that mounts closed is not present and costs nothing, and a
|
|
130
|
+
* part that closes with no running animations is removed in the same commit,
|
|
131
|
+
* before the browser paints.
|
|
132
|
+
*
|
|
133
|
+
* The caller renders `null` when `present` is false. Otherwise it writes
|
|
134
|
+
* `data-state={state}`, and adds `inert` when `state` is `"closed"`. The
|
|
135
|
+
* module header explains why focus and the scroll lock stay keyed on `open`
|
|
136
|
+
* and not on this.
|
|
137
|
+
*/
|
|
138
|
+
export hook usePresence(open: boolean, ref: { readonly current: HTMLElement | null }): Presence {
|
|
139
|
+
const [shown, setShown] = useState<boolean>(open);
|
|
140
|
+
const [exiting, setExiting] = useState<boolean>(false);
|
|
141
|
+
|
|
142
|
+
// `open` changed since the last render: update the state now. Closing starts
|
|
143
|
+
// the exit and opening cancels it. React re-renders at once with the new
|
|
144
|
+
// state, so no frame is committed with the part missing (see the header).
|
|
145
|
+
if (open !== shown) {
|
|
146
|
+
setShown(open);
|
|
147
|
+
setExiting(!open);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
useLayoutEffect(() => {
|
|
151
|
+
if (!exiting) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const running = exitAnimations(ref.current);
|
|
155
|
+
if (running.length === 0) {
|
|
156
|
+
// Nothing to wait for, so the part goes in this commit, before paint.
|
|
157
|
+
setExiting(false);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
let settled = false;
|
|
161
|
+
const done = () => {
|
|
162
|
+
if (!settled) {
|
|
163
|
+
setExiting(false);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
// A cancelled animation rejects `finished`. It is treated as finished too:
|
|
167
|
+
// a part must not stay on the page waiting for an animation that ended
|
|
168
|
+
// some other way.
|
|
169
|
+
Promise.all(running.map((animation) => animation.finished.catch(() => undefined))).then(
|
|
170
|
+
done,
|
|
171
|
+
done,
|
|
172
|
+
);
|
|
173
|
+
return () => {
|
|
174
|
+
settled = true;
|
|
175
|
+
};
|
|
176
|
+
}, [exiting, ref]);
|
|
177
|
+
|
|
178
|
+
return { present: open || exiting, state: open ? "open" : "closed" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The part of an `Animation` this module reads. */
|
|
182
|
+
type Settling = {
|
|
183
|
+
readonly finished: Promise<mixed>,
|
|
184
|
+
readonly playState?: string,
|
|
185
|
+
readonly effect?: ?{
|
|
186
|
+
readonly getComputedTiming?: () => { readonly endTime?: number, ... },
|
|
187
|
+
...
|
|
188
|
+
},
|
|
189
|
+
...
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The animations on `element` and inside it that a closing part should wait
|
|
194
|
+
* for: everything running that will end by itself.
|
|
195
|
+
*
|
|
196
|
+
* An animation that repeats for ever has an `endTime` of `Infinity`, and a
|
|
197
|
+
* paused one does not advance, so neither is in the answer; the module header
|
|
198
|
+
* says why. A DOM without `getAnimations` answers nothing, which is a part
|
|
199
|
+
* with nothing to wait for.
|
|
200
|
+
*/
|
|
201
|
+
function exitAnimations(element: HTMLElement | null): $ReadOnlyArray<Settling> {
|
|
202
|
+
// Read through a structural type: `getAnimations` is recent enough that a
|
|
203
|
+
// DOM library may not declare it, and a test DOM may not have it.
|
|
204
|
+
const host: ?{
|
|
205
|
+
readonly getAnimations?: (options: { subtree: boolean }) => $ReadOnlyArray<Settling>,
|
|
206
|
+
...
|
|
207
|
+
} = element as $FlowFixMe;
|
|
208
|
+
if (host == null || typeof host.getAnimations !== "function") {
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
return host.getAnimations({ subtree: true }).filter((animation) => {
|
|
212
|
+
if (animation.playState === "paused") {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
const timing = animation.effect?.getComputedTiming?.();
|
|
216
|
+
return timing?.endTime == null || Number.isFinite(timing.endTime);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Whether anything on `element` or inside it is animating towards an end.
|
|
222
|
+
*
|
|
223
|
+
* For a part that measures itself: a measurement taken while a transition runs
|
|
224
|
+
* reads a frame of the transition, and one that briefly changes the element's
|
|
225
|
+
* own style to measure it would cancel the transition outright.
|
|
226
|
+
*/
|
|
227
|
+
export function isAnimating(element: HTMLElement | null): boolean {
|
|
228
|
+
return exitAnimations(element).length > 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The attributes a part writes: `data-state`, for the stylesheet, and `inert`
|
|
233
|
+
* while it is closing, so a part on its way out cannot be pressed, focused or
|
|
234
|
+
* found by a screen reader.
|
|
235
|
+
*
|
|
236
|
+
* `inert` is `undefined` otherwise, which React renders as no attribute at
|
|
237
|
+
* all. That includes a part that has finished closing and is still in the
|
|
238
|
+
* document, like a disclosure's `hidden` panel: find-in-page does not look
|
|
239
|
+
* inside an inert element, and `hidden="until-found"` exists to be found.
|
|
240
|
+
*/
|
|
241
|
+
export function presenceProps(presence: Presence): {|
|
|
242
|
+
"data-state": PresenceState,
|
|
243
|
+
inert: true | void,
|
|
244
|
+
|} {
|
|
245
|
+
return {
|
|
246
|
+
"data-state": presence.state,
|
|
247
|
+
inert: presence.present && presence.state === "closed" ? true : undefined,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @flow
|
|
2
2
|
"use client";
|
|
3
3
|
import * as React from "@uniflowed/react";
|
|
4
|
-
import { useMemo, useState } from "@uniflowed/react";
|
|
4
|
+
import { useMemo, useRef, useState } from "@uniflowed/react";
|
|
5
5
|
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
6
6
|
import { Temporal } from "@uniflowed/core/temporal";
|
|
7
7
|
import { useControlled } from "./controlled-state.js";
|
|
@@ -104,22 +104,31 @@ export component SegmentedField(time: boolean, options: DateFieldProps) {
|
|
|
104
104
|
const seconds = granularity === "second";
|
|
105
105
|
const [current, setCurrent] = useControlled(value, defaultValue, onValueChange);
|
|
106
106
|
const [draft, setDraft] = useState<Fields | null>(null);
|
|
107
|
+
// The draft as the last event handler left it, which can be ahead of the
|
|
108
|
+
// `draft` this render read: two keystrokes can land before React renders
|
|
109
|
+
// the first (#1609). Every handler reads and writes the fields through this,
|
|
110
|
+
// so none of them acts on — or commits — a render's stale copy.
|
|
111
|
+
const pending = useRef<Fields | null>(null);
|
|
107
112
|
const [announcement, announce] = useState("");
|
|
108
|
-
const fields = draft ?? fieldsFor(current, time, seconds);
|
|
109
|
-
const empty = names(time, seconds).every((part) => !fields[part]);
|
|
110
|
-
const serialized = serialize(fields, time, seconds);
|
|
111
113
|
const minimum =
|
|
112
114
|
minValue == null ? null : serialize(fieldsFor(minValue, time, seconds), time, seconds);
|
|
113
115
|
const maximumValue =
|
|
114
116
|
maxValue == null ? null : serialize(fieldsFor(maxValue, time, seconds), time, seconds);
|
|
115
117
|
if (minimum != null && maximumValue != null && minimum > maximumValue)
|
|
116
118
|
throw new RangeError("DateField minimum exceeds maximum");
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
119
|
+
const assess = (fields: Fields) => {
|
|
120
|
+
const empty = names(time, seconds).every((part) => !fields[part]);
|
|
121
|
+
const serialized = serialize(fields, time, seconds);
|
|
122
|
+
const invalid =
|
|
123
|
+
(empty ? required : serialized == null) ||
|
|
124
|
+
(serialized != null &&
|
|
125
|
+
((minimum != null && serialized < minimum) ||
|
|
126
|
+
(maximumValue != null && serialized > maximumValue) ||
|
|
127
|
+
isDateUnavailable?.(serialized) === true));
|
|
128
|
+
return { empty, serialized, invalid };
|
|
129
|
+
};
|
|
130
|
+
const fields = draft ?? fieldsFor(current, time, seconds);
|
|
131
|
+
const { invalid } = assess(fields);
|
|
123
132
|
const digits = useMemo(() => new Intl.NumberFormat(locale, { useGrouping: false }), [locale]);
|
|
124
133
|
const labels: $FlowFixMe = useMemo(
|
|
125
134
|
() => new (Intl as $FlowFixMe).DisplayNames(locale, { type: "dateTimeField" }),
|
|
@@ -170,22 +179,34 @@ export component SegmentedField(time: boolean, options: DateFieldProps) {
|
|
|
170
179
|
result = result.split(digits.format(digit)).join(String(digit));
|
|
171
180
|
return result.replace(/[^0-9]/g, "");
|
|
172
181
|
};
|
|
182
|
+
const latest = useStableCallback(
|
|
183
|
+
(): Fields => pending.current ?? fieldsFor(current, time, seconds),
|
|
184
|
+
);
|
|
185
|
+
const propose = useStableCallback((next: Fields) => {
|
|
186
|
+
pending.current = next;
|
|
187
|
+
setDraft(next);
|
|
188
|
+
});
|
|
173
189
|
const commit = useStableCallback(() => {
|
|
174
190
|
if (disabled || readOnly) return;
|
|
191
|
+
const { empty, serialized, invalid } = assess(latest());
|
|
175
192
|
onValidationChange?.(invalid);
|
|
176
193
|
if (invalid) {
|
|
177
194
|
announce("Enter a valid value within the allowed range");
|
|
178
195
|
return;
|
|
179
196
|
}
|
|
180
197
|
setCurrent(empty ? null : serialized);
|
|
198
|
+
pending.current = null;
|
|
181
199
|
setDraft(null);
|
|
182
200
|
announce(serialized ?? "Cleared");
|
|
183
201
|
});
|
|
184
|
-
const edit = useStableCallback((part: Segment,
|
|
185
|
-
if (
|
|
202
|
+
const edit = useStableCallback((part: Segment, change: (fields: Fields) => string) => {
|
|
203
|
+
if (disabled || readOnly) return;
|
|
204
|
+
const fields = latest();
|
|
205
|
+
propose({ ...fields, [part]: change(fields) });
|
|
186
206
|
});
|
|
187
207
|
const keyboard = useStableCallback((event: $FlowFixMe, part: Segment) => {
|
|
188
208
|
if (disabled || readOnly) return;
|
|
209
|
+
const fields = latest();
|
|
189
210
|
const key = event.key;
|
|
190
211
|
if (key === "Enter") {
|
|
191
212
|
event.preventDefault();
|
|
@@ -221,12 +242,16 @@ export component SegmentedField(time: boolean, options: DateFieldProps) {
|
|
|
221
242
|
const nextFields = { ...fields, [part]: String(adjusted) };
|
|
222
243
|
if (!time && (part === "month" || part === "year") && nextFields.day)
|
|
223
244
|
nextFields.day = String(Math.min(Number(nextFields.day), maximum("day", nextFields)));
|
|
224
|
-
|
|
245
|
+
propose(nextFields);
|
|
225
246
|
});
|
|
226
247
|
const segments = parts.map((part, index) => {
|
|
227
248
|
if (part.type === "dayPeriod") {
|
|
228
249
|
const pm = Number(fields.hour) >= 12;
|
|
229
|
-
const toggle = () =>
|
|
250
|
+
const toggle = () =>
|
|
251
|
+
edit("hour", (fields) => {
|
|
252
|
+
const hour = Number(fields.hour) || 0;
|
|
253
|
+
return String(hour + (hour >= 12 ? -12 : 12));
|
|
254
|
+
});
|
|
230
255
|
return (
|
|
231
256
|
<span
|
|
232
257
|
key="period"
|
|
@@ -286,8 +311,7 @@ export component SegmentedField(time: boolean, options: DateFieldProps) {
|
|
|
286
311
|
value={text ? encode(text) : ""}
|
|
287
312
|
onChange={(event) => {
|
|
288
313
|
const text = decode(event.currentTarget.value);
|
|
289
|
-
edit(
|
|
290
|
-
name,
|
|
314
|
+
edit(name, (fields) =>
|
|
291
315
|
name === "hour" && hour12 && text && Number(text) <= 12
|
|
292
316
|
? String((Number(text) % 12) + (Number(fields.hour) >= 12 ? 12 : 0))
|
|
293
317
|
: text,
|
package/menu.js
CHANGED
|
@@ -127,6 +127,7 @@ import {
|
|
|
127
127
|
useTypeahead,
|
|
128
128
|
} from "./internal/roving-focus.js";
|
|
129
129
|
import { useControlled } from "./internal/controlled-state.js";
|
|
130
|
+
import { presenceProps, usePresence } from "./internal/presence.js";
|
|
130
131
|
import {
|
|
131
132
|
ITEM_SELECTOR,
|
|
132
133
|
MENU_SELECTOR,
|
|
@@ -310,6 +311,11 @@ component MenuBody(
|
|
|
310
311
|
// be a parameter default because it is not a constant: it is the answer to
|
|
311
312
|
// "is this the outermost menu", which only this component knows.
|
|
312
313
|
const placement = side ?? (isRoot ? "bottom" : "inline-end");
|
|
314
|
+
// On the page while its exit runs; focus and the outside press stay keyed on
|
|
315
|
+
// `open`. A submenu has its own, so a tree that closes at once leaves at once.
|
|
316
|
+
// `open` is menu-tree metadata; no ref value is read during render.
|
|
317
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
318
|
+
const presence = usePresence(menu.open, bodyRef);
|
|
313
319
|
// useAnchor accepts ref objects and reads them from layout/effects.
|
|
314
320
|
// uf-lint-disable-next-line react-compiler/refs
|
|
315
321
|
const anchored = useAnchor({
|
|
@@ -320,9 +326,7 @@ component MenuBody(
|
|
|
320
326
|
anchorRef: triggerRef,
|
|
321
327
|
avoidCollisions,
|
|
322
328
|
collisionPadding,
|
|
323
|
-
|
|
324
|
-
// uf-lint-disable-next-line react-compiler/refs
|
|
325
|
-
open: menu.open,
|
|
329
|
+
open: presence.present,
|
|
326
330
|
overlayRef: bodyRef,
|
|
327
331
|
side: placement,
|
|
328
332
|
sideOffset,
|
|
@@ -385,13 +389,12 @@ component MenuBody(
|
|
|
385
389
|
|
|
386
390
|
const list = useMemo(() => ({ activeId, setActiveId }), [activeId]);
|
|
387
391
|
|
|
388
|
-
|
|
389
|
-
// uf-lint-disable-next-line react-compiler/refs
|
|
390
|
-
if (!menu.open) {
|
|
392
|
+
if (!presence.present) {
|
|
391
393
|
return null;
|
|
392
394
|
}
|
|
393
395
|
|
|
394
396
|
const props = withProps(withoutComposed(rest, ["onKeyDown", "ref"]), {
|
|
397
|
+
...presenceProps(presence),
|
|
395
398
|
// `triggered` and `base` are menu-tree metadata, not ref values.
|
|
396
399
|
// uf-lint-disable-next-line react-compiler/refs
|
|
397
400
|
"aria-labelledby": menu.triggered ? `${menu.base}-trigger` : undefined,
|
package/navigation-menu.js
CHANGED
|
@@ -43,6 +43,10 @@
|
|
|
43
43
|
// navigation on the way to a word in the article would be a surprise with
|
|
44
44
|
// nothing to show for it.
|
|
45
45
|
//
|
|
46
|
+
// A group that has just closed does stay for as long as its exit transition
|
|
47
|
+
// runs, marked `data-state="closed"` and `inert`, so it cannot be tabbed into
|
|
48
|
+
// or read while it fades. `internal/presence.js` has the rule.
|
|
49
|
+
//
|
|
46
50
|
// # Name the landmark
|
|
47
51
|
//
|
|
48
52
|
// `<nav>` is a landmark, and a page with two unnamed ones gives a reader a
|
|
@@ -51,11 +55,12 @@
|
|
|
51
55
|
"use client";
|
|
52
56
|
|
|
53
57
|
import * as React from "@uniflowed/react";
|
|
54
|
-
import { createContext, useContext, useId, useMemo, useState } from "@uniflowed/react";
|
|
58
|
+
import { createContext, useContext, useId, useMemo, useRef, useState } from "@uniflowed/react";
|
|
55
59
|
|
|
56
60
|
import type { Rest } from "./internal/merge-props.js";
|
|
57
|
-
import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
|
|
58
|
-
import {
|
|
61
|
+
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
62
|
+
import { useRegistered } from "./internal/disclosure.js";
|
|
63
|
+
import { presenceProps, usePresence } from "./internal/presence.js";
|
|
59
64
|
import { useControlled } from "./internal/controlled-state.js";
|
|
60
65
|
|
|
61
66
|
type NavigationMenuState = {|
|
|
@@ -210,14 +215,25 @@ component NavigationMenuBody(children: renders* NavigationMenuLink, ...rest: Res
|
|
|
210
215
|
// closed group is removed rather than hidden. The register has to be handed
|
|
211
216
|
// over conditionally rather than the hook called conditionally, because a
|
|
212
217
|
// hook that runs on some renders and not others is a different bug.
|
|
213
|
-
|
|
218
|
+
useRegistered(item.expanded ? item.registerBody : undefined);
|
|
219
|
+
const bodyRef = useRef<HTMLElement | null>(null);
|
|
220
|
+
const presence = usePresence(item.expanded, bodyRef);
|
|
214
221
|
|
|
215
|
-
if (!
|
|
222
|
+
if (!presence.present) {
|
|
216
223
|
return null;
|
|
217
224
|
}
|
|
218
225
|
|
|
219
226
|
return (
|
|
220
|
-
<ul
|
|
227
|
+
<ul
|
|
228
|
+
{...withoutComposed(rest, ["ref"])}
|
|
229
|
+
{...presenceProps(presence)}
|
|
230
|
+
aria-labelledby={item.triggerId}
|
|
231
|
+
id={item.bodyId}
|
|
232
|
+
// React calls callback refs during commit; the presence hook reads it later.
|
|
233
|
+
ref={composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
234
|
+
bodyRef.current = element;
|
|
235
|
+
})}
|
|
236
|
+
>
|
|
221
237
|
{children}
|
|
222
238
|
</ul>
|
|
223
239
|
);
|
package/number-field.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use client";
|
|
3
3
|
|
|
4
4
|
import * as React from "@uniflowed/react";
|
|
5
|
-
import { createContext, useContext, useMemo, useState } from "@uniflowed/react";
|
|
5
|
+
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "@uniflowed/react";
|
|
6
6
|
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
7
7
|
import { useControlled } from "./internal/controlled-state.js";
|
|
8
8
|
import type { RenderProp, Rest } from "./internal/merge-props.js";
|
|
@@ -119,27 +119,55 @@ component NumberFieldRoot(
|
|
|
119
119
|
const formatter = useMemo(() => numberFormatter(locale, formatOptions), [locale, formatOptions]);
|
|
120
120
|
const [current, setCurrent] = useControlled(value, defaultValue, onValueChange);
|
|
121
121
|
const [draft, setDraft] = useState<string | null>(null);
|
|
122
|
+
// The text as the last event handler left it, which can be ahead of the
|
|
123
|
+
// `draft` and `current` this render read: two keystrokes can land before React
|
|
124
|
+
// renders the first, and a handler installed by `useStableCallback` sees the
|
|
125
|
+
// values of the render that installed it. Without this, a second `ArrowUp`
|
|
126
|
+
// stepped from the same number as the first, and `Enter` committed the value
|
|
127
|
+
// from before either of them — `internal/segmented-field.js` holds a ref for
|
|
128
|
+
// the same window (#1609); this is #1614.
|
|
129
|
+
//
|
|
130
|
+
// Cleared after every commit rather than only when a value is stored, so a
|
|
131
|
+
// controlled parent that refuses a change still wins the next keystroke: the
|
|
132
|
+
// ref only ever spans a batch React has not rendered yet.
|
|
133
|
+
const proposed = useRef<string | null>(null);
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
proposed.current = null;
|
|
136
|
+
});
|
|
137
|
+
const assess = (source: string) => {
|
|
138
|
+
const parsed = parseNumber(source, formatter);
|
|
139
|
+
const invalid =
|
|
140
|
+
parsed != null &&
|
|
141
|
+
(!Number.isFinite(parsed) ||
|
|
142
|
+
(min != null && parsed < min) ||
|
|
143
|
+
(max != null && parsed > max) ||
|
|
144
|
+
Math.abs(
|
|
145
|
+
(parsed - (min ?? 0)) / increment - Math.round((parsed - (min ?? 0)) / increment),
|
|
146
|
+
) > 1e-7);
|
|
147
|
+
return { parsed, invalid };
|
|
148
|
+
};
|
|
122
149
|
const text = draft ?? (current == null ? "" : formatter.format(current));
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
Math.abs((parsed - (min ?? 0)) / increment - Math.round((parsed - (min ?? 0)) / increment)) >
|
|
130
|
-
1e-7);
|
|
150
|
+
const { invalid } = assess(text);
|
|
151
|
+
// Falls back to `text`, not to `current`: a draft the reader typed and has not
|
|
152
|
+
// committed is real state, and once the effect above has cleared `proposed` it
|
|
153
|
+
// is the only record of it. Reading `current` here committed the old value and
|
|
154
|
+
// threw away an invalid draft the field was meant to keep for correction.
|
|
155
|
+
const latest = useStableCallback((): string => proposed.current ?? text);
|
|
131
156
|
const assign = useStableCallback((next: number | null) => {
|
|
132
157
|
if (disabled || readOnly) return;
|
|
158
|
+
proposed.current = next == null ? "" : formatter.format(next);
|
|
133
159
|
setDraft(null);
|
|
134
160
|
setCurrent(next);
|
|
135
161
|
onValidationChange?.(false);
|
|
136
162
|
});
|
|
137
163
|
const commit = useStableCallback(() => {
|
|
138
164
|
if (disabled || readOnly) return;
|
|
165
|
+
const { parsed, invalid } = assess(latest());
|
|
139
166
|
onValidationChange?.(invalid);
|
|
140
167
|
if (!invalid) assign(parsed);
|
|
141
168
|
});
|
|
142
169
|
const stepBy = useStableCallback((direction: number) => {
|
|
170
|
+
const { parsed } = assess(latest());
|
|
143
171
|
const start = parsed != null && Number.isFinite(parsed) ? parsed : (current ?? min ?? 0);
|
|
144
172
|
const base = min ?? 0;
|
|
145
173
|
const position = (start - base) / increment;
|
|
@@ -164,7 +192,9 @@ component NumberFieldRoot(
|
|
|
164
192
|
max: upperBound,
|
|
165
193
|
formatter,
|
|
166
194
|
edit: (next: string) => {
|
|
167
|
-
if (
|
|
195
|
+
if (disabled || readOnly) return;
|
|
196
|
+
proposed.current = next;
|
|
197
|
+
setDraft(next);
|
|
168
198
|
},
|
|
169
199
|
commit,
|
|
170
200
|
stepBy,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
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",
|
|
@@ -19,10 +19,10 @@
|
|
|
19
19
|
"!*.test.js"
|
|
20
20
|
],
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@uniflowed/core": "0.
|
|
23
|
-
"@uniflowed/hooks": "0.
|
|
24
|
-
"@uniflowed/react": "0.
|
|
25
|
-
"@uniflowed/state": "0.
|
|
22
|
+
"@uniflowed/core": "0.10.0",
|
|
23
|
+
"@uniflowed/hooks": "0.10.0",
|
|
24
|
+
"@uniflowed/react": "0.10.0",
|
|
25
|
+
"@uniflowed/state": "0.10.0"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
28
|
"react": ">=19"
|
package/popover.js
CHANGED
|
@@ -71,7 +71,8 @@ import {
|
|
|
71
71
|
import { focusable } from "./internal/focus.js";
|
|
72
72
|
import { useAnchor } from "./internal/anchor.js";
|
|
73
73
|
import { useControlled } from "./internal/controlled-state.js";
|
|
74
|
-
import {
|
|
74
|
+
import { useRegistered } from "./internal/disclosure.js";
|
|
75
|
+
import { presenceProps, usePresence } from "./internal/presence.js";
|
|
75
76
|
|
|
76
77
|
export type { Align, LogicalSide, Side } from "./internal/anchor.js";
|
|
77
78
|
|
|
@@ -155,7 +156,7 @@ component PopoverRoot(
|
|
|
155
156
|
component PopoverTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
156
157
|
const popover = usePopover("Popover.Trigger");
|
|
157
158
|
const passed = withoutComposed(rest, ["onClick", "ref"]);
|
|
158
|
-
|
|
159
|
+
useRegistered(popover.registerTrigger);
|
|
159
160
|
const props = withProps(passed, {
|
|
160
161
|
// Only while it is open: an `aria-controls` naming an element that is not
|
|
161
162
|
// in the document tells a reader there is somewhere to go and then has
|
|
@@ -221,6 +222,9 @@ component PopoverBody(
|
|
|
221
222
|
// trigger would undo the thing they just did.
|
|
222
223
|
const left = useRef(false);
|
|
223
224
|
const triggerRef = popover.triggerRef;
|
|
225
|
+
// On the page while its exit runs; everything else stays keyed on `open`.
|
|
226
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
227
|
+
const presence = usePresence(popover.open, bodyRef);
|
|
224
228
|
|
|
225
229
|
// useAnchor accepts ref objects and reads them from layout/effects.
|
|
226
230
|
// uf-lint-disable-next-line react-compiler/refs
|
|
@@ -231,9 +235,8 @@ component PopoverBody(
|
|
|
231
235
|
anchorRef: triggerRef,
|
|
232
236
|
avoidCollisions,
|
|
233
237
|
collisionPadding,
|
|
234
|
-
//
|
|
235
|
-
|
|
236
|
-
open: popover.open,
|
|
238
|
+
// Placed for as long as it is on the page, so it leaves from where it was.
|
|
239
|
+
open: presence.present,
|
|
237
240
|
overlayRef: bodyRef,
|
|
238
241
|
side,
|
|
239
242
|
sideOffset,
|
|
@@ -313,9 +316,7 @@ component PopoverBody(
|
|
|
313
316
|
refs: [bodyRef, triggerRef],
|
|
314
317
|
});
|
|
315
318
|
|
|
316
|
-
|
|
317
|
-
// uf-lint-disable-next-line react-compiler/refs
|
|
318
|
-
if (!popover.open) {
|
|
319
|
+
if (!presence.present) {
|
|
319
320
|
return null;
|
|
320
321
|
}
|
|
321
322
|
|
|
@@ -332,7 +333,7 @@ component PopoverBody(
|
|
|
332
333
|
children,
|
|
333
334
|
"data-align": anchored.align,
|
|
334
335
|
"data-side": anchored.side,
|
|
335
|
-
|
|
336
|
+
...presenceProps(presence),
|
|
336
337
|
// `base` is popover metadata, not a ref value.
|
|
337
338
|
// uf-lint-disable-next-line react-compiler/refs
|
|
338
339
|
id: `${popover.base}-body`,
|