@byline/ui 4.14.1 → 4.15.0
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/dist/widgets/datepicker/datepicker.d.ts +42 -1
- package/dist/widgets/datepicker/datepicker.js +34 -4
- package/dist/widgets/datepicker/datepicker.module.js +1 -0
- package/dist/widgets/datepicker/datepicker_module.css +4 -1
- package/dist/widgets/modal/modal-context.d.ts +19 -0
- package/dist/widgets/modal/modal-context.js +4 -0
- package/dist/widgets/modal/modal-header.d.ts +1 -1
- package/dist/widgets/modal/modal-header.js +23 -1
- package/dist/widgets/modal/modal.d.ts +9 -4
- package/dist/widgets/modal/modal.js +8 -4
- package/package.json +1 -1
- package/src/widgets/datepicker/datepicker.module.css +10 -1
- package/src/widgets/datepicker/datepicker.tsx +97 -9
- package/src/widgets/modal/modal-context.ts +23 -0
- package/src/widgets/modal/modal-header.tsx +29 -2
- package/src/widgets/modal/modal.tsx +15 -6
|
@@ -6,6 +6,23 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type React from 'react';
|
|
8
8
|
import type { Intent, Size, Variant } from '../../components/inputs/@types/input.js';
|
|
9
|
+
/**
|
|
10
|
+
* The selection as the editor made it: a calendar day and a clock reading,
|
|
11
|
+
* with no instant attached.
|
|
12
|
+
*
|
|
13
|
+
* `onDateChange` reports a `Date`, which is an instant, and building one from a
|
|
14
|
+
* wall time goes through `setHours` — so on the two days a year the clocks
|
|
15
|
+
* change, the instant is not the time that was picked. A spring-forward 02:30
|
|
16
|
+
* comes back as 03:30, and an ambiguous autumn 01:30 silently resolves to the
|
|
17
|
+
* earlier of its two instants. Callers that must reject or disambiguate those
|
|
18
|
+
* cases need the wall time itself, which is what this carries.
|
|
19
|
+
*/
|
|
20
|
+
export interface DatePickerWallTime {
|
|
21
|
+
/** Calendar day as `YYYY-MM-DD`. */
|
|
22
|
+
date: string;
|
|
23
|
+
/** Clock reading as `HH:mm`, 24-hour. */
|
|
24
|
+
time: string;
|
|
25
|
+
}
|
|
9
26
|
export interface DatePickerProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
10
27
|
id: string;
|
|
11
28
|
name: string;
|
|
@@ -13,6 +30,23 @@ export interface DatePickerProps extends React.InputHTMLAttributes<HTMLInputElem
|
|
|
13
30
|
required?: boolean;
|
|
14
31
|
initialValue?: Date | null;
|
|
15
32
|
mode?: 'date' | 'datetime';
|
|
33
|
+
/**
|
|
34
|
+
* Earliest selectable day. Days before it are disabled in the calendar and
|
|
35
|
+
* the month navigation will not go back past its month.
|
|
36
|
+
*
|
|
37
|
+
* Day granularity only — the day containing `minDate` stays selectable in
|
|
38
|
+
* full, and in `datetime` mode every slot in the time list remains offered.
|
|
39
|
+
* A caller that needs a cutoff finer than a day has to enforce it itself.
|
|
40
|
+
*/
|
|
41
|
+
minDate?: Date;
|
|
42
|
+
/**
|
|
43
|
+
* Latest selectable day, the mirror of `minDate`: days after it are disabled
|
|
44
|
+
* and the month navigation will not go forward past its month. Takes
|
|
45
|
+
* precedence over `yearsInFuture`, which otherwise sets the upper bound.
|
|
46
|
+
*
|
|
47
|
+
* Day granularity only, on the same terms as `minDate`.
|
|
48
|
+
*/
|
|
49
|
+
maxDate?: Date;
|
|
16
50
|
yearsInFuture?: number;
|
|
17
51
|
yearsInPast?: number;
|
|
18
52
|
variant?: Variant;
|
|
@@ -28,10 +62,17 @@ export interface DatePickerProps extends React.InputHTMLAttributes<HTMLInputElem
|
|
|
28
62
|
ariaLabelForClear?: string;
|
|
29
63
|
onClear?: () => void;
|
|
30
64
|
onDateChange?: (value: Date | null) => void;
|
|
65
|
+
/**
|
|
66
|
+
* Fired alongside `onDateChange` with the day and clock reading the editor
|
|
67
|
+
* actually selected, before any instant is derived from them. Optional and
|
|
68
|
+
* additive — callers that are happy with the `Date` can ignore it entirely.
|
|
69
|
+
* Reports `null` when the selection is cleared.
|
|
70
|
+
*/
|
|
71
|
+
onWallTimeChange?: (wall: DatePickerWallTime | null) => void;
|
|
31
72
|
validatorFn?: (value: Date) => {
|
|
32
73
|
valid: boolean;
|
|
33
74
|
value: Date;
|
|
34
75
|
};
|
|
35
76
|
placeHolderText?: string;
|
|
36
77
|
}
|
|
37
|
-
export declare function DatePicker({ id, name, label, required, initialValue, mode, yearsInFuture, yearsInPast, variant, intent, inputSize, inputClassName, inputWrapperClassName, containerClassName, contentClassName, onClear, onDateChange, validatorFn, helpText, errorText, placeHolderText, ariaLabelForSearch, ariaLabelForClear, ...rest }: DatePickerProps): React.JSX.Element;
|
|
78
|
+
export declare function DatePicker({ id, name, label, required, initialValue, mode, minDate, maxDate, yearsInFuture, yearsInPast, variant, intent, inputSize, inputClassName, inputWrapperClassName, containerClassName, contentClassName, onClear, onDateChange, onWallTimeChange, validatorFn, helpText, errorText, placeHolderText, ariaLabelForSearch, ariaLabelForClear, ...rest }: DatePickerProps): React.JSX.Element;
|
|
@@ -13,14 +13,14 @@ import { ScrollArea } from "../../components/scroll-area/scroll-area.js";
|
|
|
13
13
|
import { CalendarIcon } from "../../icons/calendar-icon.js";
|
|
14
14
|
import { CloseIcon } from "../../icons/close-icon.js";
|
|
15
15
|
import datepicker_module from "./datepicker.module.js";
|
|
16
|
-
function DatePicker({ id, name, label, required, initialValue, mode = 'datetime', yearsInFuture = 1, yearsInPast = 10, variant, intent, inputSize, inputClassName, inputWrapperClassName, containerClassName, contentClassName, onClear = ()=>{}, onDateChange = ()=>{}, validatorFn, helpText, errorText, placeHolderText = '', ariaLabelForSearch = 'date', ariaLabelForClear = 'clear', ...rest }) {
|
|
16
|
+
function DatePicker({ id, name, label, required, initialValue, mode = 'datetime', minDate, maxDate, yearsInFuture = 1, yearsInPast = 10, variant, intent, inputSize, inputClassName, inputWrapperClassName, containerClassName, contentClassName, onClear = ()=>{}, onDateChange = ()=>{}, onWallTimeChange, validatorFn, helpText, errorText, placeHolderText = '', ariaLabelForSearch = 'date', ariaLabelForClear = 'clear', ...rest }) {
|
|
17
17
|
const [isOpen, setIsOpen] = useState(false);
|
|
18
|
-
const [time, setTime] = useState('08:00');
|
|
19
18
|
const [date, setDate] = useState(()=>{
|
|
20
19
|
if (initialValue) return initialValue;
|
|
21
20
|
if (null == initialValue && true === required) return new Date();
|
|
22
21
|
return null;
|
|
23
22
|
});
|
|
23
|
+
const [time, setTime] = useState(()=>null != date ? format(date, 'HH:mm') : '08:00');
|
|
24
24
|
const [month, setMonth] = useState(date);
|
|
25
25
|
const calendarRef = useRef(null);
|
|
26
26
|
const inputRef = useRef(null);
|
|
@@ -29,11 +29,31 @@ function DatePicker({ id, name, label, required, initialValue, mode = 'datetime'
|
|
|
29
29
|
if (inputRef?.current != null) inputRef.current.value = '';
|
|
30
30
|
setDate(null);
|
|
31
31
|
onDateChange(null);
|
|
32
|
+
emitWallTime(null, time);
|
|
32
33
|
onClear();
|
|
33
34
|
};
|
|
34
35
|
const handleOnDateChange = (value)=>{
|
|
35
36
|
if (null != onDateChange && 'function' == typeof onDateChange) onDateChange(value);
|
|
36
37
|
};
|
|
38
|
+
const emitWallTime = (day, clock)=>{
|
|
39
|
+
if (null == onWallTimeChange) return;
|
|
40
|
+
onWallTimeChange(null == day ? null : {
|
|
41
|
+
date: format(day, 'yyyy-MM-dd'),
|
|
42
|
+
time: clock
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
const disabledDays = [
|
|
46
|
+
...null == minDate ? [] : [
|
|
47
|
+
{
|
|
48
|
+
before: minDate
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
...null == maxDate ? [] : [
|
|
52
|
+
{
|
|
53
|
+
after: maxDate
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
];
|
|
37
57
|
const handleOnKeyDown = (e)=>{
|
|
38
58
|
if ('ArrowDown' === e.key) {
|
|
39
59
|
e.preventDefault();
|
|
@@ -44,6 +64,7 @@ function DatePicker({ id, name, label, required, initialValue, mode = 'datetime'
|
|
|
44
64
|
if (null == initialValue && null != date && true === required && false === hasInitialized.current) {
|
|
45
65
|
hasInitialized.current = true;
|
|
46
66
|
onDateChange(date);
|
|
67
|
+
emitWallTime(date, time);
|
|
47
68
|
}
|
|
48
69
|
});
|
|
49
70
|
return /*#__PURE__*/ jsxs("div", {
|
|
@@ -134,6 +155,7 @@ function DatePicker({ id, name, label, required, initialValue, mode = 'datetime'
|
|
|
134
155
|
/*#__PURE__*/ jsx(Popover.Portal, {
|
|
135
156
|
children: /*#__PURE__*/ jsx(Popover.Positioner, {
|
|
136
157
|
sideOffset: 5,
|
|
158
|
+
className: clsx('byline-datepicker-positioner', datepicker_module.positioner),
|
|
137
159
|
children: /*#__PURE__*/ jsxs(Popover.Popup, {
|
|
138
160
|
className: clsx('byline-datepicker-content', datepicker_module.content, contentClassName),
|
|
139
161
|
children: [
|
|
@@ -151,15 +173,18 @@ function DatePicker({ id, name, label, required, initialValue, mode = 'datetime'
|
|
|
151
173
|
onMonthChange: setMonth,
|
|
152
174
|
onSelect: (selectedDate)=>{
|
|
153
175
|
if (selectedDate) {
|
|
176
|
+
const day = new Date(selectedDate.getTime());
|
|
154
177
|
const [hours, minutes] = time.split(':');
|
|
155
178
|
selectedDate.setHours(Number.parseInt(hours, 10), Number.parseInt(minutes, 10));
|
|
156
179
|
setDate(selectedDate);
|
|
157
180
|
setMonth(selectedDate);
|
|
158
181
|
handleOnDateChange(selectedDate);
|
|
182
|
+
emitWallTime(day, time);
|
|
159
183
|
}
|
|
160
184
|
},
|
|
161
|
-
|
|
162
|
-
|
|
185
|
+
disabled: disabledDays.length > 0 ? disabledDays : void 0,
|
|
186
|
+
startMonth: minDate ?? new Date(new Date().getFullYear() - yearsInPast, 0),
|
|
187
|
+
endMonth: maxDate ?? new Date(new Date().getFullYear() + yearsInFuture, 0)
|
|
163
188
|
})
|
|
164
189
|
}),
|
|
165
190
|
'datetime' === mode && /*#__PURE__*/ jsx("div", {
|
|
@@ -185,6 +210,7 @@ function DatePicker({ id, name, label, required, initialValue, mode = 'datetime'
|
|
|
185
210
|
newDate.setHours(Number.parseInt(hour, 10), Number.parseInt(minute, 10), 0);
|
|
186
211
|
setDate(newDate);
|
|
187
212
|
handleOnDateChange(newDate);
|
|
213
|
+
emitWallTime(date, timeValue);
|
|
188
214
|
}
|
|
189
215
|
},
|
|
190
216
|
children: timeValue
|
|
@@ -212,9 +238,12 @@ function DatePicker({ id, name, label, required, initialValue, mode = 'datetime'
|
|
|
212
238
|
className: clsx('byline-datepicker-content-actions-button', datepicker_module["content-actions-button"]),
|
|
213
239
|
onClick: ()=>{
|
|
214
240
|
const today = new Date();
|
|
241
|
+
const clock = format(today, 'HH:mm');
|
|
215
242
|
setDate(today);
|
|
216
243
|
setMonth(today);
|
|
244
|
+
setTime(clock);
|
|
217
245
|
handleOnDateChange(today);
|
|
246
|
+
emitWallTime(today, clock);
|
|
218
247
|
},
|
|
219
248
|
children: "Today"
|
|
220
249
|
})
|
|
@@ -241,6 +270,7 @@ function DatePicker({ id, name, label, required, initialValue, mode = 'datetime'
|
|
|
241
270
|
onClick: ()=>{
|
|
242
271
|
setIsOpen(false);
|
|
243
272
|
handleOnDateChange(date);
|
|
273
|
+
emitWallTime(date, time);
|
|
244
274
|
},
|
|
245
275
|
children: "Select"
|
|
246
276
|
})
|
|
@@ -4,6 +4,7 @@ const datepicker_module = {
|
|
|
4
4
|
input: "input-SaODq9",
|
|
5
5
|
"input-wrapper": "input-wrapper-qwAckv",
|
|
6
6
|
inputWrapper: "input-wrapper-qwAckv",
|
|
7
|
+
positioner: "positioner-l698qq",
|
|
7
8
|
content: "content-UK9iLF",
|
|
8
9
|
slideDownAndFade: "slideDownAndFade-dohHTE",
|
|
9
10
|
slideLeftAndFade: "slideLeftAndFade-ugM6Hs",
|
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
width: 100%;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
:is(.positioner-l698qq, .byline-datepicker-positioner) {
|
|
13
|
+
z-index: var(--z-index-popover);
|
|
14
|
+
}
|
|
15
|
+
|
|
12
16
|
:is(.content-UK9iLF, .byline-datepicker-content) {
|
|
13
17
|
width: 100%;
|
|
14
|
-
z-index: var(--z-index-popover);
|
|
15
18
|
padding-top: var(--spacing-16);
|
|
16
19
|
padding-bottom: var(--spacing-8);
|
|
17
20
|
padding-left: var(--spacing-8);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared between `Modal` and its slots. Lives in its own module because
|
|
3
|
+
* `Modal` renders `Modal.Header` while `Modal.Header` needs the context —
|
|
4
|
+
* importing it from `modal.tsx` would make the two modules circular.
|
|
5
|
+
*/
|
|
6
|
+
export declare const ModalContext: import("react").Context<{
|
|
7
|
+
onDismiss?: () => void;
|
|
8
|
+
/**
|
|
9
|
+
* Id the dialog points `aria-labelledby` at. `Modal.Header` puts it on the
|
|
10
|
+
* heading it finds among its children, which is what gives the dialog its
|
|
11
|
+
* accessible name — without it a screen reader announces every modal in the
|
|
12
|
+
* admin as an unnamed "dialog".
|
|
13
|
+
*
|
|
14
|
+
* A dangling reference degrades safely: name computation ignores an
|
|
15
|
+
* `aria-labelledby` that resolves to nothing and falls through, so a modal
|
|
16
|
+
* with no header is no worse off than before.
|
|
17
|
+
*/
|
|
18
|
+
headingId?: string;
|
|
19
|
+
}>;
|
|
@@ -2,7 +2,29 @@
|
|
|
2
2
|
import { jsx } from "react/jsx-runtime";
|
|
3
3
|
import clsx from "clsx";
|
|
4
4
|
import modal_module from "./modal.module.js";
|
|
5
|
+
import { ModalContext } from "./modal-context.js";
|
|
6
|
+
import * as __rspack_external_react from "react";
|
|
7
|
+
const HEADING_TAGS = new Set([
|
|
8
|
+
'h1',
|
|
9
|
+
'h2',
|
|
10
|
+
'h3',
|
|
11
|
+
'h4',
|
|
12
|
+
'h5',
|
|
13
|
+
'h6'
|
|
14
|
+
]);
|
|
15
|
+
function labelHeading(children, headingId) {
|
|
16
|
+
let labelled = false;
|
|
17
|
+
return __rspack_external_react.Children.map(children, (child)=>{
|
|
18
|
+
if (labelled || !/*#__PURE__*/ __rspack_external_react.isValidElement(child)) return child;
|
|
19
|
+
if ('string' != typeof child.type || !HEADING_TAGS.has(child.type)) return child;
|
|
20
|
+
labelled = true;
|
|
21
|
+
return /*#__PURE__*/ __rspack_external_react.cloneElement(child, {
|
|
22
|
+
id: headingId
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
5
26
|
const modal_header_ModalHeader = function({ ref, children, className, ...rest }) {
|
|
27
|
+
const { headingId } = (0, __rspack_external_react.useContext)(ModalContext);
|
|
6
28
|
const classes = clsx('byline-modal-header', modal_module["modal-header"], 'prose', className);
|
|
7
29
|
return /*#__PURE__*/ jsx("div", {
|
|
8
30
|
style: {
|
|
@@ -11,7 +33,7 @@ const modal_header_ModalHeader = function({ ref, children, className, ...rest })
|
|
|
11
33
|
ref: ref,
|
|
12
34
|
...rest,
|
|
13
35
|
className: classes,
|
|
14
|
-
children: children
|
|
36
|
+
children: null == headingId ? children : labelHeading(children, headingId)
|
|
15
37
|
});
|
|
16
38
|
};
|
|
17
39
|
export { modal_header_ModalHeader as ModalHeader };
|
|
@@ -2,16 +2,21 @@ import type React from 'react';
|
|
|
2
2
|
import { ModalActions } from './modal-actions';
|
|
3
3
|
import { ModalContainer } from './modal-container';
|
|
4
4
|
import { ModalContent } from './modal-content';
|
|
5
|
+
import { ModalContext } from './modal-context.js';
|
|
5
6
|
import { ModalHeader } from './modal-header';
|
|
7
|
+
export { ModalContext };
|
|
6
8
|
export interface ModalProps {
|
|
7
9
|
isOpen?: boolean;
|
|
8
10
|
onDismiss?: () => void;
|
|
9
11
|
closeOnOverlayClick?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Accessible name for the dialog, for modals that carry no `Modal.Header`
|
|
14
|
+
* heading to borrow one from. Modals that do have a heading need nothing
|
|
15
|
+
* here — see `ModalContext.headingId`.
|
|
16
|
+
*/
|
|
17
|
+
ariaLabel?: string;
|
|
10
18
|
children?: React.ReactNode;
|
|
11
19
|
}
|
|
12
|
-
export declare const ModalContext: React.Context<{
|
|
13
|
-
onDismiss?: () => void;
|
|
14
|
-
}>;
|
|
15
20
|
export type UseModalProps = ReturnType<typeof useModal>;
|
|
16
21
|
export declare function useModal(): {
|
|
17
22
|
onDismiss: () => void;
|
|
@@ -19,7 +24,7 @@ export declare function useModal(): {
|
|
|
19
24
|
isOpen: boolean;
|
|
20
25
|
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
|
21
26
|
};
|
|
22
|
-
declare function Modal({ isOpen, onDismiss, closeOnOverlayClick, children, }: ModalProps): React.JSX.Element;
|
|
27
|
+
declare function Modal({ isOpen, onDismiss, closeOnOverlayClick, ariaLabel, children, }: ModalProps): React.JSX.Element;
|
|
23
28
|
declare namespace Modal {
|
|
24
29
|
var displayName: string;
|
|
25
30
|
export { ModalContainer as Container };
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
-
import {
|
|
3
|
+
import { useCallback, useId, useRef, useState } from "react";
|
|
4
4
|
import { Dialog } from "@base-ui/react/dialog";
|
|
5
5
|
import clsx from "clsx";
|
|
6
6
|
import modal_module from "./modal.module.js";
|
|
7
7
|
import { ModalActions } from "./modal-actions.js";
|
|
8
8
|
import { ModalContainer } from "./modal-container.js";
|
|
9
9
|
import { ModalContent } from "./modal-content.js";
|
|
10
|
+
import { ModalContext } from "./modal-context.js";
|
|
10
11
|
import { ModalHeader } from "./modal-header.js";
|
|
11
|
-
const ModalContext = /*#__PURE__*/ createContext({});
|
|
12
12
|
function useModal() {
|
|
13
13
|
const [isOpen, setIsOpen] = useState(false);
|
|
14
14
|
const onDismiss = useCallback(()=>{
|
|
@@ -24,7 +24,8 @@ function useModal() {
|
|
|
24
24
|
setIsOpen
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
|
-
function Modal({ isOpen, onDismiss, closeOnOverlayClick, children }) {
|
|
27
|
+
function Modal({ isOpen, onDismiss, closeOnOverlayClick, ariaLabel, children }) {
|
|
28
|
+
const headingId = useId();
|
|
28
29
|
const pressStartedOnOverlay = useRef(false);
|
|
29
30
|
const handleOverlayPointerDown = (event)=>{
|
|
30
31
|
pressStartedOnOverlay.current = event.target === event.currentTarget;
|
|
@@ -38,7 +39,8 @@ function Modal({ isOpen, onDismiss, closeOnOverlayClick, children }) {
|
|
|
38
39
|
};
|
|
39
40
|
return /*#__PURE__*/ jsx(ModalContext.Provider, {
|
|
40
41
|
value: {
|
|
41
|
-
onDismiss
|
|
42
|
+
onDismiss,
|
|
43
|
+
headingId
|
|
42
44
|
},
|
|
43
45
|
children: /*#__PURE__*/ jsx(Dialog.Root, {
|
|
44
46
|
open: isOpen,
|
|
@@ -54,6 +56,8 @@ function Modal({ isOpen, onDismiss, closeOnOverlayClick, children }) {
|
|
|
54
56
|
}),
|
|
55
57
|
/*#__PURE__*/ jsx(Dialog.Popup, {
|
|
56
58
|
className: clsx('byline-modal-wrapper', modal_module["modal-wrapper"]),
|
|
59
|
+
"aria-label": ariaLabel,
|
|
60
|
+
"aria-labelledby": null == ariaLabel ? headingId : void 0,
|
|
57
61
|
onPointerDown: handleOverlayPointerDown,
|
|
58
62
|
onClick: handleOverlayClick,
|
|
59
63
|
children: children
|
package/package.json
CHANGED
|
@@ -21,10 +21,19 @@
|
|
|
21
21
|
width: 100%;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/* Base UI's Positioner is the absolutely-positioned element, so it is the
|
|
25
|
+
one that can take part in z-index stacking. The Popup inside it is
|
|
26
|
+
`position: static`, where a z-index is inert — which is why the calendar
|
|
27
|
+
lost to any positioned ancestor above it in the tree, most visibly by
|
|
28
|
+
rendering behind a Modal. */
|
|
29
|
+
.positioner,
|
|
30
|
+
:global(.byline-datepicker-positioner) {
|
|
31
|
+
z-index: var(--z-index-popover);
|
|
32
|
+
}
|
|
33
|
+
|
|
24
34
|
.content,
|
|
25
35
|
:global(.byline-datepicker-content) {
|
|
26
36
|
width: 100%;
|
|
27
|
-
z-index: var(--z-index-popover);
|
|
28
37
|
border-radius: 4px;
|
|
29
38
|
padding-top: var(--spacing-16);
|
|
30
39
|
padding-bottom: var(--spacing-8);
|
|
@@ -25,6 +25,24 @@ import { CloseIcon } from '../../icons/close-icon.js'
|
|
|
25
25
|
import styles from './datepicker.module.css'
|
|
26
26
|
import type { Intent, Size, Variant } from '../../components/inputs/@types/input.js'
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The selection as the editor made it: a calendar day and a clock reading,
|
|
30
|
+
* with no instant attached.
|
|
31
|
+
*
|
|
32
|
+
* `onDateChange` reports a `Date`, which is an instant, and building one from a
|
|
33
|
+
* wall time goes through `setHours` — so on the two days a year the clocks
|
|
34
|
+
* change, the instant is not the time that was picked. A spring-forward 02:30
|
|
35
|
+
* comes back as 03:30, and an ambiguous autumn 01:30 silently resolves to the
|
|
36
|
+
* earlier of its two instants. Callers that must reject or disambiguate those
|
|
37
|
+
* cases need the wall time itself, which is what this carries.
|
|
38
|
+
*/
|
|
39
|
+
export interface DatePickerWallTime {
|
|
40
|
+
/** Calendar day as `YYYY-MM-DD`. */
|
|
41
|
+
date: string
|
|
42
|
+
/** Clock reading as `HH:mm`, 24-hour. */
|
|
43
|
+
time: string
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
export interface DatePickerProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
29
47
|
id: string
|
|
30
48
|
name: string
|
|
@@ -32,6 +50,23 @@ export interface DatePickerProps extends React.InputHTMLAttributes<HTMLInputElem
|
|
|
32
50
|
required?: boolean
|
|
33
51
|
initialValue?: Date | null
|
|
34
52
|
mode?: 'date' | 'datetime'
|
|
53
|
+
/**
|
|
54
|
+
* Earliest selectable day. Days before it are disabled in the calendar and
|
|
55
|
+
* the month navigation will not go back past its month.
|
|
56
|
+
*
|
|
57
|
+
* Day granularity only — the day containing `minDate` stays selectable in
|
|
58
|
+
* full, and in `datetime` mode every slot in the time list remains offered.
|
|
59
|
+
* A caller that needs a cutoff finer than a day has to enforce it itself.
|
|
60
|
+
*/
|
|
61
|
+
minDate?: Date
|
|
62
|
+
/**
|
|
63
|
+
* Latest selectable day, the mirror of `minDate`: days after it are disabled
|
|
64
|
+
* and the month navigation will not go forward past its month. Takes
|
|
65
|
+
* precedence over `yearsInFuture`, which otherwise sets the upper bound.
|
|
66
|
+
*
|
|
67
|
+
* Day granularity only, on the same terms as `minDate`.
|
|
68
|
+
*/
|
|
69
|
+
maxDate?: Date
|
|
35
70
|
yearsInFuture?: number
|
|
36
71
|
yearsInPast?: number
|
|
37
72
|
variant?: Variant
|
|
@@ -47,6 +82,13 @@ export interface DatePickerProps extends React.InputHTMLAttributes<HTMLInputElem
|
|
|
47
82
|
ariaLabelForClear?: string
|
|
48
83
|
onClear?: () => void
|
|
49
84
|
onDateChange?: (value: Date | null) => void
|
|
85
|
+
/**
|
|
86
|
+
* Fired alongside `onDateChange` with the day and clock reading the editor
|
|
87
|
+
* actually selected, before any instant is derived from them. Optional and
|
|
88
|
+
* additive — callers that are happy with the `Date` can ignore it entirely.
|
|
89
|
+
* Reports `null` when the selection is cleared.
|
|
90
|
+
*/
|
|
91
|
+
onWallTimeChange?: (wall: DatePickerWallTime | null) => void
|
|
50
92
|
validatorFn?: (value: Date) => {
|
|
51
93
|
valid: boolean
|
|
52
94
|
value: Date
|
|
@@ -61,6 +103,8 @@ export function DatePicker({
|
|
|
61
103
|
required,
|
|
62
104
|
initialValue,
|
|
63
105
|
mode = 'datetime',
|
|
106
|
+
minDate,
|
|
107
|
+
maxDate,
|
|
64
108
|
yearsInFuture = 1,
|
|
65
109
|
yearsInPast = 10,
|
|
66
110
|
variant,
|
|
@@ -72,6 +116,7 @@ export function DatePicker({
|
|
|
72
116
|
contentClassName,
|
|
73
117
|
onClear = () => {},
|
|
74
118
|
onDateChange = () => {},
|
|
119
|
+
onWallTimeChange,
|
|
75
120
|
validatorFn,
|
|
76
121
|
helpText,
|
|
77
122
|
errorText,
|
|
@@ -81,7 +126,6 @@ export function DatePicker({
|
|
|
81
126
|
...rest
|
|
82
127
|
}: DatePickerProps): React.JSX.Element {
|
|
83
128
|
const [isOpen, setIsOpen] = useState(false)
|
|
84
|
-
const [time, setTime] = useState<string>('08:00')
|
|
85
129
|
const [date, setDate] = useState<Date | null>(() => {
|
|
86
130
|
if (initialValue) {
|
|
87
131
|
return initialValue
|
|
@@ -91,6 +135,11 @@ export function DatePicker({
|
|
|
91
135
|
}
|
|
92
136
|
return null
|
|
93
137
|
})
|
|
138
|
+
// Seeded from the incoming date so the clock the widget holds agrees with the
|
|
139
|
+
// date it is showing. Declared after `date` for that reason. Previously this
|
|
140
|
+
// always started at 08:00, so picking a different day on a picker opened at
|
|
141
|
+
// 17:56 silently moved the value to 08:00.
|
|
142
|
+
const [time, setTime] = useState<string>(() => (date != null ? format(date, 'HH:mm') : '08:00'))
|
|
94
143
|
const [month, setMonth] = useState<Date | null>(date)
|
|
95
144
|
const calendarRef = useRef<HTMLDivElement | null>(null)
|
|
96
145
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
@@ -102,6 +151,7 @@ export function DatePicker({
|
|
|
102
151
|
}
|
|
103
152
|
setDate(null)
|
|
104
153
|
onDateChange(null)
|
|
154
|
+
emitWallTime(null, time)
|
|
105
155
|
onClear()
|
|
106
156
|
}
|
|
107
157
|
|
|
@@ -111,6 +161,26 @@ export function DatePicker({
|
|
|
111
161
|
}
|
|
112
162
|
}
|
|
113
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Report the selection as a wall time. `clock` is passed explicitly wherever
|
|
166
|
+
* the editor's chosen clock reading is known independently of `day`, because
|
|
167
|
+
* `day` has already been through `setHours` by then and cannot be trusted to
|
|
168
|
+
* still say what was picked.
|
|
169
|
+
*/
|
|
170
|
+
const emitWallTime = (day: Date | null, clock: string): void => {
|
|
171
|
+
if (onWallTimeChange == null) return
|
|
172
|
+
onWallTimeChange(day == null ? null : { date: format(day, 'yyyy-MM-dd'), time: clock })
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Two separate matchers, never one `{ before, after }` object: that shape is
|
|
176
|
+
// react-day-picker's DateInterval and matches the days *between* the two
|
|
177
|
+
// bounds — the exact inverse of an allowed window. An array of matchers is
|
|
178
|
+
// OR'd, so each bound disables its own side.
|
|
179
|
+
const disabledDays = [
|
|
180
|
+
...(minDate == null ? [] : [{ before: minDate }]),
|
|
181
|
+
...(maxDate == null ? [] : [{ after: maxDate }]),
|
|
182
|
+
]
|
|
183
|
+
|
|
114
184
|
const handleOnKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
|
115
185
|
if (e.key === 'ArrowDown') {
|
|
116
186
|
e.preventDefault()
|
|
@@ -132,6 +202,7 @@ export function DatePicker({
|
|
|
132
202
|
) {
|
|
133
203
|
hasInitialized.current = true
|
|
134
204
|
onDateChange(date)
|
|
205
|
+
emitWallTime(date, time)
|
|
135
206
|
}
|
|
136
207
|
})
|
|
137
208
|
|
|
@@ -205,7 +276,10 @@ export function DatePicker({
|
|
|
205
276
|
<span className="sr-only">Select date</span>
|
|
206
277
|
</Popover.Trigger>
|
|
207
278
|
<Popover.Portal>
|
|
208
|
-
<Popover.Positioner
|
|
279
|
+
<Popover.Positioner
|
|
280
|
+
sideOffset={5}
|
|
281
|
+
className={cx('byline-datepicker-positioner', styles.positioner)}
|
|
282
|
+
>
|
|
209
283
|
<Popover.Popup
|
|
210
284
|
className={cx('byline-datepicker-content', styles.content, contentClassName)}
|
|
211
285
|
>
|
|
@@ -222,6 +296,10 @@ export function DatePicker({
|
|
|
222
296
|
onMonthChange={setMonth}
|
|
223
297
|
onSelect={(selectedDate: Date) => {
|
|
224
298
|
if (selectedDate) {
|
|
299
|
+
// Read the day before `setHours` mutates it: the
|
|
300
|
+
// editor picked this day at the clock reading already
|
|
301
|
+
// held in `time`, and normalization can move both.
|
|
302
|
+
const day = new Date(selectedDate.getTime())
|
|
225
303
|
const [hours, minutes] = time.split(':')
|
|
226
304
|
selectedDate.setHours(
|
|
227
305
|
Number.parseInt(hours, 10),
|
|
@@ -230,15 +308,15 @@ export function DatePicker({
|
|
|
230
308
|
setDate(selectedDate)
|
|
231
309
|
setMonth(selectedDate)
|
|
232
310
|
handleOnDateChange(selectedDate)
|
|
311
|
+
emitWallTime(day, time)
|
|
233
312
|
}
|
|
234
313
|
}}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
// }
|
|
314
|
+
disabled={disabledDays.length > 0 ? disabledDays : undefined}
|
|
315
|
+
// Clamp the navigable range to the bounds as well as
|
|
316
|
+
// disabling the days, so the caller is not offered months
|
|
317
|
+
// in which nothing can be picked.
|
|
318
|
+
startMonth={minDate ?? new Date(new Date().getFullYear() - yearsInPast, 0)}
|
|
319
|
+
endMonth={maxDate ?? new Date(new Date().getFullYear() + yearsInFuture, 0)}
|
|
242
320
|
/>
|
|
243
321
|
</div>
|
|
244
322
|
{mode === 'datetime' && (
|
|
@@ -268,6 +346,10 @@ export function DatePicker({
|
|
|
268
346
|
)
|
|
269
347
|
setDate(newDate)
|
|
270
348
|
handleOnDateChange(newDate)
|
|
349
|
+
// `date`, not `newDate` — the grid label is
|
|
350
|
+
// what was clicked, and `newDate` may have
|
|
351
|
+
// been normalized away from it.
|
|
352
|
+
emitWallTime(date, timeValue)
|
|
271
353
|
}
|
|
272
354
|
}}
|
|
273
355
|
>
|
|
@@ -297,9 +379,14 @@ export function DatePicker({
|
|
|
297
379
|
)}
|
|
298
380
|
onClick={() => {
|
|
299
381
|
const today = new Date()
|
|
382
|
+
const clock = format(today, 'HH:mm')
|
|
300
383
|
setDate(today)
|
|
301
384
|
setMonth(today)
|
|
385
|
+
// Keep the held clock in step with the value, so a
|
|
386
|
+
// subsequent day pick preserves this time.
|
|
387
|
+
setTime(clock)
|
|
302
388
|
handleOnDateChange(today)
|
|
389
|
+
emitWallTime(today, clock)
|
|
303
390
|
}}
|
|
304
391
|
>
|
|
305
392
|
Today
|
|
@@ -329,6 +416,7 @@ export function DatePicker({
|
|
|
329
416
|
onClick={() => {
|
|
330
417
|
setIsOpen(false)
|
|
331
418
|
handleOnDateChange(date)
|
|
419
|
+
emitWallTime(date, time)
|
|
332
420
|
}}
|
|
333
421
|
>
|
|
334
422
|
Select
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { createContext } from 'react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared between `Modal` and its slots. Lives in its own module because
|
|
7
|
+
* `Modal` renders `Modal.Header` while `Modal.Header` needs the context —
|
|
8
|
+
* importing it from `modal.tsx` would make the two modules circular.
|
|
9
|
+
*/
|
|
10
|
+
export const ModalContext = createContext<{
|
|
11
|
+
onDismiss?: () => void
|
|
12
|
+
/**
|
|
13
|
+
* Id the dialog points `aria-labelledby` at. `Modal.Header` puts it on the
|
|
14
|
+
* heading it finds among its children, which is what gives the dialog its
|
|
15
|
+
* accessible name — without it a screen reader announces every modal in the
|
|
16
|
+
* admin as an unnamed "dialog".
|
|
17
|
+
*
|
|
18
|
+
* A dangling reference degrades safely: name computation ignores an
|
|
19
|
+
* `aria-labelledby` that resolves to nothing and falls through, so a modal
|
|
20
|
+
* with no header is no worse off than before.
|
|
21
|
+
*/
|
|
22
|
+
headingId?: string
|
|
23
|
+
}>({})
|
|
@@ -1,16 +1,42 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { useContext } from 'react'
|
|
4
5
|
|
|
5
6
|
import cx from 'clsx'
|
|
6
7
|
|
|
7
8
|
import styles from './modal.module.css'
|
|
9
|
+
import { ModalContext } from './modal-context.js'
|
|
8
10
|
|
|
9
11
|
type ModalHeaderIntrinsicProps = React.JSX.IntrinsicElements['div']
|
|
10
12
|
export interface ModalHeaderProps extends ModalHeaderIntrinsicProps {
|
|
11
13
|
className?: string
|
|
12
14
|
}
|
|
13
15
|
|
|
16
|
+
const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Give the dialog's heading the id that `Modal` points `aria-labelledby` at.
|
|
20
|
+
*
|
|
21
|
+
* The heading is found rather than declared, so no caller has to change: every
|
|
22
|
+
* header in the admin is written as a heading element optionally followed by a
|
|
23
|
+
* close button. Only the first heading is labelled, and only direct children
|
|
24
|
+
* are searched — a header deep enough to hide its heading is better served by
|
|
25
|
+
* `Modal`'s explicit `ariaLabel`.
|
|
26
|
+
*
|
|
27
|
+
* `Modal.Header` owns the heading's `id` for this reason; an id set by a caller
|
|
28
|
+
* is replaced, because the dialog's name depends on this one resolving.
|
|
29
|
+
*/
|
|
30
|
+
function labelHeading(children: React.ReactNode, headingId: string): React.ReactNode {
|
|
31
|
+
let labelled = false
|
|
32
|
+
return React.Children.map(children, (child) => {
|
|
33
|
+
if (labelled || !React.isValidElement(child)) return child
|
|
34
|
+
if (typeof child.type !== 'string' || !HEADING_TAGS.has(child.type)) return child
|
|
35
|
+
labelled = true
|
|
36
|
+
return React.cloneElement(child as React.ReactElement<{ id?: string }>, { id: headingId })
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
14
40
|
export const ModalHeader = function ModalHeader({
|
|
15
41
|
ref,
|
|
16
42
|
children,
|
|
@@ -19,10 +45,11 @@ export const ModalHeader = function ModalHeader({
|
|
|
19
45
|
}: ModalHeaderProps & {
|
|
20
46
|
ref?: React.RefObject<HTMLDivElement>
|
|
21
47
|
}) {
|
|
48
|
+
const { headingId } = useContext(ModalContext)
|
|
22
49
|
const classes = cx('byline-modal-header', styles['modal-header'], 'prose', className)
|
|
23
50
|
return (
|
|
24
51
|
<div style={{ overflowWrap: 'anywhere' }} ref={ref} {...rest} className={classes}>
|
|
25
|
-
{children}
|
|
52
|
+
{headingId == null ? children : labelHeading(children, headingId)}
|
|
26
53
|
</div>
|
|
27
54
|
)
|
|
28
55
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import type React from 'react'
|
|
4
|
-
import {
|
|
4
|
+
import { useCallback, useId, useRef, useState } from 'react'
|
|
5
5
|
|
|
6
6
|
import { Dialog } from '@base-ui/react/dialog'
|
|
7
7
|
import cx from 'clsx'
|
|
@@ -10,19 +10,24 @@ import styles from './modal.module.css'
|
|
|
10
10
|
import { ModalActions } from './modal-actions'
|
|
11
11
|
import { ModalContainer } from './modal-container'
|
|
12
12
|
import { ModalContent } from './modal-content'
|
|
13
|
+
import { ModalContext } from './modal-context.js'
|
|
13
14
|
import { ModalHeader } from './modal-header'
|
|
14
15
|
|
|
16
|
+
export { ModalContext }
|
|
17
|
+
|
|
15
18
|
export interface ModalProps {
|
|
16
19
|
isOpen?: boolean
|
|
17
20
|
onDismiss?: () => void
|
|
18
21
|
closeOnOverlayClick?: boolean
|
|
22
|
+
/**
|
|
23
|
+
* Accessible name for the dialog, for modals that carry no `Modal.Header`
|
|
24
|
+
* heading to borrow one from. Modals that do have a heading need nothing
|
|
25
|
+
* here — see `ModalContext.headingId`.
|
|
26
|
+
*/
|
|
27
|
+
ariaLabel?: string
|
|
19
28
|
children?: React.ReactNode
|
|
20
29
|
}
|
|
21
30
|
|
|
22
|
-
export const ModalContext = createContext<{
|
|
23
|
-
onDismiss?: () => void
|
|
24
|
-
}>({})
|
|
25
|
-
|
|
26
31
|
export type UseModalProps = ReturnType<typeof useModal>
|
|
27
32
|
|
|
28
33
|
export function useModal(): {
|
|
@@ -53,8 +58,10 @@ function Modal({
|
|
|
53
58
|
isOpen,
|
|
54
59
|
onDismiss,
|
|
55
60
|
closeOnOverlayClick,
|
|
61
|
+
ariaLabel,
|
|
56
62
|
children,
|
|
57
63
|
}: ModalProps): React.JSX.Element {
|
|
64
|
+
const headingId = useId()
|
|
58
65
|
// Overlay dismissal is handled here rather than by Base UI's outside-press
|
|
59
66
|
// detection. `Dialog.Popup` below is the full-viewport flex box that centres
|
|
60
67
|
// the dialog, not the dialog box itself, so a click on the empty space around
|
|
@@ -83,7 +90,7 @@ function Modal({
|
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
return (
|
|
86
|
-
<ModalContext.Provider value={{ onDismiss }}>
|
|
93
|
+
<ModalContext.Provider value={{ onDismiss, headingId }}>
|
|
87
94
|
<Dialog.Root
|
|
88
95
|
open={isOpen}
|
|
89
96
|
onOpenChange={(open) => {
|
|
@@ -98,6 +105,8 @@ function Modal({
|
|
|
98
105
|
<Dialog.Backdrop className={cx('byline-modal-backdrop', styles.backdrop)} />
|
|
99
106
|
<Dialog.Popup
|
|
100
107
|
className={cx('byline-modal-wrapper', styles['modal-wrapper'])}
|
|
108
|
+
aria-label={ariaLabel}
|
|
109
|
+
aria-labelledby={ariaLabel == null ? headingId : undefined}
|
|
101
110
|
onPointerDown={handleOverlayPointerDown}
|
|
102
111
|
onClick={handleOverlayClick}
|
|
103
112
|
>
|