@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/combobox.js CHANGED
@@ -49,6 +49,69 @@
49
49
  // the active option is cleared when the option it named is filtered away, the
50
50
  // count is remeasured, and `aria-activedescendant` never names an id that has
51
51
  // left the document.
52
+ //
53
+ // # Groups, and the two elements that had to change to have them
54
+ //
55
+ // A `listbox` may own `option` and `group` elements, and nothing else. This
56
+ // module rendered a `<ul>` of `<li>`s, which is the right shape for a flat list
57
+ // and the wrong one the moment a group appears: a group's options belong inside
58
+ // the group, a group inside a `<ul>` is an `<li>`, and an `<li>` inside an
59
+ // `<li>` is not something HTML has. The parser closes the outer one, so the
60
+ // markup a server sent and the tree a browser built would disagree — which
61
+ // React finds at hydration, in production, on the one page that had groups.
62
+ //
63
+ // The way out that keeps the list is a second `<ul role="presentation">` around
64
+ // each group's options, and it was rejected twice over. It works by an
65
+ // inheritance rule — a presentational role propagating to the elements its own
66
+ // role requires, except where a child carries an explicit role — which is
67
+ // correct in the specification and up to the software, and this package's whole
68
+ // premise is not building on that distinction. It would also leave the two
69
+ // halves of one pattern with two differently shaped listboxes, for a reason
70
+ // neither module could state.
71
+ //
72
+ // So `Combobox.List` and `Combobox.Option` are `div`s, exactly as `select.js`'s
73
+ // are and for the reason its header already gives at length. That is a change
74
+ // to what this component renders, and a caller whose stylesheet names `ul` or
75
+ // `li` will see it; nothing else moved, because the roles were always the part
76
+ // that carried the meaning.
77
+ //
78
+ // `Combobox.Group` and `Combobox.GroupLabel` are then `Select.Group` and
79
+ // `Select.GroupLabel`. The second name is deliberate rather than clumsy:
80
+ // `Combobox.Label` already means the *field's* label, so the heading over a
81
+ // group of options cannot also be `Combobox.Label`, and shadcn's single
82
+ // `SelectLabel` — which is the group's — has no name left for the field's.
83
+ //
84
+ // There is no `Combobox.Separator`, and that is the same decision `select.js`
85
+ // made about the tree rather than a different one about the part. A rule
86
+ // between two groups of options cannot be a `role="separator"`, because a
87
+ // listbox may not own one; it is `aria-hidden` decoration, and a
88
+ // `<div aria-hidden="true">` is something a caller writes without needing a
89
+ // part for it. `Select.Separator` exists because a select's options are a fixed
90
+ // list somebody wrote out and the rule between two of them is fixed too. A
91
+ // combobox's options are whatever survived the filter, so a rule that stays put
92
+ // while the groups either side of it disappear is decoration in the wrong
93
+ // place, and the caller who filtered is the one who knows where it goes.
94
+ //
95
+ // # A command palette is a composition, not a seventh module
96
+ //
97
+ // `crates/uf_lib/src/ui.rs` lists a `Command` with `Root`, `Input`, `List`,
98
+ // `Item`, `Group` and `Empty`, and with groups here every one of those parts
99
+ // now exists: a palette is a `Combobox` inside a `Dialog`, opened by
100
+ // `useKeyCombo("mod+k", …)` from `@uniflowed/hooks/keyboard`, with
101
+ // `Combobox.Group` for the sections, `Combobox.Empty` for the no-results state
102
+ // and `Combobox.Status` for the count. `ubugeeei-redundancy.md`'s objection to
103
+ // small lookalikes is an objection to shipping a module whose entire content is
104
+ // a composition the reader could have written, so the answer is the
105
+ // documentation page — `docs/app/reference/ui`, under "A command palette" —
106
+ // and not a seventh module.
107
+ //
108
+ // One behaviour a `Command` module would genuinely add is not in that page,
109
+ // because it is not implemented anywhere: a palette whose filter matched
110
+ // nothing still traps focus, so `Tab` cycles between a text field and a close
111
+ // button while the reader is told there are no results. That is `Dialog`'s
112
+ // question rather than this module's — a modal with nothing in it to reach is
113
+ // the general case — and it is left open on purpose rather than answered here
114
+ // by a component that would only look like it had.
52
115
 
