@uniflowed/ui 0.0.0-alpha.12 → 0.0.0-alpha.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/accordion.js +21 -3
- package/calendar.js +550 -0
- package/checkbox.js +188 -10
- package/collapsible.js +29 -12
- package/combobox.js +176 -5
- package/context-menu.js +198 -0
- package/date-picker.js +346 -0
- package/field.js +192 -25
- package/hover-card.js +3 -3
- package/index.js +196 -10
- package/internal/anchor.js +71 -6
- package/internal/date-grid.js +260 -0
- package/internal/disclosure.js +201 -0
- package/internal/menu-tree.js +228 -0
- package/menu.js +309 -163
- package/menubar.js +285 -0
- package/package.json +8 -3
- package/popover.js +21 -8
- package/resizable.js +149 -9
- package/select.js +29 -0
- package/switch.js +5 -3
- package/toggle.js +3 -2
- package/tooltip.js +3 -3
package/internal/anchor.js
CHANGED
|
@@ -108,6 +108,19 @@ import { directionOf } from "./roving-focus.js";
|
|
|
108
108
|
*/
|
|
109
109
|
export type Side = "top" | "right" | "bottom" | "left";
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* A side a caller may ask for, which is the four above plus the two that mean
|
|
113
|
+
* "the way the reader reads".
|
|
114
|
+
*
|
|
115
|
+
* The WAI-ARIA menu pattern puts a submenu on the inline end - to the right of a
|
|
116
|
+
* left-to-right menu and to the left of a right-to-left one - and `menu.js`
|
|
117
|
+
* already spells the *keys* that way, so a physical-only `side` would have left
|
|
118
|
+
* one half of that pattern mirrored and the other half not. `Placement` and
|
|
119
|
+
* `placeOverlay` stay physical: a logical side is resolved once, against the
|
|
120
|
+
* trigger's own direction, before any arithmetic sees it.
|
|
121
|
+
*/
|
|
122
|
+
export type LogicalSide = Side | "inline-start" | "inline-end";
|
|
123
|
+
|
|
111
124
|
/**
|
|
112
125
|
* Where the overlay sits along the trigger's other axis.
|
|
113
126
|
*
|
|
@@ -170,10 +183,29 @@ export type Anchored = {|
|
|
|
170
183
|
/** What `useAnchor` is told, on top of the geometry `placeOverlay` needs. */
|
|
171
184
|
export type AnchorRequest = {|
|
|
172
185
|
readonly anchorRef: { current: HTMLElement | null },
|
|
186
|
+
/**
|
|
187
|
+
* A box to place against instead of the anchor element's own.
|
|
188
|
+
*
|
|
189
|
+
* A context menu opens *at a point* rather than against an element: the
|
|
190
|
+
* pointer coordinates the reader right-clicked at, which is a zero-sized
|
|
191
|
+
* rectangle no element in the document has. The element is still needed —
|
|
192
|
+
* it is what the writing direction is read from, what a `ResizeObserver`
|
|
193
|
+
* watches, and what focus returns to — so this replaces the *measurement*
|
|
194
|
+
* and nothing else.
|
|
195
|
+
*
|
|
196
|
+
* `null` (and absent) means "measure the element", which is every other
|
|
197
|
+
* overlay in this package.
|
|
198
|
+
*
|
|
199
|
+
* It has to be stable between renders for the same position, because it is
|
|
200
|
+
* one of the things the placement effect re-runs for; a fresh object each
|
|
201
|
+
* render would re-measure on every render of the page around it.
|
|
202
|
+
*/
|
|
203
|
+
readonly anchorRect?: Rect | null,
|
|
173
204
|
readonly overlayRef: { current: HTMLElement | null },
|
|
174
205
|
/** Nothing is measured while it is closed: there is nothing to measure. */
|
|
175
206
|
readonly open: boolean,
|
|
176
|
-
|
|
207
|
+
/** Resolved against the trigger's writing direction; see `LogicalSide`. */
|
|
208
|
+
readonly side: LogicalSide,
|
|
177
209
|
readonly align: Align,
|
|
178
210
|
readonly sideOffset: number,
|
|
179
211
|
readonly alignOffset: number,
|
|
@@ -189,6 +221,28 @@ const OPPOSITE: { readonly [Side]: Side } = {
|
|
|
189
221
|
right: "left",
|
|
190
222
|
};
|
|
191
223
|
|
|
224
|
+
/**
|
|
225
|
+
* The side on the screen that `side` names for a reader reading `direction`.
|
|
226
|
+
*
|
|
227
|
+
* The four physical ones pass through unchanged, because a design that puts a
|
|
228
|
+
* popover to the right of a toolbar means the right of the toolbar in Arabic
|
|
229
|
+
* too; `Side`'s own documentation says why that is the useful default and why
|
|
230
|
+
* alignment is the axis that mirrors.
|
|
231
|
+
*/
|
|
232
|
+
export function physicalSide(side: LogicalSide, direction: Direction): Side {
|
|
233
|
+
// Written out rather than left to a wildcard, so that a sixth side added to
|
|
234
|
+
// `LogicalSide` one day is a checker error here instead of a value that falls
|
|
235
|
+
// through unresolved.
|
|
236
|
+
return match (side) {
|
|
237
|
+
"inline-start" => direction === "rtl" ? "right" : "left",
|
|
238
|
+
"inline-end" => direction === "rtl" ? "left" : "right",
|
|
239
|
+
"top" => "top",
|
|
240
|
+
"right" => "right",
|
|
241
|
+
"bottom" => "bottom",
|
|
242
|
+
"left" => "left",
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
192
246
|
/** Whether a side stacks the overlay above the trigger or beside it. */
|
|
193
247
|
function isVertical(side: Side): boolean {
|
|
194
248
|
return side === "top" || side === "bottom";
|
|
@@ -388,6 +442,7 @@ export hook useAnchor(request: AnchorRequest): Anchored {
|
|
|
388
442
|
const {
|
|
389
443
|
align,
|
|
390
444
|
alignOffset,
|
|
445
|
+
anchorRect,
|
|
391
446
|
anchorRef,
|
|
392
447
|
avoidCollisions,
|
|
393
448
|
collisionPadding,
|
|
@@ -396,7 +451,15 @@ export hook useAnchor(request: AnchorRequest): Anchored {
|
|
|
396
451
|
side,
|
|
397
452
|
sideOffset,
|
|
398
453
|
} = request;
|
|
399
|
-
|
|
454
|
+
// Absent and `null` are one answer here — "measure the element" — so the two
|
|
455
|
+
// spellings become one value before anything depends on it.
|
|
456
|
+
const virtual = anchorRect ?? null;
|
|
457
|
+
// The left-to-right reading of a logical side, which is what `Anchored`
|
|
458
|
+
// reports until something has been measured. In a right-to-left page a
|
|
459
|
+
// submenu's `inline-end` is the *left*, and the first `reflow` says so - one
|
|
460
|
+
// commit later, exactly as a flip does, and for the same reason: the direction
|
|
461
|
+
// is a fact about the document, and a render may not read one.
|
|
462
|
+
const [settled, setSettled] = useState<Anchored>({ align, side: physicalSide(side, "ltr") });
|
|
400
463
|
|
|
401
464
|
const reflow = useStableCallback(() => {
|
|
402
465
|
const anchor = anchorRef.current;
|
|
@@ -405,7 +468,7 @@ export hook useAnchor(request: AnchorRequest): Anchored {
|
|
|
405
468
|
if (anchor == null || overlay == null || view == null) {
|
|
406
469
|
return;
|
|
407
470
|
}
|
|
408
|
-
const box = rectOf(anchor);
|
|
471
|
+
const box = virtual ?? rectOf(anchor);
|
|
409
472
|
const placement = placeOverlay({
|
|
410
473
|
align,
|
|
411
474
|
alignOffset,
|
|
@@ -414,7 +477,7 @@ export hook useAnchor(request: AnchorRequest): Anchored {
|
|
|
414
477
|
collisionPadding,
|
|
415
478
|
direction: directionOf(anchor),
|
|
416
479
|
overlay: rectOf(overlay),
|
|
417
|
-
side,
|
|
480
|
+
side: physicalSide(side, directionOf(anchor)),
|
|
418
481
|
sideOffset,
|
|
419
482
|
// The viewport of a fixed element, which is the whole of it: a fixed box
|
|
420
483
|
// is positioned against the viewport rather than against whatever is
|
|
@@ -443,8 +506,9 @@ export hook useAnchor(request: AnchorRequest): Anchored {
|
|
|
443
506
|
// drawn from `data-side` pointing the wrong way, after a reopen that
|
|
444
507
|
// follows a flip.
|
|
445
508
|
if (!open) {
|
|
509
|
+
const asked = physicalSide(side, anchor == null ? "ltr" : directionOf(anchor));
|
|
446
510
|
setSettled((current) =>
|
|
447
|
-
current.side ===
|
|
511
|
+
current.side === asked && current.align === align ? current : { align, side: asked },
|
|
448
512
|
);
|
|
449
513
|
}
|
|
450
514
|
return;
|
|
@@ -482,6 +546,7 @@ export hook useAnchor(request: AnchorRequest): Anchored {
|
|
|
482
546
|
}, [
|
|
483
547
|
align,
|
|
484
548
|
alignOffset,
|
|
549
|
+
anchorRect,
|
|
485
550
|
anchorRef,
|
|
486
551
|
avoidCollisions,
|
|
487
552
|
collisionPadding,
|
|
@@ -496,5 +561,5 @@ export hook useAnchor(request: AnchorRequest): Anchored {
|
|
|
496
561
|
// first commit — and the server's markup, which measures nothing at all —
|
|
497
562
|
// says the requested side rather than the last one some other opening
|
|
498
563
|
// happened to settle on.
|
|
499
|
-
return open ? settled : { align, side };
|
|
564
|
+
return open ? settled : { align, side: physicalSide(side, "ltr") };
|
|
500
565
|
}
|
|
@@ -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
|
+
}
|
package/internal/disclosure.js
CHANGED
|
@@ -48,6 +48,58 @@
|
|
|
48
48
|
// or re-adds the attribute and this upgrades it again. Nothing here writes an
|
|
49
49
|
// attribute React believes it owns while React believes it.
|
|
50
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
|
+
//
|
|
51
103
|
// # Why this is `internal/` and not a subpath
|
|
52
104
|
//
|
|
53
105
|
// The same reason `roving-focus.js` gives. These are rules about markup this
|
|
@@ -95,3 +147,152 @@ export hook useUntilFound(ref: { current: HTMLElement | null }, open: boolean):
|
|
|
95
147
|
element.setAttribute("hidden", "until-found");
|
|
96
148
|
}, [ref, open]);
|
|
97
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
|
+
}
|