@uniflowed/ui 0.0.0-alpha.12 → 0.0.0-alpha.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/accordion.js +21 -3
- package/calendar.js +550 -0
- package/checkbox.js +188 -10
- package/collapsible.js +29 -12
- package/combobox.js +176 -5
- package/context-menu.js +198 -0
- package/date-picker.js +346 -0
- package/field.js +192 -25
- package/hover-card.js +3 -3
- package/index.js +196 -10
- package/internal/anchor.js +71 -6
- package/internal/date-grid.js +260 -0
- package/internal/disclosure.js +201 -0
- package/internal/menu-tree.js +228 -0
- package/menu.js +309 -163
- package/menubar.js +285 -0
- package/package.json +8 -3
- package/popover.js +21 -8
- package/resizable.js +149 -9
- package/select.js +29 -0
- package/switch.js +5 -3
- package/toggle.js +3 -2
- package/tooltip.js +3 -3
package/context-menu.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// The same menu, opened by the right button.
|
|
4
|
+
//
|
|
5
|
+
// Everything below the trigger is `menu.js` — the arrow keys, typeahead,
|
|
6
|
+
// submenus, `Escape` stacking, the roving tab stop and the two checkable item
|
|
7
|
+
// kinds — because a context menu *is* a menu and a second implementation of one
|
|
8
|
+
// would be a second set of keyboard bugs. What is here is the two things that
|
|
9
|
+
// make it a component rather than an `oncontextmenu` handler, and both of them
|
|
10
|
+
// are the parts people leave out.
|
|
11
|
+
//
|
|
12
|
+
// # It has to be reachable from the keyboard
|
|
13
|
+
//
|
|
14
|
+
// `Shift+F10` and the `ContextMenu` key open a context menu, on every platform,
|
|
15
|
+
// and a component that only listens for `contextmenu` is a WCAG 2.1.1 failure:
|
|
16
|
+
// the commands in it are reachable by pointer and by nothing else. Long press
|
|
17
|
+
// is the touch equivalent of the same gesture, and `@uniflowed/hooks/dom`'s
|
|
18
|
+
// `useLongPress` already knows what a long press is — including that a press
|
|
19
|
+
// that moves is a drag and not a press.
|
|
20
|
+
//
|
|
21
|
+
// The trigger is therefore focusable. That is a real cost and it is stated
|
|
22
|
+
// rather than hidden: a list of two hundred rows with a context menu on each is
|
|
23
|
+
// two hundred tab stops. A caller whose trigger already *contains* something
|
|
24
|
+
// focusable should pass `tabIndex={-1}` and let the keys arrive from inside it,
|
|
25
|
+
// which they do — the handler is on the trigger and the event bubbles. What is
|
|
26
|
+
// not on offer is leaving the keys out, because the alternative to a tab stop
|
|
27
|
+
// is a command a keyboard cannot reach.
|
|
28
|
+
//
|
|
29
|
+
// # It opens at a point, and sometimes at an element
|
|
30
|
+
//
|
|
31
|
+
// A context menu opened by the pointer belongs at the pointer — the reader is
|
|
32
|
+
// looking at their cursor, and a menu that appeared against the top-left corner
|
|
33
|
+
// of a table row is a menu they have to go and find. Opened by the keyboard
|
|
34
|
+
// there is no pointer, and the menu belongs against the element that has focus.
|
|
35
|
+
//
|
|
36
|
+
// So the anchor is a rectangle rather than an element, and
|
|
37
|
+
// `internal/anchor.js`'s `anchorRect` is the seam: the trigger element is still
|
|
38
|
+
// what the writing direction is read from and what focus goes back to, and only
|
|
39
|
+
// the *measurement* is replaced. `null` — which is what the keyboard path
|
|
40
|
+
// leaves behind — measures the trigger, so both routes end in one code path
|
|
41
|
+
// rather than two placements that drift.
|
|
42
|
+
//
|
|
43
|
+
// # The body is not named after the trigger
|
|
44
|
+
//
|
|
45
|
+
// `Menu.Body` names itself with `aria-labelledby` pointing at its trigger,
|
|
46
|
+
// because a dropdown menu's trigger is a button with a short label — "File" —
|
|
47
|
+
// and that is the menu's name. A context menu's trigger is arbitrary content: a
|
|
48
|
+
// table row, a canvas, a paragraph. Naming the menu after it would announce the
|
|
49
|
+
// whole row as the menu's name. So `ContextMenu.Trigger` registers itself as
|
|
50
|
+
// the thing focus returns to and *not* as a name, and the caller gives
|
|
51
|
+
// `ContextMenu.Body` an `aria-label`. That is the one attribute this component
|
|
52
|
+
// cannot supply and the reference page says so.
|
|
53
|
+
|
|
54
|
+
"use client";
|
|
55
|
+
|
|
56
|
+
import * as React from "@uniflowed/react";
|
|
57
|
+
import { useCallback, useContext, useMemo, useRef, useState } from "@uniflowed/react";
|
|
58
|
+
import { useLongPress } from "@uniflowed/hooks/dom";
|
|
59
|
+
|
|
60
|
+
import type { Rect } from "./internal/anchor.js";
|
|
61
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
62
|
+
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
63
|
+
import { MenuAnchorContext, MenuContext, MenuLevel, useMenu } from "./internal/menu-tree.js";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Where the pointer was, or nothing when the keyboard opened the menu.
|
|
67
|
+
*
|
|
68
|
+
* Held by the root rather than by the trigger because the *body* is what reads
|
|
69
|
+
* it, and the body is a sibling of the trigger rather than a child of it.
|
|
70
|
+
*/
|
|
71
|
+
type PointState = {|
|
|
72
|
+
readonly point: Rect | null,
|
|
73
|
+
readonly openAt: (point: Rect | null) => void,
|
|
74
|
+
|};
|
|
75
|
+
|
|
76
|
+
const PointContext: React.Context<PointState | null> = React.createContext(null);
|
|
77
|
+
|
|
78
|
+
/** A zero-sized box at a pointer's coordinates, which is what a point is. */
|
|
79
|
+
function pointAt(x: number, y: number): Rect {
|
|
80
|
+
return { height: 0, width: 0, x, y };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The trigger, the menu, and where the pointer was when it opened.
|
|
85
|
+
*
|
|
86
|
+
* Renders no element of its own, for the reason `Menu.Root` gives: the trigger
|
|
87
|
+
* and the body are siblings in whatever layout the caller wrote.
|
|
88
|
+
*/
|
|
89
|
+
export component ContextMenuRoot(
|
|
90
|
+
children: React.Node,
|
|
91
|
+
defaultOpen?: boolean = false,
|
|
92
|
+
open?: boolean,
|
|
93
|
+
onOpenChange?: (open: boolean) => void,
|
|
94
|
+
) {
|
|
95
|
+
// State rather than a ref, and that is load-bearing: the rectangle is one of
|
|
96
|
+
// the things the placement effect re-runs for, so a second right-click
|
|
97
|
+
// somewhere else has to be a new value React has committed rather than a
|
|
98
|
+
// mutation nothing heard about.
|
|
99
|
+
const [point, setPoint] = useState<Rect | null>(null);
|
|
100
|
+
const openAt = useCallback((next: Rect | null) => setPoint(next), []);
|
|
101
|
+
const state = useMemo(() => ({ point, openAt }), [point, openAt]);
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<PointContext.Provider value={state}>
|
|
105
|
+
<MenuAnchorContext.Provider value={point}>
|
|
106
|
+
<MenuLevel defaultOpen={defaultOpen} onOpenChange={onOpenChange} open={open} parent={null}>
|
|
107
|
+
{children}
|
|
108
|
+
</MenuLevel>
|
|
109
|
+
</MenuAnchorContext.Provider>
|
|
110
|
+
</PointContext.Provider>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
hook usePoint(part: string): PointState {
|
|
115
|
+
const state = useContext(PointContext);
|
|
116
|
+
if (state == null) {
|
|
117
|
+
throw new Error(`${part} must be rendered inside a ContextMenu.Root`);
|
|
118
|
+
}
|
|
119
|
+
return state;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The content the menu belongs to.
|
|
124
|
+
*
|
|
125
|
+
* A `<div>` rather than a button, because what a context menu hangs off is a
|
|
126
|
+
* region of the page. The module header says why it is in the tab order and
|
|
127
|
+
* when a caller should take it out again.
|
|
128
|
+
*/
|
|
129
|
+
export component ContextMenuTrigger(children: React.Node, ...rest: Rest) {
|
|
130
|
+
const menu = useMenu("ContextMenu.Trigger");
|
|
131
|
+
const { openAt } = usePoint("ContextMenu.Trigger");
|
|
132
|
+
const triggerRef = useRef<HTMLElement | null>(null);
|
|
133
|
+
const passed = withoutComposed(rest, ["onContextMenu", "onKeyDown", "ref"]);
|
|
134
|
+
|
|
135
|
+
const openHere = useCallback(() => {
|
|
136
|
+
// No point: the menu goes against the element, which is where the reader's
|
|
137
|
+
// focus already is.
|
|
138
|
+
openAt(null);
|
|
139
|
+
menu.pendingFocus.current = "first";
|
|
140
|
+
menu.setOpen(true);
|
|
141
|
+
}, [menu, openAt]);
|
|
142
|
+
|
|
143
|
+
// The touch equivalent of the right button. `useLongPress` cancels itself
|
|
144
|
+
// when the pointer moves, so a drag across a list is not two hundred menus.
|
|
145
|
+
useLongPress(triggerRef, (event: Event) => {
|
|
146
|
+
const pointer: $FlowFixMe = event;
|
|
147
|
+
openAt(pointAt(pointer.clientX ?? 0, pointer.clientY ?? 0));
|
|
148
|
+
menu.pendingFocus.current = "first";
|
|
149
|
+
menu.setOpen(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div
|
|
154
|
+
// Above the spread, alone, because it is the one attribute here a caller
|
|
155
|
+
// is invited to overrule: the module header promises `tabIndex={-1}` to a
|
|
156
|
+
// caller whose trigger already contains something focusable, and a prop
|
|
157
|
+
// written *after* `{...passed}` wins over the caller's silently — which
|
|
158
|
+
// is a documented escape hatch that does nothing. Everything below the
|
|
159
|
+
// spread is this component's own and stays there.
|
|
160
|
+
tabIndex={0}
|
|
161
|
+
{...passed}
|
|
162
|
+
aria-haspopup="menu"
|
|
163
|
+
id={`${menu.base}-trigger`}
|
|
164
|
+
onContextMenu={composeHandlers(rest.onContextMenu, (event) => {
|
|
165
|
+
const press: $FlowFixMe = event;
|
|
166
|
+
// The browser's own menu would otherwise cover this one, and the reader
|
|
167
|
+
// would be looking at the platform's Back/Reload rather than at the
|
|
168
|
+
// commands the page has for what they pressed on.
|
|
169
|
+
press.preventDefault();
|
|
170
|
+
openAt(pointAt(press.clientX ?? 0, press.clientY ?? 0));
|
|
171
|
+
menu.pendingFocus.current = "first";
|
|
172
|
+
menu.setOpen(true);
|
|
173
|
+
})}
|
|
174
|
+
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
175
|
+
// Both spellings. `ContextMenu` is the dedicated key on a PC keyboard;
|
|
176
|
+
// `Shift+F10` is the one every platform has, and is what a laptop
|
|
177
|
+
// without that key leaves a reader with.
|
|
178
|
+
const asked =
|
|
179
|
+
event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey === true);
|
|
180
|
+
if (!asked) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
event.preventDefault();
|
|
184
|
+
openHere();
|
|
185
|
+
})}
|
|
186
|
+
ref={composeRefs(rest.ref, (element) => {
|
|
187
|
+
triggerRef.current = element;
|
|
188
|
+
// What focus goes back to when the menu closes. It is deliberately not
|
|
189
|
+
// registered as the menu's *name*; see the module header.
|
|
190
|
+
menu.triggerRef.current = element;
|
|
191
|
+
})}
|
|
192
|
+
>
|
|
193
|
+
{children}
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export type { MenuSelect } from "./menu.js";
|
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
|
+
}
|