53
116
  "use client";
54
117
 
@@ -64,12 +127,16 @@ import {
64
127
  } from "@uniflowed/react";
65
128
  import { useStableCallback } from "@uniflowed/hooks/lifecycle";
66
129
 
130
+ import type { Align, LogicalSide } from "./internal/anchor.js";
131
+ import { useAnchor } from "./internal/anchor.js";
67
132
  import type { Rest } from "./internal/merge-props.js";
68
133
  import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
69
134
  import { itemsOf, moveTo } from "./internal/roving-focus.js";
70
135
  import { useControlled } from "./internal/controlled-state.js";
71
136
  import { FormValue } from "./internal/form-value.js";
72
137
 
138
+ export type { Align, LogicalSide, Side } from "./internal/anchor.js";
139
+
73
140
  const OPTION_SELECTOR = '[role="option"]';
74
141
  const LISTBOX_SELECTOR = '[role="listbox"]';
75
142
 
@@ -116,6 +183,14 @@ hook useCombobox(part: string): ComboboxState {
116
183
  return state;
117
184
  }
118
185
 
186
+ /** The id of a group's label, so `Combobox.Group` only claims one that exists. */
187
+ type ComboboxGroupState = {|
188
+ readonly labelId: string,
189
+ readonly registerLabel: (present: boolean) => void,
190
+ |};
191
+
192
+ const ComboboxGroupContext: React.Context<ComboboxGroupState | null> = createContext(null);
193
+
119
194
  /**
120
195
  * The combobox.
121
196
  *
@@ -347,11 +422,24 @@ export component ComboboxInput(...rest: Rest) {
347
422
  /**
348
423
  * The list of options, in the document only while it is open.
349
424
  *
425
+ * A `div` rather than the `ul` this was, because a listbox that owns groups
426
+ * cannot be a list without a second `list` role between a group and the options
427
+ * it holds. The module header has the argument and what it costs a caller.
428
+ *
350
429
  * It also keeps the two things that have to stay true as the caller filters:
351
430
  * the count the live region announces, and the invariant that
352
431
  * `aria-activedescendant` never names an option that has left the list.
353
432
  */
