@uniflowed/ui 0.0.0-alpha.4 → 0.0.0-alpha.40

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.
Files changed (52) hide show
  1. package/accordion.js +360 -0
  2. package/alert-dialog.js +282 -0
  3. package/alert.js +142 -0
  4. package/avatar.js +276 -0
  5. package/breadcrumb.js +138 -0
  6. package/calendar.js +547 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +216 -31
  9. package/collapsible.js +169 -0
  10. package/combobox.js +209 -40
  11. package/context-menu.js +206 -0
  12. package/date-picker.js +346 -0
  13. package/dialog.js +229 -197
  14. package/drawer.js +490 -0
  15. package/field.js +257 -42
  16. package/hover-card.js +330 -0
  17. package/index.js +1548 -24
  18. package/input-otp.js +218 -0
  19. package/interactions.js +2323 -0
  20. package/internal/anchor.js +565 -0
  21. package/internal/date-grid.js +260 -0
  22. package/internal/disclosure.js +298 -0
  23. package/internal/focus.js +64 -0
  24. package/internal/form-value.js +83 -0
  25. package/internal/hover-intent.js +259 -0
  26. package/internal/menu-tree.js +228 -0
  27. package/internal/merge-props.js +206 -7
  28. package/internal/range.js +147 -0
  29. package/internal/roving-focus.js +205 -11
  30. package/menu.js +521 -336
  31. package/menubar.js +288 -0
  32. package/navigation-menu.js +251 -0
  33. package/package.json +8 -12
  34. package/pagination.js +209 -0
  35. package/popover.js +344 -0
  36. package/progress.js +91 -0
  37. package/radio-group.js +302 -0
  38. package/resizable.js +447 -0
  39. package/scroll-area.js +283 -0
  40. package/select.js +888 -0
  41. package/separator.js +97 -0
  42. package/sheet.js +189 -0
  43. package/sidebar.js +313 -0
  44. package/skeleton.js +159 -0
  45. package/slider.js +405 -0
  46. package/switch.js +43 -34
  47. package/table.js +520 -0
  48. package/tabs.js +99 -96
  49. package/toast.js +592 -0
  50. package/toggle-group.js +282 -0
  51. package/toggle.js +105 -0
  52. package/tooltip.js +400 -0
