@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.
@@ -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 invalid =
118
- (empty ? required : serialized == null) ||
119
- (serialized != null &&
120
- ((minimum != null && serialized < minimum) ||
121
- (maximumValue != null && serialized > maximumValue) ||
122
- isDateUnavailable?.(serialized) === true));
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, text: string) => {
185
- if (!disabled && !readOnly) setDraft({ ...fields, [part]: text });
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
- setDraft(nextFields);
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 = () => edit("hour", String((Number(fields.hour) || 0) + (pm ? -12 : 12)));
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
- // `open` is menu-tree metadata; no ref value is read during render.
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
- // `open` is menu-tree metadata; no ref value is read during render.
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,
@@ -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 { usePresence } from "./internal/disclosure.js";
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
- usePresence(item.expanded ? item.registerBody : undefined);
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 (!item.expanded) {
222
+ if (!presence.present) {
216
223
  return null;
217
224
  }
218
225
 
219
226
  return (
220
- <ul {...rest} aria-labelledby={item.triggerId} id={item.bodyId}>
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 parsed = parseNumber(text, formatter);
124
- const invalid =
125
- parsed != null &&
126
- (!Number.isFinite(parsed) ||
127
- (min != null && parsed < min) ||
128
- (max != null && parsed > max) ||
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 (!disabled && !readOnly) setDraft(next);
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.8.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.8.0",
23
- "@uniflowed/hooks": "0.8.0",
24
- "@uniflowed/react": "0.8.0",
25
- "@uniflowed/state": "0.8.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 { usePresence } from "./internal/disclosure.js";
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
- usePresence(popover.registerTrigger);
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
- // `open` is popover metadata; no ref value is read during render.
235
- // uf-lint-disable-next-line react-compiler/refs
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
- // `open` is popover metadata; no ref value is read during render.
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
- "data-state": "open",
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`,