354
- export component ComboboxList(children: renders* ComboboxOption, ...rest: Rest) {
433
+ export component ComboboxList(
434
+ children: renders* (ComboboxOption | ComboboxGroup),
435
+ align?: Align = "start",
436
+ alignOffset?: number = 0,
437
+ avoidCollisions?: boolean = true,
438
+ collisionPadding?: number = 0,
439
+ side?: LogicalSide = "bottom",
440
+ sideOffset?: number = 0,
441
+ ...rest: Rest
442
+ ) {
355
443
  const combobox = useCombobox("Combobox.List");
356
444
  const { activeId, count, listRef, inputRef, pendingActive, setActiveId, setCount } = combobox;
357
445
  const close = useStableCallback(() => {
@@ -359,6 +447,22 @@ export component ComboboxList(children: renders* ComboboxOption, ...rest: Rest)
359
447
  combobox.setActiveId(null);
360
448
  });
361
449
 
450
+ // Anchored to the *field*, not to a wrapper the caller may not have written.
451
+ // `align="start"` because a list of options belongs under the edge the text
452
+ // starts at, and `--uf-anchor-trigger-width` is what a stylesheet reads to
453
+ // make it exactly as wide as the field.
454
+ const anchored = useAnchor({
455
+ align,
456
+ alignOffset,
457
+ anchorRef: inputRef,
458
+ avoidCollisions,
459
+ collisionPadding,
460
+ open: combobox.open,
461
+ overlayRef: listRef,
462
+ side,
463
+ sideOffset,
464
+ });
465
+
362
466
  // No dependency list on purpose: what this reads is the *rendered* options,
363
467
  // and they change whenever the caller re-filters — which is a change to
364
468
  // `children` that no dependency list can describe. Every write below is
@@ -427,9 +531,11 @@ export component ComboboxList(children: renders* ComboboxOption, ...rest: Rest)
427
531
  const passed = withoutComposed(rest, ["ref"]);
428
532
 
429
533
  return (
430
- <ul
534
+ <div
431
535
  {...passed}
432
536
  aria-labelledby={combobox.labelled ? `${combobox.base}-label` : undefined}
537
+ data-align={anchored.align}
538
+ data-side={anchored.side}
433
539
  id={`${combobox.base}-list`}
434
540
  ref={composeRefs(rest.ref, (element) => {
435
541
  listRef.current = element;
@@ -437,7 +543,7 @@ export component ComboboxList(children: renders* ComboboxOption, ...rest: Rest)
437
543
  role="listbox"
438
544
  >
439
545
  {children}
440
- </ul>
546
+ </div>
441
547
  );
442
548
  }
443
549
 
@@ -463,7 +569,7 @@ export component ComboboxOption(
463
569
  const passed = withoutComposed(rest, ["onClick", "onPointerDown", "onPointerMove"]);
464
570
 
465
571
  return (
466
- <li
572
+ <div
467
573
  {...passed}
468
574
  aria-disabled={disabled ? "true" : undefined}
469
575
  aria-selected={combobox.value === value ? "true" : "false"}
@@ -495,7 +601,72 @@ export component ComboboxOption(
495
601
  role="option"
496
602
  >
497
603
  {children}
498
- </li>
604
+ </div>
605
+ );
606
+ }
607
+
608
+ /**
609
+ * A named group of options.
610
+ *
611
+ * The name reaches the group through `aria-labelledby`, and only while a
612
+ * `Combobox.GroupLabel` is rendered — the same rule, and the same reason, as
613
+ * `Select.Group` and `Menu.Group` before it.
614
+ *
615
+ * Nothing about `Combobox.Input` had to learn that groups exist. It asks for
616
+ * `[role="option"]` elements whose nearest `[role="listbox"]` is this list, and
617
+ * a group is not a listbox — so the arrow keys walk an option at a time across
618
+ * a boundary they cannot see, and the heading is never a place the cursor can
619
+ * land, because it is not an option.
620
+ *
621
+ * `children` is narrower than `Select.Group`'s `React.Node`, and the narrower
622
+ * one is the true statement: a `group` inside a `listbox` may own options and
623
+ * its own heading, and nothing else. `Select.Group` should say the same and
624
+ * does not yet.
625
+ */
626
+ export component ComboboxGroup(
627
+ children: renders* (ComboboxOption | ComboboxGroupLabel),
628
+ ...rest: Rest
629
+ ) {
630
+ const base = useId();
631
+ const [labelled, setLabelled] = useState(false);
632
+
633
+ const group = useMemo(() => ({ labelId: `${base}-label`, registerLabel: setLabelled }), [base]);
634
+
635
+ return (
636
+ <ComboboxGroupContext.Provider value={group}>
637
+ <div {...rest} aria-labelledby={labelled ? group.labelId : undefined} role="group">
638
+ {children}
639
+ </div>
640
+ </ComboboxGroupContext.Provider>
641
+ );
642
+ }
643
+
644
+ /**
645
+ * The heading of a `Combobox.Group`.
646
+ *
647
+ * `role="presentation"` because the group already carries the name: left as
648
+ * ordinary content a reader would hear the heading once as the group's name and
649
+ * again as a stray line of text among the options.
650
+ *
651
+ * This is not `Combobox.Label`. That one names the field; this one names a
652
+ * group of options, and a combobox with groups has both.
653
+ */
654
+ export component ComboboxGroupLabel(children: React.Node, ...rest: Rest) {
655
+ const group = useContext(ComboboxGroupContext);
656
+ const register = group?.registerLabel;
657
+
658
+ useEffect(() => {
659
+ if (register == null) {
660
+ return;
661
+ }
662
+ register(true);
663
+ return () => register(false);
664
+ }, [register]);
665
+
666
+ return (
667
+ <div {...rest} id={group?.labelId} role="presentation">
668
+ {children}
669
+ </div>
499
670
  );
500
671
  }
501
672
 
package/date-picker.js ADDED
@@ -0,0 +1,346 @@
1
+ // @flow
2
+ //
3
+ // A field somebody types a date into, and a calendar for the times they would
4
+ // rather point at one.
5
+ //
6
+ // # Why the field comes first
7
+ //
8
+ // A date picker whose only input is the grid is slower for everybody who
9
+ // already knows the date — nine keystrokes to arrow to a day they could have
10
+ // typed in six — and it is unusable for anybody who cannot operate a grid at
11
+ // all. So the text field is the control, the calendar is the second way in, and
12
+ // the composition is written down here rather than left to each application to
13
+ // assemble differently.
14
+ //
15
+ // # It is a composition, and the parts are the ones that already exist
16
+ //
17
+ // `Popover` for the overlay and its dismissal, `Calendar` for the grid. Nothing
18
+ // about anchoring, outside presses, `Escape` or focus restoration is
19
+ // reimplemented here, which is the point: ubugeeei-prod/uf#256's complaint was
20
+ // five components each carrying a corner of the same behaviour, and a sixth
21
+ // carrying its own corner would be the same mistake with a different name.
22
+ //
23
+ // What this module does own is the three joins between them:
24
+ //
25
+ // * the field's text and the chosen date, which are not the same value and
26
+ // must not be kept in step by an effect that fights the reader's typing;
27
+ // * `Escape` and a chosen date both returning focus to the *field* rather than
28
+ // to the button, because the field is the primary control;
29
+ // * the calendar opening with focus on a date rather than on the button that
30
+ // steps back a month, which is `Popover.Body`'s `initialFocus` and
31
+ // `Calendar.Root`'s `focusedDayRef` meeting.
32
+ //
33
+ // # Parsing is `@uniflowed/temporal`'s, not this module's
34
+ //
35
+ // `format` and `parse` default to ISO 8601, because that is the format
36
+ // `Temporal.PlainDate` reads and writes and the only one this package can claim
37
+ // to handle. `12/03/26` is the third of December or the twelfth of March
38
+ // depending on where the reader is, and answering that needs the locale's date
39
+ // patterns — CLDR data, which is what `@uniflowed/temporal`'s calendar surface
40
+ // waits on the native runtime for. A UI package that shipped its own guess at it
41
+ // would be a wrong date in production rather than a missing feature, so the two
42
+ // props are the seam: an application with a locale format passes both, and gets
43
+ // its own round trip rather than this module's approximation of one.
44
+
45
+ "use client";
46
+
47
+ import * as React from "@uniflowed/react";
48
+ import { createContext, useContext, useMemo, useRef, useState } from "@uniflowed/react";
49
+ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
50
+ import type { PlainDate } from "@uniflowed/core/temporal";
51
+ import { Temporal } from "@uniflowed/core/temporal";
52
+
53
+ import type { DateValue } from "./calendar.js";
54
+ import { CalendarRoot } from "./calendar.js";
55
+ import { useControlled } from "./internal/controlled-state.js";
56
+ import type { Align, LogicalSide } from "./internal/anchor.js";
57
+ import type { Rest } from "./internal/merge-props.js";
58
+ import {
59
+ composeHandlers,
60
+ composeRefs,
61
+ forwarded,
62
+ withoutComposed,
63
+ } from "./internal/merge-props.js";
64
+ import { PopoverBody, PopoverRoot, PopoverTrigger } from "./popover.js";
65
+
66
+ type DatePickerState = {|
67
+ /** The text in the field, which is the draft while one is being typed. */
68
+ readonly text: string,
69
+ readonly setDraft: (text: string | null) => void,
70
+ /** Whether the last thing typed could not be read as a date. */
71
+ readonly invalid: boolean,
72
+ readonly commit: (text: string) => void,
73
+ readonly choose: (date: PlainDate) => void,
74
+ readonly value: PlainDate | null,
75
+ readonly fieldRef: { current: HTMLElement | null },
76
+ readonly focusField: () => void,
77
+ |};
78
+
79
+ const DatePickerContext: React.Context<DatePickerState | null> = createContext(null);
80
+
81
+ hook useDatePicker(part: string): DatePickerState {
82
+ const state = useContext(DatePickerContext);
83
+ if (state == null) {
84
+ throw new Error(`${part} must be rendered inside a DatePicker.Root`);
85
+ }
86
+ return state;
87
+ }
88
+
89
+ /** ISO 8601, which is what `PlainDate.toString` produces and `from` parses. */
90
+ function isoFormat(date: PlainDate): string {
91
+ return date.toString();
92
+ }
93
+
94
+ /**
95
+ * ISO 8601 or nothing.
96
+ *
97
+ * `PlainDate.from` throws a `RangeError` on anything it cannot read, and a
98
+ * reader half way through typing a date is in that state on almost every
99
+ * keystroke — so the failure is a value here rather than an exception, and the
100
+ * field decides what to do about it.
101
+ */
102
+ function isoParse(text: string): PlainDate | null {
103
+ const trimmed = text.trim();
104
+ if (trimmed === "") {
105
+ return null;
106
+ }
107
+ try {
108
+ return Temporal.PlainDate.from(trimmed);
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * The value, the popover around the calendar, and the joins between them.
116
+ *
117
+ * Renders no element of its own: the field, the button and the calendar are
118
+ * siblings in whatever layout the caller wrote, and a wrapper would put a
119
+ * `<div>` between them that they then have to style around.
120
+ */
121
+ export component DatePickerRoot(
122
+ children: React.Node,
123
+ defaultOpen?: boolean = false,
124
+ defaultValue?: DateValue | null = null,
125
+ /** How a chosen date is written into the field. ISO 8601 unless told otherwise. */
126
+ format?: (date: PlainDate) => string = isoFormat,
127
+ isDateDisabled?: (date: PlainDate) => boolean,
128
+ locale?: string,
129
+ onOpenChange?: (open: boolean) => void,
130
+ onValueChange?: (value: PlainDate | null) => mixed,
131
+ open?: boolean,
132
+ /** How typed text becomes a date, or null when it is not one yet. */
133
+ parse?: (text: string) => PlainDate | null = isoParse,
134
+ today?: DateValue,
135
+ value?: DateValue | null,
136
+ weekStartsOn?: number,
137
+ ) {
138
+ const fieldRef = useRef<HTMLElement | null>(null);
139
+ const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
140
+
141
+ const controlled = useMemo(
142
+ () =>
143
+ value === undefined ? undefined : value === null ? null : Temporal.PlainDate.from(value),
144
+ [value],
145
+ );
146
+ const initial = useMemo(
147
+ () => (defaultValue == null ? null : Temporal.PlainDate.from(defaultValue)),
148
+ [defaultValue],
149
+ );
150
+ const report = useStableCallback((next: PlainDate | null) => {
151
+ onValueChange?.(next);
152
+ });
153
+ const [chosen, setChosen] = useControlled<PlainDate | null>(controlled, initial, report);
154
+
155
+ // The field's text is the *draft* while there is one, and the formatted value
156
+ // otherwise. Two pieces of state kept in step by an effect is the arrangement
157
+ // this avoids: an effect that copies the value into the field overwrites what
158
+ // the reader is halfway through typing, and one that does not runs stale the
159
+ // moment the caller sets a value from outside.
160
+ const [draft, setDraft] = useState<string | null>(null);
161
+ const [invalid, setInvalid] = useState(false);
162
+ const text = draft ?? (chosen == null ? "" : format(chosen));
163
+
164
+ const focusField = useStableCallback(() => {
165
+ fieldRef.current?.focus?.();
166
+ });
167
+
168
+ const commit = useStableCallback((typed: string) => {
169
+ if (typed.trim() === "") {
170
+ setChosen(null);
171
+ setDraft(null);
172
+ setInvalid(false);
173
+ return;
174
+ }
175
+ const parsed = parse(typed);
176
+ if (parsed == null) {
177
+ // The text stays. Clearing it would throw away what the reader typed and
178
+ // leave them nothing to correct.
179
+ setInvalid(true);
180
+ return;
181
+ }
182
+ setChosen(parsed);
183
+ setDraft(null);
184
+ setInvalid(false);
185
+ });
186
+
187
+ const choose = useStableCallback((date: PlainDate) => {
188
+ setChosen(date);
189
+ setDraft(null);
190
+ setInvalid(false);
191
+ // Before the popover closes, and that order is load-bearing: `Popover.Body`
192
+ // restores focus to its trigger only when focus would otherwise be lost, so
193
+ // moving it to the field first is what makes the field - and not the button
194
+ // - where the reader ends up.
195
+ focusField();
196
+ setOpen(false);
197
+ });
198
+
199
+ const dateSettings = useMemo(
200
+ () => ({ isDateDisabled, locale, today, weekStartsOn }),
201
+ [isDateDisabled, locale, today, weekStartsOn],
202
+ );
203
+
204
+ const state = useMemo(
205
+ () => ({
206
+ choose,
207
+ commit,
208
+ fieldRef,
209
+ focusField,
210
+ invalid,
211
+ setDraft,
212
+ text,
213
+ value: chosen,
214
+ }),
215
+ [choose, chosen, commit, focusField, invalid, text],
216
+ );
217
+
218
+ return (
219
+ <DatePickerContext.Provider value={state}>
220
+ <CalendarSettings.Provider value={dateSettings}>
221
+ <PopoverRoot onOpenChange={setOpen} open={isOpen}>
222
+ {children}
223
+ </PopoverRoot>
224
+ </CalendarSettings.Provider>
225
+ </DatePickerContext.Provider>
226
+ );
227
+ }
228
+
229
+ /** What `DatePicker.Root` was told about dates, for the calendar it renders. */
230
+ type CalendarSettingsValue = {|
231
+ readonly isDateDisabled: ((date: PlainDate) => boolean) | void,
232
+ readonly locale: string | void,
233
+ readonly today: DateValue | void,
234
+ readonly weekStartsOn: number | void,
235
+ |};
236
+
237
+ const CalendarSettings: React.Context<CalendarSettingsValue> = createContext({
238
+ isDateDisabled: undefined,
239
+ locale: undefined,
240
+ today: undefined,
241
+ weekStartsOn: undefined,
242
+ });
243
+
244
+ /**
245
+ * The text field, which is the control.
246
+ *
247
+ * An ordinary `<input type="text">` rather than `type="date"`: the native one is
248
+ * a different widget with its own popup, its own format and no way to be told
249
+ * which dates are unavailable, and wrapping it would leave two calendars in one
250
+ * control. It carries no `role`, no `aria-haspopup` and no `aria-expanded` — it
251
+ * does not open the popover, the button beside it does, and telling a reader the
252
+ * field expands something would be a promise the field does not keep.
253
+ */
254
+ export component DatePickerInput(...rest: Rest) {
255
+ const picker = useDatePicker("DatePicker.Input");
256
+ const passed = withoutComposed(rest, ["onBlur", "onChange", "onKeyDown", "ref"]);
257
+
258
+ return (
259
+ <input
260
+ {...passed}
261
+ aria-invalid={picker.invalid ? "true" : undefined}
262
+ onBlur={composeHandlers(rest.onBlur, (event) => {
263
+ picker.commit((event.currentTarget: $FlowFixMe).value);
264
+ })}
265
+ onChange={composeHandlers(rest.onChange, (event) => {
266
+ picker.setDraft((event.currentTarget: $FlowFixMe).value);
267
+ })}
268
+ onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
269
+ if (event.key !== "Enter") {
270
+ return;
271
+ }
272
+ // Claimed, so a date picker inside a form is not a control where
273
+ // pressing Enter to confirm what you typed submits the page instead.
274
+ event.preventDefault();
275
+ picker.commit((event.currentTarget: $FlowFixMe).value);
276
+ })}
277
+ ref={composeRefs(rest.ref, (element) => {
278
+ picker.fieldRef.current = element;
279
+ })}
280
+ type="text"
281
+ value={picker.text}
282
+ />
283
+ );
284
+ }
285
+
286
+ /** The button that opens the calendar. */
287
+ export component DatePickerTrigger(children: React.Node, ...rest: Rest) {
288
+ // `forwarded`, because this part renders another part rather than an
289
+ // intrinsic; `internal/merge-props.js` says what that costs and why.
290
+ return <PopoverTrigger {...forwarded(rest)}>{children}</PopoverTrigger>;
291
+ }
292
+
293
+ /**
294
+ * The calendar, in the popover, wired to the field.
295
+ *
296
+ * `children` is the calendar's own layout — the month buttons and
297
+ * `Calendar.Month` — because where those sit is a design decision and there is
298
+ * no arrangement of them this module could impose that would suit every one.
299
+ */
300
+ export component DatePickerCalendar(
301
+ children: React.Node,
302
+ align?: Align = "start",
303
+ side?: LogicalSide = "bottom",
304
+ sideOffset?: number = 0,
305
+ ...rest: Rest
306
+ ) {
307
+ const picker = useDatePicker("DatePicker.Calendar");
308
+ const settings = useContext(CalendarSettings);
309
+ const dayRef = useRef<HTMLElement | null>(null);
310
+ const passed = withoutComposed(rest, ["onKeyDown"]);
311
+
312
+ return (
313
+ <PopoverBody
314
+ {...forwarded(passed)}
315
+ align={align}
316
+ // The date, not the first button in the popover. The APG's date picker
317
+ // dialog puts focus on the grid for the same reason.
318
+ initialFocus={dayRef}
319
+ onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
320
+ if (event.key !== "Escape") {
321
+ return;
322
+ }
323
+ // Not prevented and not stopped: `Popover.Body`'s own handler is what
324
+ // closes it, and this only decides where focus lands afterwards. Moving
325
+ // it to the field first is what makes the popover's restore stand down;
326
+ // `choose` says why that is the order.
327
+ picker.focusField();
328
+ })}
329
+ side={side}
330
+ sideOffset={sideOffset}
331
+ >
332
+ <CalendarRoot
333
+ defaultFocused={picker.value ?? undefined}
334
+ focusedDayRef={dayRef}
335
+ isDateDisabled={settings.isDateDisabled}
336
+ locale={settings.locale}
337
+ onValueChange={picker.choose}
338
+ today={settings.today}
339
+ value={picker.value}
340
+ weekStartsOn={settings.weekStartsOn}
341
+ >
342
+ {children}
343
+ </CalendarRoot>
344
+ </PopoverBody>
345
+ );
346
+ }
package/hover-card.js CHANGED
@@ -43,7 +43,7 @@ import { createContext, useContext, useEffect, useId, useMemo, useRef } from "@u
43
43
  import { useEventListener } from "@uniflowed/hooks/dom";
44
44
  import { useStableCallback } from "@uniflowed/hooks/lifecycle";
45
45
 
46
- import type { Align, Side } from "./internal/anchor.js";
46
+ import type { Align, LogicalSide } from "./internal/anchor.js";
47
47
  import type { HoverIntent } from "./internal/hover-intent.js";
48
48
  import type { Rest } from "./internal/merge-props.js";
49
49
  import { composeRefs, withProps, withoutComposed } from "./internal/merge-props.js";
@@ -57,7 +57,7 @@ import {
57
57
  import { useAnchor } from "./internal/anchor.js";
58
58
  import { useControlled } from "./internal/controlled-state.js";
59
59
 
60
- export type { Align, Side } from "./internal/anchor.js";
60
+ export type { Align, LogicalSide, Side } from "./internal/anchor.js";
61
61
 
62
62
  type HoverCardState = {|
63
63
  readonly base: string,
@@ -213,7 +213,7 @@ export component HoverCardBody(
213
213
  alignOffset?: number = 0,
214
214
  avoidCollisions?: boolean = true,
215
215
  collisionPadding?: number = 0,
216
- side?: Side = "bottom",
216
+ side?: LogicalSide = "bottom",
217
217
  sideOffset?: number = 0,
218
218
  ...rest: Rest
219
219
  ) {