@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/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, and the
103
- // count a screen reader is told.
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
- export type { Align, Side } from "./popover.js";
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
- * {matches.map((each) => (
759
- * <Combobox.Option key={each} value={each}>{each}</Combobox.Option>
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
  *
@@ -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
- readonly side: Side,
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
- const [settled, setSettled] = useState<Anchored>({ align, side });
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 === side && current.align === align ? current : { align, 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.12",
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/hooks": "0.0.0-alpha.12",
52
- "@uniflowed/react": "0.0.0-alpha.12"
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"