@uniflowed/ui 0.0.0-alpha.12 → 0.0.0-alpha.13
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/calendar.js +550 -0
- package/combobox.js +176 -5
- package/date-picker.js +346 -0
- package/hover-card.js +3 -3
- package/index.js +94 -6
- package/internal/anchor.js +47 -5
- package/internal/date-grid.js +260 -0
- package/menu.js +45 -0
- package/package.json +6 -3
- package/popover.js +21 -8
- package/resizable.js +149 -9
- package/select.js +29 -0
- package/tooltip.js +3 -3
package/index.js
CHANGED
|
@@ -99,8 +99,9 @@
|
|
|
99
99
|
// plain element does not; if it ever stops being true, the component should
|
|
100
100
|
// be deleted rather than fixed.
|
|
101
101
|
// - `menu.js` — the arrow keys, typeahead, submenus and `Escape` stacking.
|
|
102
|
-
// - `combobox.js` — `aria-activedescendant` over a filtered list,
|
|
103
|
-
//
|
|
102
|
+
// - `combobox.js` — `aria-activedescendant` over a filtered list, the count a
|
|
103
|
+
// screen reader is told, and the option groups that make a command palette a
|
|
104
|
+
// composition rather than a seventh module.
|
|
104
105
|
// - `select.js` — the other half of the combobox pattern: the select-only one,
|
|
105
106
|
// with typeahead, option groups and a value a form can submit.
|
|
106
107
|
// - `tabs.js` — the roving `tabindex`, and automatic versus manual activation.
|
|
@@ -182,10 +183,19 @@ import {
|
|
|
182
183
|
CarouselPrevious,
|
|
183
184
|
CarouselRoot,
|
|
184
185
|
} from "./carousel.js";
|
|
186
|
+
import {
|
|
187
|
+
CalendarDay,
|
|
188
|
+
CalendarMonth,
|
|
189
|
+
CalendarNext,
|
|
190
|
+
CalendarPrevious,
|
|
191
|
+
CalendarRoot,
|
|
192
|
+
} from "./calendar.js";
|
|
185
193
|
import { Checkbox } from "./checkbox.js";
|
|
186
194
|
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from "./collapsible.js";
|
|
187
195
|
import {
|
|
188
196
|
ComboboxEmpty,
|
|
197
|
+
ComboboxGroup,
|
|
198
|
+
ComboboxGroupLabel,
|
|
189
199
|
ComboboxInput,
|
|
190
200
|
ComboboxLabel,
|
|
191
201
|
ComboboxList,
|
|
@@ -193,6 +203,12 @@ import {
|
|
|
193
203
|
ComboboxRoot,
|
|
194
204
|
ComboboxStatus,
|
|
195
205
|
} from "./combobox.js";
|
|
206
|
+
import {
|
|
207
|
+
DatePickerCalendar,
|
|
208
|
+
DatePickerInput,
|
|
209
|
+
DatePickerRoot,
|
|
210
|
+
DatePickerTrigger,
|
|
211
|
+
} from "./date-picker.js";
|
|
196
212
|
import {
|
|
197
213
|
DialogBody,
|
|
198
214
|
DialogClose,
|
|
@@ -312,6 +328,9 @@ import { ToggleGroupItem, ToggleGroupRoot } from "./toggle-group.js";
|
|
|
312
328
|
import { TooltipBody, TooltipProvider, TooltipRoot, TooltipTrigger } from "./tooltip.js";
|
|
313
329
|
|
|
314
330
|
export type { AccordionType } from "./accordion.js";
|
|
331
|
+
// A date, however a caller had one to hand: a `PlainDate` from
|
|
332
|
+
// `@uniflowed/temporal`, or the ISO 8601 string a form field or a URL carries.
|
|
333
|
+
export type { DateValue } from "./calendar.js";
|
|
315
334
|
export type { ActivationMode } from "./tabs.js";
|
|
316
335
|
// What a modal announces itself as, for a caller who holds one in a variable.
|
|
317
336
|
// Two members, not a string: see `dialog.js`.
|
|
@@ -324,7 +343,10 @@ export type { SidebarSide } from "./sidebar.js";
|
|
|
324
343
|
// Where an anchored overlay opens, for a caller who holds one in a variable or
|
|
325
344
|
// a prop of their own. Unions rather than strings, so `side="botom"` is a type
|
|
326
345
|
// error at the call rather than an overlay that quietly opens somewhere else.
|
|
327
|
-
|
|
346
|
+
// `LogicalSide` is the same four plus `inline-start` and `inline-end`, which
|
|
347
|
+
// are the ones that mean "the way the reader reads" - what a submenu opens
|
|
348
|
+
// onto, and the left of the page in Arabic.
|
|
349
|
+
export type { Align, LogicalSide, Side } from "./popover.js";
|
|
328
350
|
export type { Sort } from "./table.js";
|
|
329
351
|
export type { Notification, ToastChanges, ToastOptions, Urgency } from "./toast.js";
|
|
330
352
|
export type { ToggleGroupType } from "./toggle-group.js";
|
|
@@ -755,13 +777,19 @@ export const Menu = {
|
|
|
755
777
|
* <Combobox.Label>Country</Combobox.Label>
|
|
756
778
|
* <Combobox.Input />
|
|
757
779
|
* <Combobox.List>
|
|
758
|
-
*
|
|
759
|
-
* <Combobox.
|
|
760
|
-
*
|
|
780
|
+
* <Combobox.Group>
|
|
781
|
+
* <Combobox.GroupLabel>Europe</Combobox.GroupLabel>
|
|
782
|
+
* {european.map((each) => (
|
|
783
|
+
* <Combobox.Option key={each} value={each}>{each}</Combobox.Option>
|
|
784
|
+
* ))}
|
|
785
|
+
* </Combobox.Group>
|
|
761
786
|
* </Combobox.List>
|
|
762
787
|
* <Combobox.Empty>No matches.</Combobox.Empty>
|
|
763
788
|
* <Combobox.Status />
|
|
764
789
|
* </Combobox.Root>
|
|
790
|
+
*
|
|
791
|
+
* `Combobox.Label` names the field and `Combobox.GroupLabel` names a group of
|
|
792
|
+
* options, which is why there are two of them.
|
|
765
793
|
*/
|
|
766
794
|
export const Combobox = {
|
|
767
795
|
Root: ComboboxRoot,
|
|
@@ -769,6 +797,8 @@ export const Combobox = {
|
|
|
769
797
|
Input: ComboboxInput,
|
|
770
798
|
List: ComboboxList,
|
|
771
799
|
Option: ComboboxOption,
|
|
800
|
+
Group: ComboboxGroup,
|
|
801
|
+
GroupLabel: ComboboxGroupLabel,
|
|
772
802
|
Empty: ComboboxEmpty,
|
|
773
803
|
Status: ComboboxStatus,
|
|
774
804
|
};
|
|
@@ -837,6 +867,64 @@ export const Popover = {
|
|
|
837
867
|
Body: PopoverBody,
|
|
838
868
|
};
|
|
839
869
|
|
|
870
|
+
/**
|
|
871
|
+
* A month of dates, as one stop in the page's tab order.
|
|
872
|
+
*
|
|
873
|
+
* The grid is `role="grid"`, the arrow keys move by a day and by a week, and
|
|
874
|
+
* `PageUp` and `PageDown` change the month - with `Shift`, the year. Running off
|
|
875
|
+
* the end of a month shows the next one and lands on its first day, and the
|
|
876
|
+
* month is announced in a live region when it changes.
|
|
877
|
+
*
|
|
878
|
+
* <Calendar.Root defaultValue="2026-10-14" onValueChange={setWhen}>
|
|
879
|
+
* <Calendar.Previous>Previous month</Calendar.Previous>
|
|
880
|
+
* <Calendar.Next>Next month</Calendar.Next>
|
|
881
|
+
* <Calendar.Month />
|
|
882
|
+
* </Calendar.Root>
|
|
883
|
+
*
|
|
884
|
+
* `Calendar.Month` takes a function child when a day needs more than its number
|
|
885
|
+
* in it - a dot for an appointment, a price for a night - and it is handed the
|
|
886
|
+
* date and returns a `Calendar.Day`.
|
|
887
|
+
*
|
|
888
|
+
* Dates are `@uniflowed/temporal`'s `PlainDate`, or the ISO strings it reads.
|
|
889
|
+
* `isDateDisabled` marks a day unavailable *without* making it unreachable: it
|
|
890
|
+
* is `aria-disabled` and the arrow keys still land on it, which is the opposite
|
|
891
|
+
* of what a disabled menu item does and the only way a reader can find out which
|
|
892
|
+
* days are unavailable.
|
|
893
|
+
*/
|
|
894
|
+
export const Calendar = {
|
|
895
|
+
Root: CalendarRoot,
|
|
896
|
+
Previous: CalendarPrevious,
|
|
897
|
+
Next: CalendarNext,
|
|
898
|
+
Month: CalendarMonth,
|
|
899
|
+
Day: CalendarDay,
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* A field somebody types a date into, and a calendar for the times they would
|
|
904
|
+
* rather point at one.
|
|
905
|
+
*
|
|
906
|
+
* <DatePicker.Root onValueChange={setWhen} value={when}>
|
|
907
|
+
* <DatePicker.Input aria-label="Arrive on" />
|
|
908
|
+
* <DatePicker.Trigger>Choose a date</DatePicker.Trigger>
|
|
909
|
+
* <DatePicker.Calendar>
|
|
910
|
+
* <Calendar.Previous>Previous month</Calendar.Previous>
|
|
911
|
+
* <Calendar.Next>Next month</Calendar.Next>
|
|
912
|
+
* <Calendar.Month />
|
|
913
|
+
* </DatePicker.Calendar>
|
|
914
|
+
* </DatePicker.Root>
|
|
915
|
+
*
|
|
916
|
+
* The field is the control and the grid is the second way in: `Escape` and a
|
|
917
|
+
* chosen date both put focus back on the field. `format` and `parse` are ISO
|
|
918
|
+
* 8601 both ways unless a caller passes their own - `date-picker.js` says why a
|
|
919
|
+
* locale format is not this package's to guess.
|
|
920
|
+
*/
|
|
921
|
+
export const DatePicker = {
|
|
922
|
+
Root: DatePickerRoot,
|
|
923
|
+
Input: DatePickerInput,
|
|
924
|
+
Trigger: DatePickerTrigger,
|
|
925
|
+
Calendar: DatePickerCalendar,
|
|
926
|
+
};
|
|
927
|
+
|
|
840
928
|
/**
|
|
841
929
|
* A phrase about a control, on hover and on focus, that WCAG would accept.
|
|
842
930
|
*
|
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
|
*
|
|
@@ -173,7 +186,8 @@ export type AnchorRequest = {|
|
|
|
173
186
|
readonly overlayRef: { current: HTMLElement | null },
|
|
174
187
|
/** Nothing is measured while it is closed: there is nothing to measure. */
|
|
175
188
|
readonly open: boolean,
|
|
176
|
-
|
|
189
|
+
/** Resolved against the trigger's writing direction; see `LogicalSide`. */
|
|
190
|
+
readonly side: LogicalSide,
|
|
177
191
|
readonly align: Align,
|
|
178
192
|
readonly sideOffset: number,
|
|
179
193
|
readonly alignOffset: number,
|
|
@@ -189,6 +203,28 @@ const OPPOSITE: { readonly [Side]: Side } = {
|
|
|
189
203
|
right: "left",
|
|
190
204
|
};
|
|
191
205
|
|
|
206
|
+
/**
|
|
207
|
+
* The side on the screen that `side` names for a reader reading `direction`.
|
|
208
|
+
*
|
|
209
|
+
* The four physical ones pass through unchanged, because a design that puts a
|
|
210
|
+
* popover to the right of a toolbar means the right of the toolbar in Arabic
|
|
211
|
+
* too; `Side`'s own documentation says why that is the useful default and why
|
|
212
|
+
* alignment is the axis that mirrors.
|
|
213
|
+
*/
|
|
214
|
+
export function physicalSide(side: LogicalSide, direction: Direction): Side {
|
|
215
|
+
// Written out rather than left to a wildcard, so that a sixth side added to
|
|
216
|
+
// `LogicalSide` one day is a checker error here instead of a value that falls
|
|
217
|
+
// through unresolved.
|
|
218
|
+
return match (side) {
|
|
219
|
+
"inline-start" => direction === "rtl" ? "right" : "left",
|
|
220
|
+
"inline-end" => direction === "rtl" ? "left" : "right",
|
|
221
|
+
"top" => "top",
|
|
222
|
+
"right" => "right",
|
|
223
|
+
"bottom" => "bottom",
|
|
224
|
+
"left" => "left",
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
192
228
|
/** Whether a side stacks the overlay above the trigger or beside it. */
|
|
193
229
|
function isVertical(side: Side): boolean {
|
|
194
230
|
return side === "top" || side === "bottom";
|
|
@@ -396,7 +432,12 @@ export hook useAnchor(request: AnchorRequest): Anchored {
|
|
|
396
432
|
side,
|
|
397
433
|
sideOffset,
|
|
398
434
|
} = request;
|
|
399
|
-
|
|
435
|
+
// The left-to-right reading of a logical side, which is what `Anchored`
|
|
436
|
+
// reports until something has been measured. In a right-to-left page a
|
|
437
|
+
// submenu's `inline-end` is the *left*, and the first `reflow` says so - one
|
|
438
|
+
// commit later, exactly as a flip does, and for the same reason: the direction
|
|
439
|
+
// is a fact about the document, and a render may not read one.
|
|
440
|
+
const [settled, setSettled] = useState<Anchored>({ align, side: physicalSide(side, "ltr") });
|
|
400
441
|
|
|
401
442
|
const reflow = useStableCallback(() => {
|
|
402
443
|
const anchor = anchorRef.current;
|
|
@@ -414,7 +455,7 @@ export hook useAnchor(request: AnchorRequest): Anchored {
|
|
|
414
455
|
collisionPadding,
|
|
415
456
|
direction: directionOf(anchor),
|
|
416
457
|
overlay: rectOf(overlay),
|
|
417
|
-
side,
|
|
458
|
+
side: physicalSide(side, directionOf(anchor)),
|
|
418
459
|
sideOffset,
|
|
419
460
|
// The viewport of a fixed element, which is the whole of it: a fixed box
|
|
420
461
|
// is positioned against the viewport rather than against whatever is
|
|
@@ -443,8 +484,9 @@ export hook useAnchor(request: AnchorRequest): Anchored {
|
|
|
443
484
|
// drawn from `data-side` pointing the wrong way, after a reopen that
|
|
444
485
|
// follows a flip.
|
|
445
486
|
if (!open) {
|
|
487
|
+
const asked = physicalSide(side, anchor == null ? "ltr" : directionOf(anchor));
|
|
446
488
|
setSettled((current) =>
|
|
447
|
-
current.side ===
|
|
489
|
+
current.side === asked && current.align === align ? current : { align, side: asked },
|
|
448
490
|
);
|
|
449
491
|
}
|
|
450
492
|
return;
|
|
@@ -496,5 +538,5 @@ export hook useAnchor(request: AnchorRequest): Anchored {
|
|
|
496
538
|
// first commit — and the server's markup, which measures nothing at all —
|
|
497
539
|
// says the requested side rather than the last one some other opening
|
|
498
540
|
// happened to settle on.
|
|
499
|
-
return open ? settled : { align, side };
|
|
541
|
+
return open ? settled : { align, side: physicalSide(side, "ltr") };
|
|
500
542
|
}
|
|
@@ -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/menu.js
CHANGED
|
@@ -39,6 +39,23 @@
|
|
|
39
39
|
// were aiming at. Keyboard and click open a submenu; a deliberate hover
|
|
40
40
|
// implementation is tracked work, not a line to be added carelessly.
|
|
41
41
|
//
|
|
42
|
+
// # Where the menu goes
|
|
43
|
+
//
|
|
44
|
+
// `internal/anchor.js`, the same module `Popover.Body` uses, and adopting it
|
|
45
|
+
// here rather than writing a second one is most of ubugeeei-prod/uf#256. Two
|
|
46
|
+
// things about a menu were wrong before it and are worth naming, because
|
|
47
|
+
// neither looks like a positioning bug:
|
|
48
|
+
//
|
|
49
|
+
// * A menu in a table row, a card, or anything else with `overflow: hidden`
|
|
50
|
+
// was cut off at that box's edge. It is `position: fixed` now, so the
|
|
51
|
+
// clipping ancestor is not its business.
|
|
52
|
+
// * A menu whose trigger sat near the bottom of the page opened downwards,
|
|
53
|
+
// off the screen, and the reader saw nothing at all.
|
|
54
|
+
//
|
|
55
|
+
// A submenu asks for `side="inline-end"` rather than `right`, which is the same
|
|
56
|
+
// answer `submenuKeys` gives about the *keys*: the submenu opens the way the
|
|
57
|
+
// page reads, and the arrow that opens it points at where it went.
|
|
58
|
+
//
|
|
42
59
|
// # Items are found in the document, not in a registry
|
|
43
60
|
//
|
|
44
61
|
// `internal/roving-focus.js` explains why. The short version is that mount
|
|
@@ -59,6 +76,8 @@ import {
|
|
|
59
76
|
} from "@uniflowed/react";
|
|
60
77
|
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
61
78
|
|
|
79
|
+
import type { Align, LogicalSide } from "./internal/anchor.js";
|
|
80
|
+
import { useAnchor } from "./internal/anchor.js";
|
|
62
81
|
import type { Rest } from "./internal/merge-props.js";
|
|
63
82
|
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
64
83
|
import {
|
|
@@ -73,6 +92,8 @@ import {
|
|
|
73
92
|
import { useControlled } from "./internal/controlled-state.js";
|
|
74
93
|
import type { Direction } from "./internal/roving-focus.js";
|
|
75
94
|
|
|
95
|
+
export type { Align, LogicalSide, Side } from "./internal/anchor.js";
|
|
96
|
+
|
|
76
97
|
/**
|
|
77
98
|
* Anything that plays the part of a menu item, including the two checkable
|
|
78
99
|
* kinds a caller may write themselves. The keyboard has to move between all of
|
|
@@ -328,6 +349,12 @@ export component MenuTrigger(children: React.Node, ...rest: Rest) {
|
|
|
328
349
|
*/
|
|
329
350
|
export component MenuBody(
|
|
330
351
|
children: renders* (MenuItem | MenuSeparator | MenuGroup | MenuSub),
|
|
352
|
+
align?: Align = "start",
|
|
353
|
+
alignOffset?: number = 0,
|
|
354
|
+
avoidCollisions?: boolean = true,
|
|
355
|
+
collisionPadding?: number = 0,
|
|
356
|
+
side?: LogicalSide,
|
|
357
|
+
sideOffset?: number = 0,
|
|
331
358
|
...rest: Rest
|
|
332
359
|
) {
|
|
333
360
|
const menu = useMenu("Menu.Body");
|
|
@@ -343,6 +370,22 @@ export component MenuBody(
|
|
|
343
370
|
const pendingFocus = menu.pendingFocus;
|
|
344
371
|
const isRoot = menu.parent == null;
|
|
345
372
|
const closeAll = useStableCallback(() => closeTree(menu));
|
|
373
|
+
// A root menu drops from its button; a submenu comes out of the side of the
|
|
374
|
+
// item that opened it, on the side the page reads towards. The default cannot
|
|
375
|
+
// be a parameter default because it is not a constant: it is the answer to
|
|
376
|
+
// "is this the outermost menu", which only this component knows.
|
|
377
|
+
const placement = side ?? (isRoot ? "bottom" : "inline-end");
|
|
378
|
+
const anchored = useAnchor({
|
|
379
|
+
align,
|
|
380
|
+
alignOffset,
|
|
381
|
+
anchorRef: triggerRef,
|
|
382
|
+
avoidCollisions,
|
|
383
|
+
collisionPadding,
|
|
384
|
+
open: menu.open,
|
|
385
|
+
overlayRef: bodyRef,
|
|
386
|
+
side: placement,
|
|
387
|
+
sideOffset,
|
|
388
|
+
});
|
|
346
389
|
// Set when the menu was dismissed by a press somewhere else, so the cleanup
|
|
347
390
|
// knows not to drag focus back to the trigger the reader just left.
|
|
348
391
|
const dismissed = useRef(false);
|
|
@@ -418,6 +461,8 @@ export component MenuBody(
|
|
|
418
461
|
{...passed}
|
|
419
462
|
aria-labelledby={menu.triggered ? `${menu.base}-trigger` : undefined}
|
|
420
463
|
aria-orientation="vertical"
|
|
464
|
+
data-align={anchored.align}
|
|
465
|
+
data-side={anchored.side}
|
|
421
466
|
id={`${menu.base}-body`}
|
|
422
467
|
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
423
468
|
const body: $FlowFixMe = event.currentTarget;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/ui",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.13",
|
|
4
4
|
"description": "Headless, accessible React components whose composition Flow checks, part of the Unified Toolchain for Flow.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
".": "./index.js",
|
|
15
15
|
"./accordion": "./accordion.js",
|
|
16
16
|
"./alert-dialog": "./alert-dialog.js",
|
|
17
|
+
"./calendar": "./calendar.js",
|
|
17
18
|
"./carousel": "./carousel.js",
|
|
18
19
|
"./checkbox": "./checkbox.js",
|
|
19
20
|
"./collapsible": "./collapsible.js",
|
|
20
21
|
"./combobox": "./combobox.js",
|
|
22
|
+
"./date-picker": "./date-picker.js",
|
|
21
23
|
"./dialog": "./dialog.js",
|
|
22
24
|
"./drawer": "./drawer.js",
|
|
23
25
|
"./field": "./field.js",
|
|
@@ -48,8 +50,9 @@
|
|
|
48
50
|
"internal"
|
|
49
51
|
],
|
|
50
52
|
"dependencies": {
|
|
51
|
-
"@uniflowed/
|
|
52
|
-
"@uniflowed/
|
|
53
|
+
"@uniflowed/core": "0.0.0-alpha.13",
|
|
54
|
+
"@uniflowed/hooks": "0.0.0-alpha.13",
|
|
55
|
+
"@uniflowed/react": "0.0.0-alpha.13"
|
|
53
56
|
},
|
|
54
57
|
"peerDependencies": {
|
|
55
58
|
"react": ">=19"
|