@@ -0,0 +1,260 @@
1
+ // @flow
2
+ //
3
+ // The month a calendar shows, and the keyboard that walks it.
4
+ //
5
+ // # Why this is not `roving-focus.js`
6
+ //
7
+ // A date grid is a roving tab stop — one stop in the page's tab order, arrow
8
+ // keys inside it — so most of `internal/roving-focus.js` applies and the
9
+ // calendar uses it: `directionOf` for a right-to-left week, `isEnabled` nowhere,
10
+ // and the same rule about reading the document rather than a registry. What does
11
+ // not fit is the part that does the moving, and it does not fit for four
12
+ // reasons rather than one:
13
+ //
14
+ // * **The movement is arithmetic on a date, not an index in a NodeList.**
15
+ // `moveTo` walks the items it was handed. `ArrowDown` in a calendar is "a
16
+ // week later", and a week later is often a cell that is not in the grid at
17
+ // all yet — so the answer cannot be found among the elements, and a
18
+ // `movementFor` grown to two axes would still return "next" for a key whose
19
+ // real meaning is `+7 days`.
20
+ // * **Running off the end changes what is rendered.** `ArrowRight` on the 31st
21
+ // shows the next month *and* leaves focus on the 1st, which is a cell that
22
+ // did not exist when the key was pressed. That is a `pendingFocus` problem,
23
+ // the same shape `Menu.Body` solves for a menu opening onto its last item,
24
+ // and it is why the movement is computed as a value the component can act on
25
+ // over two renders rather than as a `.focus()` inside a helper.
26
+ // * **An unavailable date stays focusable.** `moveTo` skips anything
27
+ // `isEnabled` rejects, which is right for a menu item and wrong here: a
28
+ // reader arrowing through October has to be able to pass over the days that
29
+ // cannot be booked, each `aria-disabled="true"` and each still reachable. A
30
+ // grid that skipped them would present a month with holes in it and no way
31
+ // to find out what is in the holes.
32
+ // * **There is no wrap, and no ends.** A list has a first and a last item.
33
+ // A calendar has neither: every direction leads to another month.
34
+ //
35
+ // So the two primitives stay apart, and the boundary is that one owns *focus
36
+ // among elements that exist* and this one owns *which date the keyboard means*.
37
+ // Everything below is a pure function over `PlainDate` values: no element, no
38
+ // React, no document. That is deliberate for the same reason
39
+ // `internal/anchor.js` keeps `placeOverlay` pure — the month-boundary cases are
40
+ // the ones worth testing exhaustively, and testing them through a rendered grid
41
+ // would test the renderer instead.
42
+ //
43
+ // # Dates come from Temporal, and from `@uniflowed/core/temporal` in particular
44
+ //
45
+ // Not `Date`. A package that ships a temporal library and then computes a month
46
+ // length with `new Date(y, m + 1, 0)` is the opposite of what "build uf with uf"
47
+ // asks for, and `Date`'s month-is-zero-based, mutates-in-place, local-timezone
48
+ // arithmetic is where calendar bugs come from in the first place.
49
+ //
50
+ // The specifier is `@uniflowed/core/temporal` rather than `@uniflowed/temporal`,
51
+ // which is the same object — `packages/temporal/index.js` is a re-export and
52
+ // says so. It has to be that one: `@uniflowed/ui` is published to npm,
53
+ // `@uniflowed/temporal` is not yet (its calendar surface waits on the native
54
+ // runtime), and `tools/ci/publishable.sh` refuses a published package that
55
+ // depends on an unpublished one because `npm install` would answer `ETARGET`.
56
+ // When the name is published this import can move, and nothing else changes.
57
+
58
+ import type { PlainDate } from "@uniflowed/core/temporal";
59
+ import { Temporal } from "@uniflowed/core/temporal";
60
+
61
+ import type { Direction } from "./roving-focus.js";
62
+
63
+ /**
64
+ * ISO 8601's first day of the week, which is Monday.
65
+ *
66
+ * The fallback when nothing better is known, and deliberately not Sunday: ISO is
67
+ * the standard the rest of this package's date handling follows, and a default
68
+ * that matched one large locale would be a guess dressed as a convention.
69
+ */
70
+ export const ISO_WEEK_START: number = 1;
71
+
72
+ /** Days in a week, which is the width of every grid here. */
73
+ export const DAYS_IN_WEEK: number = 7;
74
+
75
+ /**
76
+ * What a key asks the focused date to become.
77
+ *
78
+ * A value rather than a new date, because the component that acts on it has to
79
+ * do two things with the answer — move the focus and possibly re-render a
80
+ * different month — and because "PageDown means a month" is the part worth
81
+ * asserting on its own.
82
+ */
83
+ export type DateMovement =
84
+ | {| readonly kind: "days", readonly by: number |}
85
+ | {| readonly kind: "months", readonly by: number |}
86
+ | {| readonly kind: "years", readonly by: number |}
87
+ | {| readonly kind: "week-edge", readonly to: "start" | "end" |};
88
+
89
+ /** The part of a key event a grid reads. */
90
+ export type DateKeyPress = {
91
+ readonly key: string,
92
+ readonly shiftKey?: boolean,
93
+ ...
94
+ };
95
+
96
+ /**
97
+ * The movement a key asks for, or null when the key is not the grid's.
98
+ *
99
+ * The unhandled keys matter as much as the handled ones, for the reason
100
+ * `movementFor` gives: `Tab` belongs to the page and `Escape` belongs to
101
+ * whatever the calendar is inside, and a grid that swallowed either would be a
102
+ * place a reader could not leave.
103
+ *
104
+ * `direction` mirrors the horizontal pair and nothing else. `ArrowDown` is a
105
+ * week later in an Arabic calendar exactly as in an English one — a
106
+ * right-to-left page still runs top to bottom — and `Home` and `End` name the
107
+ * first and last day of the week in *reading* order, which is what
108
+ * `weekEdge` walks.
109
+ *
110
+ * `Shift` turns the two page keys into years, which is the one keyboard
111
+ * convention here that a reader cannot discover by trying: it is in the
112
+ * WAI-ARIA date-picker pattern, every native date field has it, and a year is
113
+ * otherwise twelve `PageDown` presses.
114
+ */
115
+ export function movementForDateKey(event: DateKeyPress, direction: Direction): DateMovement | null {
116
+ const forward = direction === "rtl" ? -1 : 1;
117
+ const pages = event.shiftKey === true ? 12 : 1;
118
+ return match (event.key) {
119
+ "ArrowRight" => { kind: "days", by: forward },
120
+ "ArrowLeft" => { kind: "days", by: -forward },
121
+ "ArrowDown" => { kind: "days", by: DAYS_IN_WEEK },
122
+ "ArrowUp" => { kind: "days", by: -DAYS_IN_WEEK },
123
+ "Home" => { kind: "week-edge", to: "start" },
124
+ "End" => { kind: "week-edge", to: "end" },
125
+ // A year is twelve months rather than `{ years: 1 }`, so that the day is
126
+ // clamped once by the same rule the month keys use: the 29th of February
127
+ // plus a year is the 28th, and adding a year to a month-clamped date and
128
+ // adding twelve months have to agree or `Shift+PageDown` twice would not
129
+ // equal `PageDown` twenty-four times.
130
+ "PageUp" => { kind: "months", by: -pages },
131
+ "PageDown" => { kind: "months", by: pages },
132
+ _ => null,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * How far into its week `date` sits, counting from `weekStartsOn`.
138
+ *
139
+ * Zero for the first column, six for the last, in whichever order the week is
140
+ * laid out. Both `weekEdge` and the grid's leading blanks are this number.
141
+ */
142
+ export function columnOf(date: PlainDate, weekStartsOn: number): number {
143
+ return (date.dayOfWeek - weekStartsOn + DAYS_IN_WEEK) % DAYS_IN_WEEK;
144
+ }
145
+
146
+ /** The first or the last day of `date`'s week, as this locale lays a week out. */
147
+ export function weekEdge(date: PlainDate, weekStartsOn: number, to: "start" | "end"): PlainDate {
148
+ const into = columnOf(date, weekStartsOn);
149
+ return to === "start"
150
+ ? date.subtract({ days: into })
151
+ : date.add({ days: DAYS_IN_WEEK - 1 - into });
152
+ }
153
+
154
+ /**
155
+ * The date `movement` reaches from `from`.
156
+ *
157
+ * Nothing here refuses: a movement onto an unavailable date, into a month with
158
+ * nothing selectable in it, or past whatever range the caller allows still
159
+ * returns that date. Refusing is the component's decision and it makes a
160
+ * different one — the module header says why a reader has to be able to walk
161
+ * over an unavailable day rather than around it.
162
+ *
163
+ * The month and year movements clamp the day, because `PlainDate.add` does: the
164
+ * 31st of January plus a month is the 28th of February rather than the 3rd of
165
+ * March, which is Temporal's `constrain` overflow and what a person means by
166
+ * "next month".
167
+ */
168
+ export function moveDate(from: PlainDate, movement: DateMovement, weekStartsOn: number): PlainDate {
169
+ return match (movement) {
170
+ {kind: "days", by: const by} => from.add({ days: by }),
171
+ {kind: "months", by: const by} => from.add({ months: by }),
172
+ {kind: "years", by: const by} => from.add({ years: by }),
173
+ {kind: "week-edge", to: const to} => weekEdge(from, weekStartsOn, to),
174
+ };
175
+ }
176
+
177
+ /**
178
+ * The rows of one month, seven cells wide, with `null` where no day falls.
179
+ *
180
+ * The blanks are blanks rather than the neighbouring months' days, and that is
181
+ * the decision the rest of the keyboard behaviour rests on. A grid that showed
182
+ * the 1st of November inside October's would have two cells that mean the same
183
+ * date whenever a reader moved between them, and the WAI-ARIA pattern's
184
+ * promise — that `ArrowRight` off the end of the month *changes the month* — has
185
+ * nowhere to happen. Every blank is a `<td>` with no `gridcell` role, so the
186
+ * rows stay rectangular for a screen reader counting columns.
187
+ */
188
+ export function weeksOf(
189
+ year: number,
190
+ month: number,
191
+ weekStartsOn: number,
192
+ ): $ReadOnlyArray<$ReadOnlyArray<PlainDate | null>> {
193
+ const first = Temporal.PlainDate.from({ day: 1, month, year });
194
+ const blanks = columnOf(first, weekStartsOn);
195
+ const days = first.daysInMonth;
196
+ const rows = Math.ceil((blanks + days) / DAYS_IN_WEEK);
197
+
198
+ const weeks = [];
199
+ for (let row = 0; row < rows; row += 1) {
200
+ const week = [];
201
+ for (let column = 0; column < DAYS_IN_WEEK; column += 1) {
202
+ const day = row * DAYS_IN_WEEK + column - blanks + 1;
203
+ week.push(day >= 1 && day <= days ? first.add({ days: day - 1 }) : null);
204
+ }
205
+ weeks.push(week);
206
+ }
207
+ return weeks;
208
+ }
209
+
210
+ /**
211
+ * Seven dates, one per column, for naming the columns.
212
+ *
213
+ * Dates rather than strings, because the names are the locale's and
214
+ * `PlainDate.toLocaleString` is where a locale's day names live. Any week would
215
+ * do; this one starts on a Monday so that `weekStartsOn` indexes it directly.
216
+ */
217
+ export function weekdaysFrom(weekStartsOn: number): $ReadOnlyArray<PlainDate> {
218
+ const monday = Temporal.PlainDate.from("2024-01-01");
219
+ const days = [];
220
+ for (let column = 0; column < DAYS_IN_WEEK; column += 1) {
221
+ days.push(monday.add({ days: weekStartsOn - ISO_WEEK_START + column }));
222
+ }
223
+ return days;
224
+ }
225
+
226
+ /** Whether two dates are in the same month of the same year. */
227
+ export function sameMonth(a: PlainDate, b: PlainDate): boolean {
228
+ return a.year === b.year && a.month === b.month;
229
+ }
230
+
231
+ /**
232
+ * The day `locale` starts its weeks on, in ISO numbering.
233
+ *
234
+ * `Intl.Locale.prototype.getWeekInfo` is the answer the platform has, and its
235
+ * `firstDay` is already ISO-numbered, so no translation is needed. Everything
236
+ * around the call is defence rather than logic: Flow's vendored `intl.js`
237
+ * declares no `Locale` at all, the method is newer than the class on some hosts,
238
+ * and a locale tag that is merely *malformed* throws where an unknown one does
239
+ * not. Each of those ends at ISO Monday, which is a defensible week rather than
240
+ * a broken one.
241
+ *
242
+ * The cast is the narrowest available: one property read off `Intl`, for a class
243
+ * the checker has never heard of. Widening it to the return value would hide
244
+ * whether `firstDay` was a number at all, which is why that is asked separately.
245
+ */
246
+ export function firstDayOfWeekFor(locale: string | void): number {
247
+ const factory = (Intl as $FlowFixMe).Locale;
248
+ if (typeof factory !== "function") {
249
+ return ISO_WEEK_START;
250
+ }
251
+ try {
252
+ const info = new factory(locale ?? "und").getWeekInfo?.();
253
+ const first = info?.firstDay;
254
+ return typeof first === "number" && first >= 1 && first <= DAYS_IN_WEEK
255
+ ? first
256
+ : ISO_WEEK_START;
257
+ } catch {
258
+ return ISO_WEEK_START;
259
+ }
260
+ }
@@ -0,0 +1,298 @@
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
+ // # The height a closed panel would have
52
+ //
53
+ // The third rule, and the one that took a piece of work rather than a line.
54
+ // `height: 0 → var(--uf-collapsible-height)` is the whole of animating a
55
+ // disclosure, and the number in that property is the one thing a stylesheet
56
+ // cannot compute: it is the height the content *would* have, wanted at the
57
+ // moment the panel is still closed, because a transition has to know its
58
+ // destination before it starts.
59
+ //
60
+ // Every obvious way of getting it answers zero. A closed panel is `hidden`, so
61
+ // it has no box: `ResizeObserver` reports `0`, `getBoundingClientRect()` is
62
+ // empty, `scrollHeight` is `0`, and `useElementSize` from
63
+ // `@uniflowed/hooks/dom` measures a hidden element and reports zero — which is
64
+ // exactly the moment the number is wanted. Measuring after the panel opens
65
+ // gives the right number one frame late, which is the jank this removes.
66
+ //
67
+ // So `useMeasuredHeight` lays the panel out without painting it: inline
68
+ // `display`, `position: absolute` and `visibility: hidden`, read, restore. Four
69
+ // things about that are not obvious:
70
+ //
71
+ // * It overrides `display` inline rather than removing `hidden`. The
72
+ // attribute is `useUntilFound`'s and React's; an effect that took it away
73
+ // and put it back would be fighting both of them for one frame, and would
74
+ // lose whichever ran last. An inline declaration beats the user-agent
75
+ // stylesheet's `[hidden] { display: none }` and touches nothing anybody
76
+ // else believes they own.
77
+ // * It sets `content-visibility: visible` in the same pass, because
78
+ // `hidden="until-found"` is `content-visibility: hidden`, which does not lay
79
+ // its subtree out either. Defeating one of the two and not the other
80
+ // measures zero on exactly the panels this package ships.
81
+ // * It sets `height: auto` in the same pass, for the same reason and against
82
+ // the very rule this property exists for. A closed panel is `height: 0` —
83
+ // that is the half of `height: 0 → var(--uf-collapsible-height)` that is
84
+ // always on — so a panel laid out with the stylesheet still applying
85
+ // measures zero and writes `0px` back into the property it was asked to
86
+ // fill. Defeating `display` and not `height` is the same mistake as
87
+ // defeating `display` and not `content-visibility`, one declaration along.
88
+ // * And it pins the width, because taking a box out of flow makes it
89
+ // shrink-to-fit against its containing block — the nearest positioned
90
+ // ancestor, which on most pages is the viewport. Text that wraps to four
91
+ // lines where the panel lives measures one line there. The width it would
92
+ // have in flow is its parent's content box; when that cannot be read the
93
+ // pass leaves the width alone rather than inventing one.
94
+ //
95
+ // It is opt-in, and that is the honest answer to "it should cost nothing on a
96
+ // page that never animates". Whether a stylesheet reads the property is not
97
+ // something the component can ask — `getComputedStyle` on a `display: none`
98
+ // element answers about the declaration, not about whether anybody transitions
99
+ // on it — so the alternative to a prop is a forced layout per panel per render
100
+ // on every page that has a collapsible, animated or not. A forty-section FAQ is
101
+ // forty synchronous layouts nobody asked for.
102
+ //
103
+ // # Why this is `internal/` and not a subpath
104
+ //
105
+ // The same reason `roving-focus.js` gives. These are rules about markup this
106
+ // package emits — that a trigger and its panel agree on an id, that a panel is
107
+ // the element carrying `hidden` — and they hold because the components build
108
+ // both halves. Exported, they would be advice.
109
+
110
+ import { useEffect } from "@uniflowed/react";
111
+
112
+ /**
113
+ * Report that this part is in the document, for as long as it is.
114
+ *
115
+ * `register` is the setter from whatever names this part; it is called with
116
+ * `true` on mount and `false` on unmount, and taking it as a possibly-missing
117
+ * function lets a part be rendered outside the thing that would name it
118
+ * without the caller having to care.
119
+ */
120
+ export hook usePresence(register: ((present: boolean) => void) | void): void {
121
+ useEffect(() => {
122
+ if (register == null) {
123
+ return;
124
+ }
125
+ register(true);
126
+ return () => register(false);
127
+ }, [register]);
128
+ }
129
+
130
+ /**
131
+ * Keep a closed panel hidden the way the platform means it: findable.
132
+ *
133
+ * The element must be rendered with a plain boolean `hidden` as well — see the
134
+ * module header. This only upgrades the attribute React has already committed,
135
+ * so a browser that has never heard of `until-found` sees exactly the `hidden`
136
+ * it would have seen, and one that has can reveal the section for a
137
+ * find-in-page hit.
138
+ */
139
+ export hook useUntilFound(ref: { current: HTMLElement | null }, open: boolean): void {
140
+ useEffect(() => {
141
+ const element = ref.current;
142
+ // Nothing to do while it is open: React has removed the attribute, and
143
+ // adding one back would hide a panel the reader just opened.
144
+ if (element == null || open) {
145
+ return;
146
+ }
147
+ element.setAttribute("hidden", "until-found");
148
+ }, [ref, open]);
149
+ }
150
+
151
+ /** The custom property a stylesheet transitions a disclosure's height to. */
152
+ export const HEIGHT_PROPERTY: string = "--uf-collapsible-height";
153
+
154
+ /**
155
+ * The width `element` would have in flow, as a CSS length, or nothing.
156
+ *
157
+ * Taking the panel out of flow to measure it costs shrink-to-fit: an absolutely
158
+ * positioned box with `width: auto` is as wide as its *content* wants to be,
159
+ * bounded by its containing block — which is the nearest positioned ancestor
160
+ * and, on most pages, is the viewport rather than the panel's parent. A
161
+ * paragraph that wraps to four lines in a sidebar measures one line there, and
162
+ * the number written into the property is then a height the panel never has.
163
+ *
164
+ * The width it would have in flow is its parent's content box, which is what
165
+ * `getComputedStyle` reports for `width` on a laid-out element. Nothing is
166
+ * returned when the answer is not a length — a parent that is itself
167
+ * `display: none`, or a DOM with no layout to report — and the pass then does
168
+ * what it did before rather than pinning a width it had to guess.
169
+ */
170
+ function widthInFlow(element: HTMLElement): string | null {
171
+ const parent = element.parentElement;
172
+ const view: $FlowFixMe = element.ownerDocument?.defaultView;
173
+ if (parent == null || view == null || typeof view.getComputedStyle !== "function") {
174
+ return null;
175
+ }
176
+ const width: mixed = view.getComputedStyle(parent).width;
177
+ return typeof width === "string" && width.endsWith("px") ? width : null;
178
+ }
179
+
180
+ /**
181
+ * The height `element` has, or would have if it were not hidden.
182
+ *
183
+ * The measuring pass, and the reason this module has a header section about it.
184
+ * An open panel is measured where it stands; a closed one is briefly laid out
185
+ * and not painted. Either way this reads layout, which is a synchronous reflow
186
+ * — the caller is the one that decides it is worth paying.
187
+ *
188
+ * # The two things a closed panel is not, and has to be made
189
+ *
190
+ * Laying it out is necessary and is not sufficient, because the stylesheet this
191
+ * property exists for is `height: 0` on the closed panel and
192
+ * `height: var(--uf-collapsible-height)` on the open one. A panel laid out with
193
+ * that rule still applying measures **zero**, the property is written back as
194
+ * `0px`, and the transition has a destination of nothing — the bug the whole
195
+ * hook was written to avoid, arriving through the rule it was written for. So
196
+ * `height: auto` is set inline for the pass, exactly as `display` is: not
197
+ * because the panel wants an inline height but because the author declaration
198
+ * has to be defeated for one synchronous read and put back.
199
+ *
200
+ * `width` is the same argument for the other axis and is `widthInFlow`'s. Both
201
+ * are restored with everything else; a stylesheet is never left fighting an
202
+ * inline declaration this wrote.
203
+ */
204
+ function heightOf(element: HTMLElement): number {
205
+ if (!element.hasAttribute("hidden")) {
206
+ return element.getBoundingClientRect().height;
207
+ }
208
+ const style = element.style;
209
+ const before = {
210
+ boxSizing: style.boxSizing,
211
+ contentVisibility: style.getPropertyValue("content-visibility"),
212
+ display: style.display,
213
+ height: style.height,
214
+ position: style.position,
215
+ visibility: style.visibility,
216
+ width: style.width,
217
+ };
218
+ // Out of flow and unpainted, so nothing below the panel moves and no frame
219
+ // shows it. The order is not significant; this is all one style
220
+ // recalculation, paid for by the single read below.
221
+ style.display = "block";
222
+ style.setProperty("content-visibility", "visible");
223
+ style.position = "absolute";
224
+ style.visibility = "hidden";
225
+ style.height = "auto";
226
+ const width = widthInFlow(element);
227
+ if (width != null) {
228
+ // `border-box`, because what fills the parent's content width in flow is
229
+ // the panel's margin box rather than its content box: measuring a padded
230
+ // panel content-box wide would make it wider than it will ever be and its
231
+ // text shorter than it will ever wrap to.
232
+ style.boxSizing = "border-box";
233
+ style.width = width;
234
+ }
235
+ const height = element.getBoundingClientRect().height;
236
+ style.boxSizing = before.boxSizing;
237
+ style.display = before.display;
238
+ style.setProperty("content-visibility", before.contentVisibility);
239
+ style.height = before.height;
240
+ style.position = before.position;
241
+ style.visibility = before.visibility;
242
+ style.width = before.width;
243
+ return height;
244
+ }
245
+
246
+ /**
247
+ * Keep `HEIGHT_PROPERTY` on a disclosure's panel equal to its content's height.
248
+ *
249
+ * `enabled` is the caller's opt-in; see the module header for why there is one.
250
+ * When it is off this writes nothing and measures nothing, which is what makes
251
+ * a page with no animation pay nothing.
252
+ *
253
+ * Two effects rather than one, because they answer different questions. The
254
+ * first measures on every render, with no dependency array on purpose: what the
255
+ * panel would be worth changes when the *caller* renders different children
256
+ * into it, and a dependency list here would be a claim about when that happens
257
+ * that only the caller could keep — `useFirstItem` in `roving-focus.js` makes
258
+ * the same argument for the same reason. The second watches for a size change
259
+ * no render caused: an image that finished loading, a font that swapped. It
260
+ * only ever fires while the panel is open, because a `display: none` element
261
+ * has no box for a `ResizeObserver` to report on — which is the whole problem
262
+ * this hook exists for, arriving one more time.
263
+ */
264
+ export hook useMeasuredHeight(ref: { current: HTMLElement | null }, enabled: boolean): void {
265
+ useEffect(() => {
266
+ const element = ref.current;
267
+ if (!enabled || element == null) {
268
+ return;
269
+ }
270
+ const measured = `${String(heightOf(element))}px`;
271
+ // Compared before writing, so a render that changed nothing does not dirty
272
+ // the element's style and invite another style recalculation.
273
+ if (element.style.getPropertyValue(HEIGHT_PROPERTY) !== measured) {
274
+ element.style.setProperty(HEIGHT_PROPERTY, measured);
275
+ }
276
+ });
277
+
278
+ useEffect(() => {
279
+ const element = ref.current;
280
+ const view = element?.ownerDocument?.defaultView;
281
+ if (!enabled || element == null || view == null) {
282
+ return;
283
+ }
284
+ // Read off the window rather than through a local, for the reason
285
+ // `internal/anchor.js` gives where it does the same: a capitalised name
286
+ // holding a constructor is read as a React component by `uf lint`, and the
287
+ // window's own property is the thing being asked about anyway.
288
+ const host: $FlowFixMe = view;
289
+ if (typeof host.ResizeObserver !== "function") {
290
+ return;
291
+ }
292
+ const sizes = new host.ResizeObserver(() => {
293
+ element.style.setProperty(HEIGHT_PROPERTY, `${String(heightOf(element))}px`);
294
+ });
295
+ sizes.observe(element);
296
+ return () => sizes.disconnect();
297
+ }, [ref, enabled]);
298
+ }
@@ -0,0 +1,64 @@
1
+ // @flow
2
+ //
3
+ // What a reader can reach, in the order they reach it.
4
+ //
5
+ // One list, asked for by two components that want opposite things from it.
6
+ // `Dialog.Body` uses it to keep focus *in*: the first and last entries are
7
+ // where `Tab` and `Shift+Tab` wrap. `Popover.Body` uses it to put focus in
8
+ // once and then leaves it alone, because tabbing out of a popover is how a
9
+ // reader leaves one. The list has to be the same list for those two to be
10
+ // describable as different policies over the same fact rather than as two
11
+ // components that disagree about what focusable means.
12
+ //
13
+ // # The selector is the browser's rule, written down
14
+ //
15
+ // A disabled control is out because the browser will not focus one, and
16
+ // `tabindex="-1"` is out because it means "focusable by script, not by Tab" —
17
+ // which is what every roving tab stop in this package uses, so a menu inside a
18
+ // dialog would otherwise report thirty items as focus stops and the trap would
19
+ // wrap between two of them instead of at the dialog's edges.
20
+ //
21
+ // The three ancestor checks are the ones a selector cannot make. `hidden`,
22
+ // `inert` and `aria-hidden="true"` each hide a whole subtree, and reading them
23
+ // off the element alone returned a button inside `<div aria-hidden="true">` as
24
+ // a focus stop — after which the trap moved focus to a control no screen
25
+ // reader exposes and the reader was somewhere they could not be told about.
26
+ //
27
+ // # Why this is `internal/` and not a subpath
28
+ //
29
+ // The same reason `merge-props.js` gives. A public `focusable()` is a
30
+ // general-purpose DOM utility, and a second, weaker copy of one is how two
31
+ // parts of a package come to disagree about which elements exist. What is
32
+ // shipped here is narrower: the definition this package's focus behaviour is
33
+ // written against.
34
+
35
+ /**
36
+ * The elements a browser will move focus to with `Tab`.
37
+ *
38
+ * Exported as the selector as well as through `focusable`, because one
39
+ * question is asked about a single element rather than about a subtree: a
40
+ * tooltip's trigger has to *be* one of these or the tooltip is one only a mouse
41
+ * can reach, and `element.matches(FOCUS_STOPS)` is that question.
42
+ */
43
+ export const FOCUS_STOPS: string =
44
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
45
+
46
+ /**
47
+ * The focus stops inside an element, in document order.
48
+ *
49
+ * Document order rather than mount order, for the reason
50
+ * `internal/roving-focus.js` gives about items: the two stop agreeing the first
51
+ * time something is rendered conditionally, and the reader's `Tab` follows the
52
+ * document.
53
+ */
54
+ export function focusable(root: HTMLElement): Array<HTMLElement> {
55
+ return Array.from(root.querySelectorAll(FOCUS_STOPS)).filter(
56
+ (element: $FlowFixMe) =>
57
+ // All three hide a whole subtree, so all three are asked of the
58
+ // ancestors; see the module header for what reading them off the element
59
+ // alone let through.
60
+ element.closest("[hidden]") == null &&
61
+ element.closest("[inert]") == null &&
62
+ element.closest('[aria-hidden="true"]') == null,
63
+ );
64
+ }