@uniflowed/ui 0.0.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/accordion.js +335 -0
- package/alert-dialog.js +256 -0
- package/carousel.js +410 -0
- package/checkbox.js +80 -0
- package/collapsible.js +147 -0
- package/combobox.js +557 -0
- package/dialog.js +499 -0
- package/drawer.js +458 -0
- package/field.js +170 -0
- package/hover-card.js +334 -0
- package/index.js +1012 -0
- package/input-otp.js +218 -0
- package/internal/anchor.js +500 -0
- package/internal/controlled-state.js +65 -0
- package/internal/disclosure.js +97 -0
- package/internal/focus.js +64 -0
- package/internal/form-value.js +83 -0
- package/internal/hover-intent.js +259 -0
- package/internal/merge-props.js +201 -0
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +430 -0
- package/menu.js +654 -0
- package/navigation-menu.js +251 -0
- package/package.json +57 -0
- package/pagination.js +197 -0
- package/popover.js +326 -0
- package/progress.js +86 -0
- package/radio-group.js +298 -0
- package/resizable.js +307 -0
- package/scroll-area.js +283 -0
- package/select.js +855 -0
- package/sheet.js +165 -0
- package/sidebar.js +300 -0
- package/slider.js +405 -0
- package/switch.js +73 -0
- package/table.js +479 -0
- package/tabs.js +280 -0
- package/toast.js +624 -0
- package/toggle-group.js +280 -0
- package/toggle.js +91 -0
- package/tooltip.js +411 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Where an overlay goes, given where its trigger is.
|
|
4
|
+
//
|
|
5
|
+
// A popover, a tooltip and a hover card are the same three sentences: a box
|
|
6
|
+
// appears next to another box, it stays on the screen when there is no room
|
|
7
|
+
// where it was asked to go, and it says where it ended up so a stylesheet can
|
|
8
|
+
// point an arrow at the trigger. Written into each component, those three
|
|
9
|
+
// sentences are three copies that agree until one of them is fixed.
|
|
10
|
+
//
|
|
11
|
+
// # Flip *and* shift, because a flip alone is not enough
|
|
12
|
+
//
|
|
13
|
+
// There are two answers to "it does not fit", and a positioning layer needs
|
|
14
|
+
// both:
|
|
15
|
+
//
|
|
16
|
+
// * **Flip** — the main axis. A menu whose trigger is near the bottom of the
|
|
17
|
+
// viewport opens upwards instead of off the page, and reports
|
|
18
|
+
// `data-side="top"` so the arrow moves with it.
|
|
19
|
+
// * **Shift** — the cross axis. A menu wider than its trigger, aligned to a
|
|
20
|
+
// trigger near the right edge, has nowhere to flip *to*: both alignments
|
|
21
|
+
// overflow when the overlay is wider than the room on either side of the
|
|
22
|
+
// anchor. Sliding it back along the trigger is the only answer that keeps
|
|
23
|
+
// it on screen, and it is what Floating UI calls `shift`.
|
|
24
|
+
//
|
|
25
|
+
// Flipping the *alignment* — `start` becomes `end` when the aligned edge would
|
|
26
|
+
// leave the viewport — is the thing that looks like a shift and is not. It
|
|
27
|
+
// moves the overlay by its own width, which is a jump the reader sees, and it
|
|
28
|
+
// still overflows in exactly the case above. So the alignment never flips here:
|
|
29
|
+
// the side flips, the cross axis slides, and `data-align` keeps saying what the
|
|
30
|
+
// caller asked for.
|
|
31
|
+
//
|
|
32
|
+
// # One tier, measured, and why there is no declarative second one
|
|
33
|
+
//
|
|
34
|
+
// The platform grew this capability: CSS anchor positioning places an element
|
|
35
|
+
// against an `anchor-name` in the compositor, with `position-try-fallbacks` for
|
|
36
|
+
// the collision case and no JavaScript on the scroll path. It is the better
|
|
37
|
+
// mechanism and it is deliberately not used here, because it cannot express
|
|
38
|
+
// half of what this module promises:
|
|
39
|
+
//
|
|
40
|
+
// * `position-area` places a box in one of a grid of regions around the
|
|
41
|
+
// anchor. There is no spelling that says "and then move it back inside the
|
|
42
|
+
// viewport": the property chooses a region, it does not clamp a coordinate.
|
|
43
|
+
// * `position-try-fallbacks`, with the built-in `flip-block` / `flip-inline`
|
|
44
|
+
// or a hand-written `@position-try` block, tries another *placement* when
|
|
45
|
+
// the first overflows. Every one of those is a flip. None is a slide.
|
|
46
|
+
// * `position-area: span-all` with `justify-self: anchor-center` centres the
|
|
47
|
+
// overlay on the anchor and then needs a `margin-inline` to pull it back
|
|
48
|
+
// inside — and that margin is a number only a measurement produces, so the
|
|
49
|
+
// declarative path would be a measured path wearing a stylesheet.
|
|
50
|
+
//
|
|
51
|
+
// A tier that flips and a tier that flips *and* slides put the same overlay in
|
|
52
|
+
// two different places, and which one a reader gets depends on their browser.
|
|
53
|
+
// Shipping one tier that both engines run is worth more than shipping the
|
|
54
|
+
// faster one to some of them, so this module measures. It is the reason
|
|
55
|
+
// `packages/ui` has no `CSS.supports` in it: there is nothing to branch on.
|
|
56
|
+
//
|
|
57
|
+
// The measuring is kept cheap rather than clever. A placement is computed on
|
|
58
|
+
// open, on a scroll anywhere in the page, on a viewport resize and on a resize
|
|
59
|
+
// of either box — and each of those is one `getBoundingClientRect` per box
|
|
60
|
+
// followed by the arithmetic below, which reads nothing else.
|
|
61
|
+
//
|
|
62
|
+
// # Why the arithmetic is a function and not a hook
|
|
63
|
+
//
|
|
64
|
+
// `placeOverlay` takes three rectangles and returns a placement. It touches no
|
|
65
|
+
// element, no window and no React, so the whole of the collision behaviour —
|
|
66
|
+
// every flip, every slide, right-to-left alignment, the overlay that is wider
|
|
67
|
+
// than the viewport — is testable by calling it with numbers. That matters more
|
|
68
|
+
// here than in most modules: the DOM these components are tested in computes no
|
|
69
|
+
// layout at all, so a test that went through the hook could only assert *that*
|
|
70
|
+
// a position was applied, never that it was the right one.
|
|
71
|
+
//
|
|
72
|
+
// # Why an overlay is `position: fixed`
|
|
73
|
+
//
|
|
74
|
+
// Because the coordinates are the viewport's. It also gets most of what a
|
|
75
|
+
// portal is usually reached for: a fixed box is not clipped by an ancestor's
|
|
76
|
+
// `overflow: hidden`, so a menu in a table row or a scroll container is not cut
|
|
77
|
+
// in half — without moving the element away from its trigger in the
|
|
78
|
+
// accessibility tree, which is the trade `dialog.js`'s header refuses to make.
|
|
79
|
+
// The exception is an ancestor with `transform`, `filter` or `will-change`,
|
|
80
|
+
// which becomes the containing block for a fixed descendant; that is the CSS
|
|
81
|
+
// the top layer would answer, and answering it is ubugeeei-prod/uf#256's
|
|
82
|
+
// remaining half rather than this module's.
|
|
83
|
+
//
|
|
84
|
+
// # Why this is `internal/` and not a subpath
|
|
85
|
+
//
|
|
86
|
+
// The same reason `roving-focus.js` gives. This is not a positioning library,
|
|
87
|
+
// it is the relationship the overlay parts build: a `data-side` that agrees
|
|
88
|
+
// with the coordinates, one set of custom-property names for a stylesheet to
|
|
89
|
+
// read, and one definition of what `start` means in a right-to-left page.
|
|
90
|
+
// Exported, a consumer could build a part that positions itself differently and
|
|
91
|
+
// still calls itself an overlay.
|
|
92
|
+
|
|
93
|
+
import { useEffect, useState } from "@uniflowed/react";
|
|
94
|
+
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
95
|
+
|
|
96
|
+
import type { Direction } from "./roving-focus.js";
|
|
97
|
+
import { directionOf } from "./roving-focus.js";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Which side of its trigger an overlay opens onto.
|
|
101
|
+
*
|
|
102
|
+
* Physical rather than logical, which is the opposite of `Align` below and is
|
|
103
|
+
* deliberate: `side` answers "above or below", and the two horizontal ones are
|
|
104
|
+
* named after the screen because a design that puts a popover to the right of
|
|
105
|
+
* a toolbar means the right of the toolbar in any writing direction. What the
|
|
106
|
+
* writing direction changes is the *alignment*, which is where the reading
|
|
107
|
+
* order actually lives.
|
|
108
|
+
*/
|
|
109
|
+
export type Side = "top" | "right" | "bottom" | "left";
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Where the overlay sits along the trigger's other axis.
|
|
113
|
+
*
|
|
114
|
+
* Logical: `start` is the left edge in a left-to-right page and the right edge
|
|
115
|
+
* in a right-to-left one, so a menu aligned to the start of its trigger opens
|
|
116
|
+
* the way the reader reads in both.
|
|
117
|
+
*/
|
|
118
|
+
export type Align = "start" | "center" | "end";
|
|
119
|
+
|
|
120
|
+
/** A box, in viewport coordinates: what `getBoundingClientRect` reports. */
|
|
121
|
+
export type Rect = {|
|
|
122
|
+
readonly x: number,
|
|
123
|
+
readonly y: number,
|
|
124
|
+
readonly width: number,
|
|
125
|
+
readonly height: number,
|
|
126
|
+
|};
|
|
127
|
+
|
|
128
|
+
/** Everything `placeOverlay` needs, and nothing it could read for itself. */
|
|
129
|
+
export type Anchoring = {|
|
|
130
|
+
/** The trigger. */
|
|
131
|
+
readonly anchor: Rect,
|
|
132
|
+
/** The overlay. Only its size is used; where it is now does not matter. */
|
|
133
|
+
readonly overlay: Rect,
|
|
134
|
+
/** What it has to stay inside, which is the viewport for a fixed element. */
|
|
135
|
+
readonly viewport: Rect,
|
|
136
|
+
readonly side: Side,
|
|
137
|
+
readonly align: Align,
|
|
138
|
+
/** The gap between the trigger and the overlay, in pixels. */
|
|
139
|
+
readonly sideOffset: number,
|
|
140
|
+
/** A nudge along the cross axis, in the direction the page reads. */
|
|
141
|
+
readonly alignOffset: number,
|
|
142
|
+
/** Whether to flip and slide at all. `false` places it exactly as asked. */
|
|
143
|
+
readonly avoidCollisions: boolean,
|
|
144
|
+
/** How close to the viewport edge the overlay may come. */
|
|
145
|
+
readonly collisionPadding: number,
|
|
146
|
+
readonly direction: Direction,
|
|
147
|
+
|};
|
|
148
|
+
|
|
149
|
+
/** Where the overlay goes, and what the placement had to do to get there. */
|
|
150
|
+
export type Placement = {|
|
|
151
|
+
readonly x: number,
|
|
152
|
+
readonly y: number,
|
|
153
|
+
/** The side it ended up on, which is the requested one unless it flipped. */
|
|
154
|
+
readonly side: Side,
|
|
155
|
+
/** The requested alignment. It never changes; see the module header. */
|
|
156
|
+
readonly align: Align,
|
|
157
|
+
/** How far it slid along the cross axis, so an arrow can be moved back. */
|
|
158
|
+
readonly shift: number,
|
|
159
|
+
/** The room between the trigger and the viewport edge, and across it. */
|
|
160
|
+
readonly availableWidth: number,
|
|
161
|
+
readonly availableHeight: number,
|
|
162
|
+
|};
|
|
163
|
+
|
|
164
|
+
/** What `useAnchor` reports back for `data-side` and `data-align`. */
|
|
165
|
+
export type Anchored = {|
|
|
166
|
+
readonly side: Side,
|
|
167
|
+
readonly align: Align,
|
|
168
|
+
|};
|
|
169
|
+
|
|
170
|
+
/** What `useAnchor` is told, on top of the geometry `placeOverlay` needs. */
|
|
171
|
+
export type AnchorRequest = {|
|
|
172
|
+
readonly anchorRef: { current: HTMLElement | null },
|
|
173
|
+
readonly overlayRef: { current: HTMLElement | null },
|
|
174
|
+
/** Nothing is measured while it is closed: there is nothing to measure. */
|
|
175
|
+
readonly open: boolean,
|
|
176
|
+
readonly side: Side,
|
|
177
|
+
readonly align: Align,
|
|
178
|
+
readonly sideOffset: number,
|
|
179
|
+
readonly alignOffset: number,
|
|
180
|
+
readonly avoidCollisions: boolean,
|
|
181
|
+
readonly collisionPadding: number,
|
|
182
|
+
|};
|
|
183
|
+
|
|
184
|
+
/** The side a flip goes to. */
|
|
185
|
+
const OPPOSITE: { readonly [Side]: Side } = {
|
|
186
|
+
top: "bottom",
|
|
187
|
+
bottom: "top",
|
|
188
|
+
left: "right",
|
|
189
|
+
right: "left",
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/** Whether a side stacks the overlay above the trigger or beside it. */
|
|
193
|
+
function isVertical(side: Side): boolean {
|
|
194
|
+
return side === "top" || side === "bottom";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The space between each edge of the anchor and the edge of the viewport,
|
|
199
|
+
* already minus the padding the overlay must not come inside.
|
|
200
|
+
*
|
|
201
|
+
* Negative for an anchor that is itself off the screen, which is a real state
|
|
202
|
+
* — a trigger scrolled halfway out of a container — and is why nothing below
|
|
203
|
+
* assumes these are positive.
|
|
204
|
+
*/
|
|
205
|
+
function roomAround(
|
|
206
|
+
anchor: Rect,
|
|
207
|
+
viewport: Rect,
|
|
208
|
+
collisionPadding: number,
|
|
209
|
+
): { readonly [Side]: number } {
|
|
210
|
+
return {
|
|
211
|
+
top: anchor.y - (viewport.y + collisionPadding),
|
|
212
|
+
bottom: viewport.y + viewport.height - collisionPadding - (anchor.y + anchor.height),
|
|
213
|
+
left: anchor.x - (viewport.x + collisionPadding),
|
|
214
|
+
right: viewport.x + viewport.width - collisionPadding - (anchor.x + anchor.width),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The side the overlay actually fits on.
|
|
220
|
+
*
|
|
221
|
+
* The requested side wins whenever it fits, because a component that moved an
|
|
222
|
+
* overlay it did not have to move is one a designer cannot lay out against.
|
|
223
|
+
* When it does not fit and the opposite side does, it flips. When *neither*
|
|
224
|
+
* fits — a tall overlay next to a trigger in the middle of a short viewport —
|
|
225
|
+
* it takes the side with more room, so the reader sees as much of it as the
|
|
226
|
+
* page allows and `availableHeight` tells the stylesheet how much that was.
|
|
227
|
+
*/
|
|
228
|
+
function sideThatFits(anchoring: Anchoring, room: { readonly [Side]: number }): Side {
|
|
229
|
+
const { overlay, side, sideOffset } = anchoring;
|
|
230
|
+
const needed = (isVertical(side) ? overlay.height : overlay.width) + sideOffset;
|
|
231
|
+
const opposite = OPPOSITE[side];
|
|
232
|
+
if (room[side] >= needed) {
|
|
233
|
+
return side;
|
|
234
|
+
}
|
|
235
|
+
if (room[opposite] >= needed) {
|
|
236
|
+
return opposite;
|
|
237
|
+
}
|
|
238
|
+
return room[opposite] > room[side] ? opposite : side;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Where the overlay's leading edge goes along the cross axis, before sliding.
|
|
243
|
+
*
|
|
244
|
+
* `start` is the anchor's leading edge, `end` puts the overlay's trailing edge
|
|
245
|
+
* on the anchor's, and `center` splits the difference. Which edge is "leading"
|
|
246
|
+
* is the writing direction's business, and only on a horizontal cross axis: a
|
|
247
|
+
* page that reads right to left still reads top to bottom, so an overlay beside
|
|
248
|
+
* its trigger aligns to the top for `start` either way.
|
|
249
|
+
*/
|
|
250
|
+
function alignedAt(
|
|
251
|
+
align: Align,
|
|
252
|
+
mirrored: boolean,
|
|
253
|
+
anchorStart: number,
|
|
254
|
+
anchorSize: number,
|
|
255
|
+
overlaySize: number,
|
|
256
|
+
): number {
|
|
257
|
+
const resolved = mirrored ? mirror(align) : align;
|
|
258
|
+
return match (resolved) {
|
|
259
|
+
"start" => anchorStart,
|
|
260
|
+
"center" => anchorStart + anchorSize / 2 - overlaySize / 2,
|
|
261
|
+
"end" => anchorStart + anchorSize - overlaySize,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** `start` and `end` swapped, for a right-to-left page. */
|
|
266
|
+
function mirror(align: Align): Align {
|
|
267
|
+
return match (align) {
|
|
268
|
+
"start" => "end",
|
|
269
|
+
"center" => "center",
|
|
270
|
+
"end" => "start",
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Place `overlay` against `anchor` inside `viewport`.
|
|
276
|
+
*
|
|
277
|
+
* Pure arithmetic over three rectangles: no element, no window, no React. See
|
|
278
|
+
* the module header for why that is the point rather than a convenience.
|
|
279
|
+
*/
|
|
280
|
+
export function placeOverlay(anchoring: Anchoring): Placement {
|
|
281
|
+
const {
|
|
282
|
+
align,
|
|
283
|
+
alignOffset,
|
|
284
|
+
anchor,
|
|
285
|
+
avoidCollisions,
|
|
286
|
+
collisionPadding,
|
|
287
|
+
direction,
|
|
288
|
+
overlay,
|
|
289
|
+
sideOffset,
|
|
290
|
+
viewport,
|
|
291
|
+
} = anchoring;
|
|
292
|
+
|
|
293
|
+
const room = roomAround(anchor, viewport, collisionPadding);
|
|
294
|
+
const side = avoidCollisions ? sideThatFits(anchoring, room) : anchoring.side;
|
|
295
|
+
const vertical = isVertical(side);
|
|
296
|
+
|
|
297
|
+
// The main axis: hard against the trigger's edge, plus the gap. The overlay
|
|
298
|
+
// is never pushed along this axis, because pushing it would slide it over
|
|
299
|
+
// the trigger it is meant to be pointing at.
|
|
300
|
+
const main = match (side) {
|
|
301
|
+
"top" => anchor.y - overlay.height - sideOffset,
|
|
302
|
+
"bottom" => anchor.y + anchor.height + sideOffset,
|
|
303
|
+
"left" => anchor.x - overlay.width - sideOffset,
|
|
304
|
+
"right" => anchor.x + anchor.width + sideOffset,
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// The cross axis: aligned, nudged, then slid back inside if it has to be.
|
|
308
|
+
const anchorStart = vertical ? anchor.x : anchor.y;
|
|
309
|
+
const anchorSize = vertical ? anchor.width : anchor.height;
|
|
310
|
+
const overlaySize = vertical ? overlay.width : overlay.height;
|
|
311
|
+
const viewportStart = vertical ? viewport.x : viewport.y;
|
|
312
|
+
const viewportSize = vertical ? viewport.width : viewport.height;
|
|
313
|
+
// A positive `alignOffset` moves the overlay the way the page reads, so the
|
|
314
|
+
// same number nudges a menu in the same visual direction as its own text.
|
|
315
|
+
const reading = vertical && direction === "rtl" ? -1 : 1;
|
|
316
|
+
const wanted =
|
|
317
|
+
alignedAt(align, vertical && direction === "rtl", anchorStart, anchorSize, overlaySize) +
|
|
318
|
+
alignOffset * reading;
|
|
319
|
+
|
|
320
|
+
const lowest = viewportStart + collisionPadding;
|
|
321
|
+
const highest = viewportStart + viewportSize - collisionPadding - overlaySize;
|
|
322
|
+
// `Math.max` last, so an overlay *wider* than the viewport — where `highest`
|
|
323
|
+
// is below `lowest` and no position satisfies both — is pinned to the leading
|
|
324
|
+
// edge rather than pushed off the far one. `availableWidth` is what a
|
|
325
|
+
// stylesheet reads to stop it being that wide in the first place.
|
|
326
|
+
const cross = avoidCollisions ? Math.max(lowest, Math.min(wanted, highest)) : wanted;
|
|
327
|
+
|
|
328
|
+
const alongSide = Math.max(0, room[side] - sideOffset);
|
|
329
|
+
const acrossSide = Math.max(0, viewportSize - collisionPadding * 2);
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
align,
|
|
333
|
+
availableHeight: vertical ? alongSide : acrossSide,
|
|
334
|
+
availableWidth: vertical ? acrossSide : alongSide,
|
|
335
|
+
shift: cross - wanted,
|
|
336
|
+
side,
|
|
337
|
+
x: vertical ? cross : main,
|
|
338
|
+
y: vertical ? main : cross,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Write a placement onto the overlay.
|
|
344
|
+
*
|
|
345
|
+
* Imperatively, and only these seven properties. They change on every scroll
|
|
346
|
+
* frame while an overlay is open, and putting them in state would re-render the
|
|
347
|
+
* overlay and everything in it sixty times a second to move a box — which is
|
|
348
|
+
* exactly the cost `index.js`'s header says this package does not pay. React
|
|
349
|
+
* owns nothing written here: it never sets `left`, `top` or a custom property
|
|
350
|
+
* on these elements, and a caller who passes a `style` of their own keeps every
|
|
351
|
+
* property in it except the ones this component's position is made of.
|
|
352
|
+
*
|
|
353
|
+
* The two measurements are the ones a caller cannot compute for themselves. A
|
|
354
|
+
* `Select` popup that must match its trigger's width needs the trigger
|
|
355
|
+
* measured; a menu near the bottom of the page needs to know how much room is
|
|
356
|
+
* left before it can decide to scroll rather than overflow.
|
|
357
|
+
*/
|
|
358
|
+
function write(overlay: HTMLElement, anchor: Rect, placement: Placement): void {
|
|
359
|
+
const style = overlay.style;
|
|
360
|
+
style.position = "fixed";
|
|
361
|
+
style.left = `${placement.x}px`;
|
|
362
|
+
style.top = `${placement.y}px`;
|
|
363
|
+
style.setProperty("--uf-anchor-trigger-width", `${anchor.width}px`);
|
|
364
|
+
style.setProperty("--uf-anchor-trigger-height", `${anchor.height}px`);
|
|
365
|
+
style.setProperty("--uf-anchor-available-width", `${placement.availableWidth}px`);
|
|
366
|
+
style.setProperty("--uf-anchor-available-height", `${placement.availableHeight}px`);
|
|
367
|
+
// How far the slide moved it, so an arrow drawn by a stylesheet can be moved
|
|
368
|
+
// back the other way and keep pointing at the trigger.
|
|
369
|
+
style.setProperty("--uf-anchor-shift", `${placement.shift}px`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The rectangle of an element, as the arithmetic above wants it. */
|
|
373
|
+
function rectOf(element: HTMLElement): Rect {
|
|
374
|
+
const box = element.getBoundingClientRect();
|
|
375
|
+
return { height: box.height, width: box.width, x: box.left, y: box.top };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Keep an overlay against its trigger for as long as it is open.
|
|
380
|
+
*
|
|
381
|
+
* Returns the side and alignment it settled on, which the part renders as
|
|
382
|
+
* `data-side` and `data-align`. Those are state — they change rarely, a
|
|
383
|
+
* stylesheet has to see them, and a flip is exactly the moment an arrow has to
|
|
384
|
+
* move — while the coordinates are written straight to the element; `write`
|
|
385
|
+
* says why.
|
|
386
|
+
*/
|
|
387
|
+
export hook useAnchor(request: AnchorRequest): Anchored {
|
|
388
|
+
const {
|
|
389
|
+
align,
|
|
390
|
+
alignOffset,
|
|
391
|
+
anchorRef,
|
|
392
|
+
avoidCollisions,
|
|
393
|
+
collisionPadding,
|
|
394
|
+
open,
|
|
395
|
+
overlayRef,
|
|
396
|
+
side,
|
|
397
|
+
sideOffset,
|
|
398
|
+
} = request;
|
|
399
|
+
const [settled, setSettled] = useState<Anchored>({ align, side });
|
|
400
|
+
|
|
401
|
+
const reflow = useStableCallback(() => {
|
|
402
|
+
const anchor = anchorRef.current;
|
|
403
|
+
const overlay = overlayRef.current;
|
|
404
|
+
const view = overlay?.ownerDocument?.defaultView;
|
|
405
|
+
if (anchor == null || overlay == null || view == null) {
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const box = rectOf(anchor);
|
|
409
|
+
const placement = placeOverlay({
|
|
410
|
+
align,
|
|
411
|
+
alignOffset,
|
|
412
|
+
anchor: box,
|
|
413
|
+
avoidCollisions,
|
|
414
|
+
collisionPadding,
|
|
415
|
+
direction: directionOf(anchor),
|
|
416
|
+
overlay: rectOf(overlay),
|
|
417
|
+
side,
|
|
418
|
+
sideOffset,
|
|
419
|
+
// The viewport of a fixed element, which is the whole of it: a fixed box
|
|
420
|
+
// is positioned against the viewport rather than against whatever is
|
|
421
|
+
// scrolled around it.
|
|
422
|
+
viewport: { height: view.innerHeight, width: view.innerWidth, x: 0, y: 0 },
|
|
423
|
+
});
|
|
424
|
+
write(overlay, box, placement);
|
|
425
|
+
setSettled((current) =>
|
|
426
|
+
current.side === placement.side && current.align === placement.align
|
|
427
|
+
? // The same object, so a scroll that changes nothing renders nothing.
|
|
428
|
+
current
|
|
429
|
+
: { align: placement.align, side: placement.side },
|
|
430
|
+
);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
useEffect(() => {
|
|
434
|
+
const anchor = anchorRef.current;
|
|
435
|
+
const overlay = overlayRef.current;
|
|
436
|
+
const view = overlay?.ownerDocument?.defaultView;
|
|
437
|
+
if (!open || anchor == null || overlay == null || view == null) {
|
|
438
|
+
// Forget the measurement when the overlay closes. It is only read while
|
|
439
|
+
// `open`, but a body that stays mounted across a close — `PopoverBody`
|
|
440
|
+
// does — reopens in a commit where `open` is already `true`, and would
|
|
441
|
+
// report the *previous* opening's side until `reflow` corrects it from
|
|
442
|
+
// an effect, which runs after paint. That is one frame of an arrow
|
|
443
|
+
// drawn from `data-side` pointing the wrong way, after a reopen that
|
|
444
|
+
// follows a flip.
|
|
445
|
+
if (!open) {
|
|
446
|
+
setSettled((current) =>
|
|
447
|
+
current.side === side && current.align === align ? current : { align, side },
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
reflow();
|
|
453
|
+
|
|
454
|
+
const document = overlay.ownerDocument;
|
|
455
|
+
const moved = () => reflow();
|
|
456
|
+
// Capture, because a scroll event does not bubble: a trigger inside a
|
|
457
|
+
// scrolling panel would otherwise move under an overlay that never heard
|
|
458
|
+
// about it. Passive, because this never prevents the scroll it is watching.
|
|
459
|
+
document.addEventListener("scroll", moved, { capture: true, passive: true });
|
|
460
|
+
view.addEventListener("resize", moved);
|
|
461
|
+
// A trigger that grows — a button whose label changed, a field that gained
|
|
462
|
+
// a second line — moves the overlay without any scroll or resize event
|
|
463
|
+
// being fired at all.
|
|
464
|
+
//
|
|
465
|
+
// Read off the window rather than through a local: a capitalised name
|
|
466
|
+
// holding a constructor is read as a React component by `uf lint`, and it
|
|
467
|
+
// is right to — the rule cannot tell this one from a component, and the
|
|
468
|
+
// window's own property is the thing being asked about anyway.
|
|
469
|
+
const host: $FlowFixMe = view;
|
|
470
|
+
const sizes = typeof host.ResizeObserver === "function" ? new host.ResizeObserver(moved) : null;
|
|
471
|
+
sizes?.observe(anchor);
|
|
472
|
+
sizes?.observe(overlay);
|
|
473
|
+
|
|
474
|
+
return () => {
|
|
475
|
+
document.removeEventListener("scroll", moved, true);
|
|
476
|
+
view.removeEventListener("resize", moved);
|
|
477
|
+
sizes?.disconnect();
|
|
478
|
+
};
|
|
479
|
+
// The measurements are read through `reflow`, which is stable and always
|
|
480
|
+
// has the latest of them; they are named here so that changing one — a
|
|
481
|
+
// caller flipping `side` on a breakpoint — measures again.
|
|
482
|
+
}, [
|
|
483
|
+
align,
|
|
484
|
+
alignOffset,
|
|
485
|
+
anchorRef,
|
|
486
|
+
avoidCollisions,
|
|
487
|
+
collisionPadding,
|
|
488
|
+
open,
|
|
489
|
+
overlayRef,
|
|
490
|
+
reflow,
|
|
491
|
+
side,
|
|
492
|
+
sideOffset,
|
|
493
|
+
]);
|
|
494
|
+
|
|
495
|
+
// What was asked for until there is something measured to report, so the
|
|
496
|
+
// first commit — and the server's markup, which measures nothing at all —
|
|
497
|
+
// says the requested side rather than the last one some other opening
|
|
498
|
+
// happened to settle on.
|
|
499
|
+
return open ? settled : { align, side };
|
|
500
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// The one contract every part of this package makes about state.
|
|
4
|
+
//
|
|
5
|
+
// Each primitive here has a value somebody may want to own: a dialog's open,
|
|
6
|
+
// a tab set's selection, a switch's checked, a combobox's text. A library that
|
|
7
|
+
// only supports one of the two arrangements is unusable in the other half of
|
|
8
|
+
// applications — a form library owns the value, and a page that just wants tabs
|
|
9
|
+
// does not — so every one of them is uncontrolled by default and controlled the
|
|
10
|
+
// moment the corresponding prop is passed.
|
|
11
|
+
//
|
|
12
|
+
// Written once because the failure mode of writing it six times is that five of
|
|
13
|
+
// them agree and one does not, and the one that does not is a component that
|
|
14
|
+
// silently ignores the parent's value on the second render. The rules it fixes:
|
|
15
|
+
//
|
|
16
|
+
// * `undefined` means "not controlled", and `null` does not. A combobox with
|
|
17
|
+
// no selection is `value={null}` and is still controlled.
|
|
18
|
+
// * A controlled component never writes its internal state, so a parent that
|
|
19
|
+
// rejects a change actually rejects it, rather than the component moving
|
|
20
|
+
// and then being moved back on the next render.
|
|
21
|
+
// * `onChange` is called for both arrangements, so a caller can observe
|
|
22
|
+
// without taking ownership.
|
|
23
|
+
//
|
|
24
|
+
// # Why this is `internal/` and not a subpath
|
|
25
|
+
//
|
|
26
|
+
// A public `useControlled` would be a general-purpose hook, and general-purpose
|
|
27
|
+
// React hooks are `@uniflowed/hooks`' subject, not this package's. What lives
|
|
28
|
+
// here is narrower than that: the specific contract this package's components
|
|
29
|
+
// promise about their props. Exporting it would publish a second, weaker copy
|
|
30
|
+
// of somebody else's API.
|
|
31
|
+
|
|
32
|
+
import { useCallback, useState } from "@uniflowed/react";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A value the caller may own, and the setter that respects the answer.
|
|
36
|
+
*
|
|
37
|
+
* `controlled` is the prop; `fallback` is the `defaultValue`-shaped initial
|
|
38
|
+
* state used only while the caller owns nothing.
|
|
39
|
+
*/
|
|
40
|
+
export hook useControlled<T>(
|
|
41
|
+
controlled: T | void,
|
|
42
|
+
fallback: T,
|
|
43
|
+
onChange: ((next: T) => mixed) | void,
|
|
44
|
+
): [T, (next: T) => void] {
|
|
45
|
+
const [internal, setInternal] = useState<T>(fallback);
|
|
46
|
+
// `=== undefined` rather than `== null`: `null` is a legitimate controlled
|
|
47
|
+
// value — a combobox with nothing selected — and treating it as "give me the
|
|
48
|
+
// uncontrolled behaviour" made a cleared selection reappear on the next
|
|
49
|
+
// render.
|
|
50
|
+
const owned = controlled === undefined;
|
|
51
|
+
|
|
52
|
+
const set = useCallback(
|
|
53
|
+
(next: T) => {
|
|
54
|
+
if (owned) {
|
|
55
|
+
setInternal(next);
|
|
56
|
+
}
|
|
57
|
+
// Both arrangements report, so a caller can watch a value it does not
|
|
58
|
+
// own without having to take it over to do so.
|
|
59
|
+
onChange?.(next);
|
|
60
|
+
},
|
|
61
|
+
[owned, onChange],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return [owned ? internal : (controlled as $FlowFixMe), set];
|
|
65
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// The other pattern every part of this package keeps writing.
|
|
4
|
+
//
|
|
5
|
+
// `roving-focus.js` is the keyboard half of these components. This is the
|
|
6
|
+
// other half, and it is one sentence: **a button says whether a region is
|
|
7
|
+
// showing, and names it.** `Dialog.Trigger` and `Menu.Trigger` are that
|
|
8
|
+
// sentence, and so are a collapsible, an accordion header and an expandable
|
|
9
|
+
// entry in a site's navigation — three components that look nothing alike and
|
|
10
|
+
// are the same three attributes underneath.
|
|
11
|
+
//
|
|
12
|
+
// Two rules make it up, and both of them fail silently.
|
|
13
|
+
//
|
|
14
|
+
// * **Name it only while it is there.** `aria-controls` pointing at an id
|
|
15
|
+
// nothing has tells a reader there is somewhere to go and then has nowhere
|
|
16
|
+
// to send them, and `aria-labelledby` pointing at a missing element makes a
|
|
17
|
+
// screen reader announce *nothing at all* rather than falling back to the
|
|
18
|
+
// element's own text. So a panel reports whether it is in the document, and
|
|
19
|
+
// whatever names it only claims the name while the report says yes. This is
|
|
20
|
+
// the same subscription `Tabs.Panel` makes to `Tabs.Tab`, for the same
|
|
21
|
+
// reason.
|
|
22
|
+
// * **A closed panel is hidden, not absent.** `Tabs.Panel` returns `null`
|
|
23
|
+
// when it is not selected, which is right for a tab set — the panels are
|
|
24
|
+
// alternatives, and a reader looking for text in one of them is looking at
|
|
25
|
+
// the wrong tab. It is wrong for a disclosure: the browser's find-in-page
|
|
26
|
+
// cannot find text in a section that is not in the document, so a
|
|
27
|
+
// forty-section FAQ becomes forty sections a reader has to open by hand to
|
|
28
|
+
// search. `hidden="until-found"` is the platform's answer — the browser
|
|
29
|
+
// reveals the section, fires `beforematch`, and scrolls to the match —
|
|
30
|
+
// and it works in Chrome since 102 (2022-05), Firefox since 148 (2026-02)
|
|
31
|
+
// and Safari since 26.2 (2025-12, which does not yet scroll to the match);
|
|
32
|
+
// checked 2026-09-06. Everywhere else it degrades to a plain `hidden`,
|
|
33
|
+
// which is what the panel would have been anyway.
|
|
34
|
+
//
|
|
35
|
+
// # Why `useUntilFound` is a hook and not a prop
|
|
36
|
+
//
|
|
37
|
+
// Because React 19 cannot say `hidden="until-found"`. `hidden` is on React's
|
|
38
|
+
// list of boolean attributes, so `<div hidden="until-found">` renders
|
|
39
|
+
// `hidden=""` — the string is truthy, and truthy is all React keeps. There is
|
|
40
|
+
// no prop spelling that produces the attribute, which is a thing worth knowing
|
|
41
|
+
// before spending an afternoon looking for one.
|
|
42
|
+
//
|
|
43
|
+
// So the panel is rendered with the ordinary boolean `hidden` — which is what
|
|
44
|
+
// the server sends, and what keeps a closed section closed before any
|
|
45
|
+
// JavaScript arrives — and an effect *upgrades* the attribute afterwards. It is
|
|
46
|
+
// an upgrade rather than a fight: React sets `hidden=""` when it commits, this
|
|
47
|
+
// runs after that commit, and the next time React changes the prop it removes
|
|
48
|
+
// or re-adds the attribute and this upgrades it again. Nothing here writes an
|
|
49
|
+
// attribute React believes it owns while React believes it.
|
|
50
|
+
//
|
|
51
|
+
// # Why this is `internal/` and not a subpath
|
|
52
|
+
//
|
|
53
|
+
// The same reason `roving-focus.js` gives. These are rules about markup this
|
|
54
|
+
// package emits — that a trigger and its panel agree on an id, that a panel is
|
|
55
|
+
// the element carrying `hidden` — and they hold because the components build
|
|
56
|
+
// both halves. Exported, they would be advice.
|
|
57
|
+
|
|
58
|
+
import { useEffect } from "@uniflowed/react";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Report that this part is in the document, for as long as it is.
|
|
62
|
+
*
|
|
63
|
+
* `register` is the setter from whatever names this part; it is called with
|
|
64
|
+
* `true` on mount and `false` on unmount, and taking it as a possibly-missing
|
|
65
|
+
* function lets a part be rendered outside the thing that would name it
|
|
66
|
+
* without the caller having to care.
|
|
67
|
+
*/
|
|
68
|
+
export hook usePresence(register: ((present: boolean) => void) | void): void {
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (register == null) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
register(true);
|
|
74
|
+
return () => register(false);
|
|
75
|
+
}, [register]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Keep a closed panel hidden the way the platform means it: findable.
|
|
80
|
+
*
|
|
81
|
+
* The element must be rendered with a plain boolean `hidden` as well — see the
|
|
82
|
+
* module header. This only upgrades the attribute React has already committed,
|
|
83
|
+
* so a browser that has never heard of `until-found` sees exactly the `hidden`
|
|
84
|
+
* it would have seen, and one that has can reveal the section for a
|
|
85
|
+
* find-in-page hit.
|
|
86
|
+
*/
|
|
87
|
+
export hook useUntilFound(ref: { current: HTMLElement | null }, open: boolean): void {
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
const element = ref.current;
|
|
90
|
+
// Nothing to do while it is open: React has removed the attribute, and
|
|
91
|
+
// adding one back would hide a panel the reader just opened.
|
|
92
|
+
if (element == null || open) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
element.setAttribute("hidden", "until-found");
|
|
96
|
+
}, [ref, open]);
|
|
97
|
+
}
|