@hackcove/ui-react 0.10.5 → 0.10.7
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/Checkbox.js +1 -1
- package/dist/DateCalendar.d.ts +7 -5
- package/dist/DateCalendar.js +56 -8
- package/dist/DateFieldCalendar.d.ts +14 -0
- package/dist/DateFieldCalendar.js +12 -4
- package/dist/DateRangePicker.d.ts +37 -0
- package/dist/DateRangePicker.js +42 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/shownDate.d.ts +41 -0
- package/dist/shownDate.js +100 -2
- package/package.json +1 -1
package/dist/Checkbox.js
CHANGED
|
@@ -25,5 +25,5 @@ export function Checkbox({ label, description, size = "md", align = "center", "a
|
|
|
25
25
|
return (_jsxs("label", { className: cx(
|
|
26
26
|
// Faded and default-cursor unless the box is enabled. Written as `has-enabled` so the
|
|
27
27
|
// label carries no `disabled` class text: the disabled state belongs on the input.
|
|
28
|
-
"group/checkbox inline-flex cursor-default text-slate-700 opacity-50 has-enabled:cursor-pointer has-enabled:opacity-100", start ? "items-start" : "items-center", SIZE[size].label), children: [_jsx("span", { "aria-hidden": "true", className: cx(SIZE[size].box, start && SIZE[size].start, "pointer-events-none invisible absolute z-
|
|
28
|
+
"group/checkbox inline-flex cursor-default text-slate-700 opacity-50 has-enabled:cursor-pointer has-enabled:opacity-100", start ? "items-start" : "items-center", SIZE[size].label), children: [_jsx("span", { "aria-hidden": "true", className: cx(SIZE[size].box, start && SIZE[size].start, "pointer-events-none invisible absolute in-[.isolate]:z-20 block rounded border border-violet-600 bg-violet-600 text-violet-50 group-has-checked/checkbox:visible"), children: _jsx("svg", { viewBox: "0 0 16 16", fill: "none", className: "h-full w-full", children: _jsx("path", { d: "M4 8.5l2.5 2.5L12 5.5", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }) }) }), _jsx("input", { type: "checkbox", className: cx(SIZE[size].box, "shrink-0", start && SIZE[size].start, "appearance-none rounded border border-slate-300 bg-white hover:border-violet-400", focusRing), ...(describedIds ? { "aria-describedby": describedIds } : {}), ...rest }), _jsx(Words, { label: label, description: description, id: descriptionId })] }));
|
|
29
29
|
}
|
package/dist/DateCalendar.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { type ReactElement } from "react";
|
|
2
|
+
import type { DateRangePreset } from "./DateRangePicker.js";
|
|
2
3
|
export declare const calendarIcon: import("react").JSX.Element;
|
|
3
|
-
/**
|
|
4
|
-
* The picker's popover: header, a Monday-first month grid and a Clear footer. `container` is the
|
|
5
|
-
* modal `<dialog>` the picker sits in, if any: a modal makes everything outside it inert and paints
|
|
6
|
-
* above the body, so the popover renders inside it (which also keeps the theme's scope class).
|
|
7
|
-
*/
|
|
4
|
+
/** The date picker's popover: header, a Monday-first month grid and a Clear footer. */
|
|
8
5
|
export declare function CalendarPopover({ container }: {
|
|
9
6
|
container: Element | undefined;
|
|
10
7
|
}): ReactElement;
|
|
8
|
+
/** The range picker's popover: the presets, if any, the header, one Monday-first month and Clear. */
|
|
9
|
+
export declare function RangeCalendarPopover({ container, presets, }: {
|
|
10
|
+
container: Element | undefined;
|
|
11
|
+
presets: readonly DateRangePreset[] | undefined;
|
|
12
|
+
}): ReactElement;
|
package/dist/DateCalendar.js
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { getLocalTimeZone, today } from "@internationalized/date";
|
|
3
3
|
import { useContext } from "react";
|
|
4
|
-
import { Button, Calendar, CalendarCell, CalendarGrid, CalendarGridBody, CalendarGridHeader, CalendarHeaderCell, CalendarStateContext, DatePickerStateContext, Dialog, Popover, } from "react-aria-components";
|
|
4
|
+
import { Button, Calendar, CalendarCell, CalendarGrid, CalendarGridBody, CalendarGridHeader, CalendarHeaderCell, CalendarStateContext, DatePickerStateContext, DateRangePickerStateContext, Dialog, Popover, RangeCalendar, RangeCalendarStateContext, } from "react-aria-components";
|
|
5
5
|
import { cx, focusRing } from "./cx.js";
|
|
6
|
+
import { Button as PresetButton } from "./Button.js";
|
|
6
7
|
import { MONTH_OPTIONS, iconButtonClass } from "./DateField.js";
|
|
7
8
|
import { Icon } from "./Icon.js";
|
|
8
9
|
export const calendarIcon = (_jsx(Icon, { children: _jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", children: [_jsx("rect", { x: "3", y: "4.5", width: "14", height: "12.5", rx: "2" }), _jsx("path", { d: "M3 8.5h14M7 2.5v4M13 2.5v4" })] }) }));
|
|
9
10
|
const chevron = (d) => (_jsx(Icon, { children: _jsx("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: d }) }) }));
|
|
10
11
|
const selectClass = `rounded-md border border-slate-300 bg-white py-1 pr-1 pl-2 text-sm text-slate-900 transition hover:border-violet-400 ${focusRing}`;
|
|
11
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* ‹, a month dropdown, a year dropdown and ›: a birth date decades back is two picks away. It reads
|
|
14
|
+
* a one-date calendar's state, or a range calendar's when there is none.
|
|
15
|
+
*/
|
|
12
16
|
function CalendarHeader() {
|
|
13
|
-
const
|
|
17
|
+
const single = useContext(CalendarStateContext);
|
|
18
|
+
const range = useContext(RangeCalendarStateContext);
|
|
19
|
+
const state = single ?? range;
|
|
14
20
|
if (!state)
|
|
15
21
|
return null;
|
|
16
22
|
const focused = state.focusedDate;
|
|
@@ -20,8 +26,11 @@ function CalendarHeader() {
|
|
|
20
26
|
const years = Array.from({ length: last - first + 1 }, (_, i) => first + i);
|
|
21
27
|
return (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(Button, { slot: "previous", className: iconButtonClass, children: chevron("M12.5 15l-5-5 5-5") }), _jsx("select", { "aria-label": "Month", className: cx(selectClass, "flex-1"), value: focused.month, disabled: state.isDisabled, onChange: (event) => state.setFocusedDate(focused.set({ month: Number(event.target.value) })), children: MONTH_OPTIONS.map(({ month, name }) => (_jsx("option", { value: month, children: name }, month))) }), _jsx("select", { "aria-label": "Year", className: selectClass, value: focused.year, disabled: state.isDisabled, onChange: (event) => state.setFocusedDate(focused.set({ year: Number(event.target.value) })), children: years.map((year) => (_jsx("option", { value: year, children: year }, year))) }), _jsx(Button, { slot: "next", className: iconButtonClass, children: chevron("M7.5 5l5 5-5 5") })] }));
|
|
22
28
|
}
|
|
29
|
+
/** Clears the picker's value, a date or a range, and closes the popover. */
|
|
23
30
|
function ClearButton() {
|
|
24
|
-
const
|
|
31
|
+
const single = useContext(DatePickerStateContext);
|
|
32
|
+
const range = useContext(DateRangePickerStateContext);
|
|
33
|
+
const picker = single ?? range;
|
|
25
34
|
return (_jsx("button", { type: "button", className: `rounded-md px-2 py-1 text-sm font-medium text-violet-600 transition hover:bg-violet-50 ${focusRing}`, onClick: () => {
|
|
26
35
|
picker?.setValue(null);
|
|
27
36
|
picker?.close();
|
|
@@ -35,11 +44,50 @@ const cellClass = ({ isSelected, isOutsideMonth, isDisabled, isUnavailable, isIn
|
|
|
35
44
|
tone = "text-slate-300";
|
|
36
45
|
return cx("flex h-9 w-9 items-center justify-center rounded-lg text-sm tabular-nums", tone, focusRing);
|
|
37
46
|
};
|
|
47
|
+
/** The colour of a day in a range calendar: the ends filled, the days between a light band. */
|
|
48
|
+
function rangeTone(cell) {
|
|
49
|
+
if (cell.isOutsideMonth || cell.isDisabled || cell.isUnavailable)
|
|
50
|
+
return "text-slate-300";
|
|
51
|
+
if (!cell.isSelected)
|
|
52
|
+
return "cursor-pointer text-slate-700 hover:bg-slate-100";
|
|
53
|
+
if (cell.isInvalid)
|
|
54
|
+
return "bg-red-50 text-red-700";
|
|
55
|
+
if (cell.isSelectionStart || cell.isSelectionEnd)
|
|
56
|
+
return "bg-violet-600 text-violet-50";
|
|
57
|
+
return "bg-violet-50 text-violet-700";
|
|
58
|
+
}
|
|
38
59
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* above the body, so the popover renders inside it (which also keeps the theme's scope class).
|
|
60
|
+
* A day in a range calendar. The selected days join into one band: square between the ends, rounded
|
|
61
|
+
* on the outer side of each end. Any other day is rounded like a `cellClass` day.
|
|
42
62
|
*/
|
|
63
|
+
const rangeCellClass = (cell) => cx("flex h-9 w-9 items-center justify-center text-sm tabular-nums", rangeTone(cell), cell.isSelected && !cell.isOutsideMonth
|
|
64
|
+
? cx(cell.isSelectionStart && "rounded-l-lg", cell.isSelectionEnd && "rounded-r-lg")
|
|
65
|
+
: "rounded-lg", focusRing);
|
|
66
|
+
/** The weekday row and the days of the month on show, in a `Calendar` or a `RangeCalendar`. */
|
|
67
|
+
function MonthGrid({ cellClass: dayClass, }) {
|
|
68
|
+
return (_jsxs(CalendarGrid, { weekdayStyle: "short", children: [_jsx(CalendarGridHeader, { children: (day) => (_jsx(CalendarHeaderCell, { className: "pb-1 text-xs font-medium text-slate-500", children: day })) }), _jsx(CalendarGridBody, { children: (date) => _jsx(CalendarCell, { date: date, className: dayClass }) })] }));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The popover's frame and dialog. `container` is the modal `<dialog>` the picker sits in, if any: a
|
|
72
|
+
* modal makes everything outside it inert and paints above the body, so the popover renders inside
|
|
73
|
+
* it (which also keeps the theme's scope class).
|
|
74
|
+
*/
|
|
75
|
+
function PickerPopover({ container, children, }) {
|
|
76
|
+
return (_jsx(Popover, { ...(container ? { UNSTABLE_portalContainer: container } : {}), placement: "bottom start", className: "rounded-xl border border-slate-200 bg-white p-3 text-slate-900 shadow-lg", children: _jsxs(Dialog, { className: "flex flex-col gap-3 outline-none", children: [children, _jsx("div", { className: "flex justify-end border-t border-slate-200 pt-2", children: _jsx(ClearButton, {}) })] }) }));
|
|
77
|
+
}
|
|
78
|
+
/** The date picker's popover: header, a Monday-first month grid and a Clear footer. */
|
|
43
79
|
export function CalendarPopover({ container }) {
|
|
44
|
-
return (_jsx(
|
|
80
|
+
return (_jsx(PickerPopover, { container: container, children: _jsxs(Calendar, { firstDayOfWeek: "mon", className: "flex flex-col gap-3", children: [_jsx(CalendarHeader, {}), _jsx(MonthGrid, { cellClass: cellClass })] }) }));
|
|
81
|
+
}
|
|
82
|
+
/** A preset's button: sets the range, which reports it, and closes the popover. */
|
|
83
|
+
function PresetRow({ presets }) {
|
|
84
|
+
const picker = useContext(DateRangePickerStateContext);
|
|
85
|
+
return (_jsx("div", { className: "flex w-0 min-w-full flex-wrap gap-1.5", children: presets.map((preset) => (_jsx(PresetButton, { variant: "outline", size: "sm", onClick: () => {
|
|
86
|
+
picker?.setValue(preset.value);
|
|
87
|
+
picker?.close();
|
|
88
|
+
}, children: preset.label }, preset.label))) }));
|
|
89
|
+
}
|
|
90
|
+
/** The range picker's popover: the presets, if any, the header, one Monday-first month and Clear. */
|
|
91
|
+
export function RangeCalendarPopover({ container, presets, }) {
|
|
92
|
+
return (_jsxs(PickerPopover, { container: container, children: [presets && presets.length > 0 ? _jsx(PresetRow, { presets: presets }) : null, _jsxs(RangeCalendar, { firstDayOfWeek: "mon", className: "flex flex-col gap-3", children: [_jsx(CalendarHeader, {}), _jsx(MonthGrid, { cellClass: rangeCellClass })] })] }));
|
|
45
93
|
}
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { type ReactElement } from "react";
|
|
2
2
|
import { type FieldProps } from "./DateField.js";
|
|
3
|
+
import type { Size } from "./size.js";
|
|
4
|
+
/** The picker's padding, text and calendar button, by `size`. The button sets the height, so the padding is less than a `DateField`'s. */
|
|
5
|
+
export declare const PICKER_SIZE: Record<Size, {
|
|
6
|
+
frame: string;
|
|
7
|
+
button: string;
|
|
8
|
+
}>;
|
|
9
|
+
/**
|
|
10
|
+
* The modal `<dialog>` a picker sits in, if any, for its popover's `container`: pass `findDialog` as the
|
|
11
|
+
* picker's `ref`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function useDialogContainer(): {
|
|
14
|
+
container: Element | undefined;
|
|
15
|
+
findDialog: (el: HTMLDivElement | null) => void;
|
|
16
|
+
};
|
|
3
17
|
/** The body of `<DateField calendar>`: the field with a calendar button that opens a month view in a popover. */
|
|
4
18
|
export declare function CalendarDateField({ label, description, errorMessage, error, isInvalid: invalidProp, value, defaultValue, onChange, name, size, ...rest }: FieldProps & {
|
|
5
19
|
defaultOpen?: boolean;
|
|
@@ -6,7 +6,7 @@ import { CalendarPopover, calendarIcon } from "./DateCalendar.js";
|
|
|
6
6
|
import { FieldSegment, LOCALE, forcedInvalid, Notes, frameClass, iconButtonLook, labelClass, } from "./DateField.js";
|
|
7
7
|
import { ShownInput, useShownDate } from "./shownDate.js";
|
|
8
8
|
/** The picker's padding, text and calendar button, by `size`. The button sets the height, so the padding is less than a `DateField`'s. */
|
|
9
|
-
const PICKER_SIZE = {
|
|
9
|
+
export const PICKER_SIZE = {
|
|
10
10
|
sm: { frame: "py-0.5 pl-2 pr-0.5 text-xs", button: "h-5 w-5" },
|
|
11
11
|
md: { frame: "py-1 pl-2.5 pr-1 text-sm", button: "h-7 w-7" },
|
|
12
12
|
lg: { frame: "py-1.5 pl-3 pr-1.5 text-base", button: "h-8 w-8" },
|
|
@@ -19,6 +19,15 @@ function PickerInput({ leaving, onLeft, children, }) {
|
|
|
19
19
|
const field = useContext(DateFieldContext);
|
|
20
20
|
return (_jsx(DateFieldContext.Provider, { value: { ...field, isReadOnly: leaving, onBlur: onLeft }, children: children }));
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* The modal `<dialog>` a picker sits in, if any, for its popover's `container`: pass `findDialog` as the
|
|
24
|
+
* picker's `ref`.
|
|
25
|
+
*/
|
|
26
|
+
export function useDialogContainer() {
|
|
27
|
+
const [container, setContainer] = useState(undefined);
|
|
28
|
+
const findDialog = useCallback((el) => setContainer(el?.closest("dialog") ?? undefined), []);
|
|
29
|
+
return { container, findDialog };
|
|
30
|
+
}
|
|
22
31
|
/** The body of `<DateField calendar>`: the field with a calendar button that opens a month view in a popover. */
|
|
23
32
|
export function CalendarDateField({ label, description, errorMessage, error, isInvalid: invalidProp, value, defaultValue, onChange, name, size = "md", ...rest }) {
|
|
24
33
|
const { inner, setInner, shown, impossible, onRead, validate, leaving, onBlurCapture, onLeft } = useShownDate({
|
|
@@ -29,7 +38,6 @@ export function CalendarDateField({ label, description, errorMessage, error, isI
|
|
|
29
38
|
maxValue: rest.maxValue,
|
|
30
39
|
isRequired: rest.isRequired,
|
|
31
40
|
});
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
return (_jsx(I18nProvider, { locale: LOCALE, children: _jsx(AriaDatePicker, { ...rest, ref: findDialog, value: inner, onChange: setInner, validate: validate, ...forcedInvalid({ impossible, isInvalid: invalidProp, error }), granularity: "day", shouldForceLeadingZeros: true, className: "flex flex-col gap-1.5", children: ({ isInvalid }) => (_jsxs(_Fragment, { children: [label ? _jsx(Label, { className: labelClass, children: label }) : null, _jsxs(Group, { className: (state) => cx(frameClass(state), "gap-1", PICKER_SIZE[size].frame), children: [_jsx("div", { className: "contents", onBlurCapture: onBlurCapture, children: _jsx(PickerInput, { leaving: leaving, onLeft: onLeft, children: _jsx(DateInput, { className: "flex flex-1", children: (segment) => (_jsx(FieldSegment, { segment: segment, ...(segment.type === "day" ? { onRead } : {}) })) }) }) }), _jsx(Button, { className: cx(iconButtonLook, PICKER_SIZE[size].button), children: calendarIcon })] }), _jsx(ShownInput, { name: name, shown: shown, isDisabled: rest.isDisabled }), _jsx(Notes, { description: description, errorMessage: errorMessage, error: error, impossible: impossible, isInvalid: isInvalid }), _jsx(CalendarPopover, { container: dialog })] })) }) }));
|
|
41
|
+
const { container, findDialog } = useDialogContainer();
|
|
42
|
+
return (_jsx(I18nProvider, { locale: LOCALE, children: _jsx(AriaDatePicker, { ...rest, ref: findDialog, value: inner, onChange: setInner, validate: validate, ...forcedInvalid({ impossible, isInvalid: invalidProp, error }), granularity: "day", shouldForceLeadingZeros: true, className: "flex flex-col gap-1.5", children: ({ isInvalid }) => (_jsxs(_Fragment, { children: [label ? _jsx(Label, { className: labelClass, children: label }) : null, _jsxs(Group, { className: (state) => cx(frameClass(state), "gap-1", PICKER_SIZE[size].frame), children: [_jsx("div", { className: "contents", onBlurCapture: onBlurCapture, children: _jsx(PickerInput, { leaving: leaving, onLeft: onLeft, children: _jsx(DateInput, { className: "flex flex-1", children: (segment) => (_jsx(FieldSegment, { segment: segment, ...(segment.type === "day" ? { onRead } : {}) })) }) }) }), _jsx(Button, { className: cx(iconButtonLook, PICKER_SIZE[size].button), children: calendarIcon })] }), _jsx(ShownInput, { name: name, shown: shown, isDisabled: rest.isDisabled }), _jsx(Notes, { description: description, errorMessage: errorMessage, error: error, impossible: impossible, isInvalid: isInvalid }), _jsx(CalendarPopover, { container: container })] })) }) }));
|
|
35
43
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
import { type FieldProps } from "./DateField.js";
|
|
3
|
+
import type { DataAttributes } from "./link.js";
|
|
4
|
+
import { type ShownRange } from "./shownDate.js";
|
|
5
|
+
/** A from and to date: `start` on or before `end`. */
|
|
6
|
+
export type DateRange = ShownRange;
|
|
7
|
+
/** A ready-made range the app builds from its own today, such as "Last 7 days". */
|
|
8
|
+
export type DateRangePreset = {
|
|
9
|
+
label: string;
|
|
10
|
+
value: DateRange;
|
|
11
|
+
};
|
|
12
|
+
export type DateRangePickerProps = Pick<FieldProps, "label" | "description" | "errorMessage" | "error" | "isInvalid" | "size" | "minValue" | "maxValue" | "isRequired" | "isDisabled" | "aria-label" | "aria-labelledby" | "aria-describedby" | "aria-details"> & DataAttributes & {
|
|
13
|
+
/** The range, or `null` for none. Reported only when both days are complete, real and in order. */
|
|
14
|
+
value?: DateRange | null | undefined;
|
|
15
|
+
/** The range shown first when the picker holds its own value. */
|
|
16
|
+
defaultValue?: DateRange | null | undefined;
|
|
17
|
+
/** Called with the range once both sides make one, and with `null` when they stop making one. */
|
|
18
|
+
onChange?: ((range: DateRange | null) => void) | undefined;
|
|
19
|
+
/** Posts the start as `YYYY-MM-DD` in a form, or `""` while there is no range. */
|
|
20
|
+
startName?: string | undefined;
|
|
21
|
+
/** Posts the end as `YYYY-MM-DD` in a form, or `""` while there is no range. */
|
|
22
|
+
endName?: string | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Ready-made ranges, a row of buttons above the month. The app builds them from its own today;
|
|
25
|
+
* the picker has none of its own and never reads the clock.
|
|
26
|
+
*/
|
|
27
|
+
presets?: readonly DateRangePreset[] | undefined;
|
|
28
|
+
/** Open the calendar on mount. */
|
|
29
|
+
defaultOpen?: boolean | undefined;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* A from and to date in one field: DD/MM/YYYY "to" DD/MM/YYYY and a calendar button that opens one
|
|
33
|
+
* Monday-first month, where two presses pick the range. `presets` adds the app's ready-made ranges
|
|
34
|
+
* above the month. The range is reported only when both sides are complete, real dates within
|
|
35
|
+
* min/max and the start is on or before the end; otherwise `null`.
|
|
36
|
+
*/
|
|
37
|
+
export declare function DateRangePicker({ label, description, errorMessage, error, isInvalid: invalidProp, value, defaultValue, onChange, startName, endName, presets, defaultOpen, size, ...rest }: DateRangePickerProps): ReactElement;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useContext } from "react";
|
|
3
|
+
import { Button, DateFieldContext, DateInput, DateRangePicker as AriaDateRangePicker, Group, I18nProvider, Label, } from "react-aria-components";
|
|
4
|
+
import { cx } from "./cx.js";
|
|
5
|
+
import { RangeCalendarPopover, calendarIcon } from "./DateCalendar.js";
|
|
6
|
+
import { FieldSegment, LOCALE, forcedInvalid, frameClass, iconButtonLook, labelClass, Notes, } from "./DateField.js";
|
|
7
|
+
import { PICKER_SIZE, useDialogContainer } from "./DateFieldCalendar.js";
|
|
8
|
+
import { ShownInput, useShownRange } from "./shownDate.js";
|
|
9
|
+
/**
|
|
10
|
+
* One side's segments, named for a screen reader and read-only while focus leaves it with an
|
|
11
|
+
* impossible day (see `useShownDate`). React Aria hands each side its field props through the
|
|
12
|
+
* `start` and `end` slots; only this side's slot is changed.
|
|
13
|
+
*/
|
|
14
|
+
function SideInput({ slot, name, side, onRead, }) {
|
|
15
|
+
const field = useContext(DateFieldContext);
|
|
16
|
+
const slots = field && "slots" in field ? field.slots : undefined;
|
|
17
|
+
const own = slots?.[slot] ?? {};
|
|
18
|
+
return (_jsx("div", { role: "group", "aria-label": name, className: "flex items-center", onBlurCapture: side.onBlurCapture, children: _jsx(DateFieldContext.Provider, { value: {
|
|
19
|
+
slots: {
|
|
20
|
+
...slots,
|
|
21
|
+
[slot]: { ...own, isReadOnly: side.leaving, onBlur: side.onLeft },
|
|
22
|
+
},
|
|
23
|
+
}, children: _jsx(DateInput, { slot: slot, className: "flex", children: (segment) => (_jsx(FieldSegment, { segment: segment, ...(segment.type === "day" ? { onRead } : {}) })) }) }) }));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A from and to date in one field: DD/MM/YYYY "to" DD/MM/YYYY and a calendar button that opens one
|
|
27
|
+
* Monday-first month, where two presses pick the range. `presets` adds the app's ready-made ranges
|
|
28
|
+
* above the month. The range is reported only when both sides are complete, real dates within
|
|
29
|
+
* min/max and the start is on or before the end; otherwise `null`.
|
|
30
|
+
*/
|
|
31
|
+
export function DateRangePicker({ label, description, errorMessage, error, isInvalid: invalidProp, value, defaultValue, onChange, startName, endName, presets, defaultOpen, size = "md", ...rest }) {
|
|
32
|
+
const shown = useShownRange({
|
|
33
|
+
value,
|
|
34
|
+
defaultValue,
|
|
35
|
+
onChange,
|
|
36
|
+
minValue: rest.minValue,
|
|
37
|
+
maxValue: rest.maxValue,
|
|
38
|
+
isRequired: rest.isRequired,
|
|
39
|
+
});
|
|
40
|
+
const { container, findDialog } = useDialogContainer();
|
|
41
|
+
return (_jsx(I18nProvider, { locale: LOCALE, children: _jsx(AriaDateRangePicker, { ...rest, ...(defaultOpen === undefined ? {} : { defaultOpen }), ref: findDialog, value: shown.inner, onChange: shown.setInner, validate: shown.validate, ...forcedInvalid({ impossible: shown.problem, isInvalid: invalidProp, error }), granularity: "day", shouldForceLeadingZeros: true, className: "flex flex-col gap-1.5", children: ({ isInvalid }) => (_jsxs(_Fragment, { children: [label ? _jsx(Label, { className: labelClass, children: label }) : null, _jsxs(Group, { className: (state) => cx(frameClass(state), "gap-1", PICKER_SIZE[size].frame), children: [_jsx(SideInput, { slot: "start", name: "Start date", side: shown.start, onRead: shown.onReadStart }), _jsx("span", { "aria-hidden": "true", className: "px-1 text-slate-500", children: "to" }), _jsx(SideInput, { slot: "end", name: "End date", side: shown.end, onRead: shown.onReadEnd }), _jsx(Button, { className: cx(iconButtonLook, PICKER_SIZE[size].button, "ml-auto"), children: calendarIcon })] }), _jsx(ShownInput, { name: startName, shown: shown.startShown, isDisabled: rest.isDisabled }), _jsx(ShownInput, { name: endName, shown: shown.endShown, isDisabled: rest.isDisabled }), _jsx(Notes, { description: description, errorMessage: errorMessage, error: error, impossible: shown.problem, isInvalid: isInvalid }), _jsx(RangeCalendarPopover, { container: container, presets: presets })] })) }) }));
|
|
42
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -84,5 +84,6 @@ export { TextLink, type TextLinkSize } from "./TextLink.js";
|
|
|
84
84
|
export { ToolCallCard } from "./ToolCallCard.js";
|
|
85
85
|
export { Caption, Display, Lead, Overline, PageHeading, Prose, SectionHeading, SubsectionHeading, type SubsectionHeadingSize, Text, type TextTone, type TypographyColor, type TypographyProps, } from "./typography.js";
|
|
86
86
|
export { DateField, type DateFieldProps } from "./DateField.js";
|
|
87
|
+
export { DateRangePicker, type DateRange, type DateRangePickerProps, type DateRangePreset, } from "./DateRangePicker.js";
|
|
87
88
|
export { fromISODate, toISODate } from "./isoDate.js";
|
|
88
89
|
export type { CalendarDate } from "@internationalized/date";
|
package/dist/index.js
CHANGED
|
@@ -80,4 +80,5 @@ export { TextLink } from "./TextLink.js";
|
|
|
80
80
|
export { ToolCallCard } from "./ToolCallCard.js";
|
|
81
81
|
export { Caption, Display, Lead, Overline, PageHeading, Prose, SectionHeading, SubsectionHeading, Text, } from "./typography.js";
|
|
82
82
|
export { DateField } from "./DateField.js";
|
|
83
|
+
export { DateRangePicker, } from "./DateRangePicker.js";
|
|
83
84
|
export { fromISODate, toISODate } from "./isoDate.js";
|
package/dist/shownDate.d.ts
CHANGED
|
@@ -36,6 +36,47 @@ export declare function useShownDate(props: ValueProps): {
|
|
|
36
36
|
onBlurCapture: (event: FocusEvent<HTMLElement>) => void;
|
|
37
37
|
onLeft: () => void;
|
|
38
38
|
};
|
|
39
|
+
/** A from and to date, both days. */
|
|
40
|
+
export type ShownRange = {
|
|
41
|
+
start: CalendarDate;
|
|
42
|
+
end: CalendarDate;
|
|
43
|
+
};
|
|
44
|
+
type RangeProps = {
|
|
45
|
+
value?: ShownRange | null | undefined;
|
|
46
|
+
defaultValue?: ShownRange | null | undefined;
|
|
47
|
+
onChange?: ((range: ShownRange | null) => void) | undefined;
|
|
48
|
+
minValue?: FieldProps["minValue"] | undefined;
|
|
49
|
+
maxValue?: FieldProps["maxValue"] | undefined;
|
|
50
|
+
isRequired?: boolean | undefined;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* `useShownDate` for a range: each side's segments are read on their own, and the range reported is
|
|
54
|
+
* what both sides show, once both are complete, real dates within min/max and the start is on or
|
|
55
|
+
* before the end; otherwise `null`. Each side keeps an impossible day on blur with its own `leaving`
|
|
56
|
+
* state, so one side is read-only only for the blur that leaves it. `problem` is the message for an
|
|
57
|
+
* impossible day, naming its side, or for an end before the start. `startShown` and `endShown`, what
|
|
58
|
+
* the hidden inputs post, are `""` while there is no range.
|
|
59
|
+
*/
|
|
60
|
+
export declare function useShownRange(props: RangeProps): {
|
|
61
|
+
inner: ShownRange | null;
|
|
62
|
+
setInner: import("react").Dispatch<import("react").SetStateAction<ShownRange | null>>;
|
|
63
|
+
startShown: string;
|
|
64
|
+
endShown: string;
|
|
65
|
+
problem: string | null;
|
|
66
|
+
onReadStart: (next: Read) => void;
|
|
67
|
+
onReadEnd: (next: Read) => void;
|
|
68
|
+
validate: () => "Enter the whole date." | null;
|
|
69
|
+
start: {
|
|
70
|
+
leaving: boolean;
|
|
71
|
+
onBlurCapture: (event: FocusEvent<HTMLElement>) => void;
|
|
72
|
+
onLeft: () => void;
|
|
73
|
+
};
|
|
74
|
+
end: {
|
|
75
|
+
leaving: boolean;
|
|
76
|
+
onBlurCapture: (event: FocusEvent<HTMLElement>) => void;
|
|
77
|
+
onLeft: () => void;
|
|
78
|
+
};
|
|
79
|
+
};
|
|
39
80
|
/** The hidden input a form posts under `name`: the shown date as `YYYY-MM-DD`, or `""`. */
|
|
40
81
|
export declare function ShownInput({ name, shown, isDisabled, }: {
|
|
41
82
|
name: string | undefined;
|
package/dist/shownDate.js
CHANGED
|
@@ -40,7 +40,7 @@ export function useShownDate(props) {
|
|
|
40
40
|
impossible: null,
|
|
41
41
|
partial: false,
|
|
42
42
|
});
|
|
43
|
-
const
|
|
43
|
+
const side = useLeaving(read.impossible);
|
|
44
44
|
const shown = withinRange(read.typed, props);
|
|
45
45
|
const reported = useRef(shown);
|
|
46
46
|
const report = useRef(onChange);
|
|
@@ -73,15 +73,113 @@ export function useShownDate(props) {
|
|
|
73
73
|
impossible: read.impossible,
|
|
74
74
|
onRead,
|
|
75
75
|
validate: () => (isRequired && read.partial ? "Enter the whole date." : null),
|
|
76
|
+
...side,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The `leaving` read-only trick for one group of segments (see `useShownDate`): `onBlurCapture` goes
|
|
81
|
+
* on a wrapper of the input and `onLeft` is the field's own `onBlur`.
|
|
82
|
+
*/
|
|
83
|
+
function useLeaving(impossible) {
|
|
84
|
+
const [leaving, setLeaving] = useState(false);
|
|
85
|
+
return {
|
|
76
86
|
leaving,
|
|
77
87
|
onBlurCapture: (event) => {
|
|
78
|
-
if (
|
|
88
|
+
if (impossible && !event.currentTarget.contains(event.relatedTarget)) {
|
|
79
89
|
flushSync(() => setLeaving(true));
|
|
80
90
|
}
|
|
81
91
|
},
|
|
82
92
|
onLeft: () => setLeaving(false),
|
|
83
93
|
};
|
|
84
94
|
}
|
|
95
|
+
const blank = { typed: "", impossible: null, partial: false };
|
|
96
|
+
/** `"start/end"` when both are dates and start is on or before end, else `""`. */
|
|
97
|
+
function rangeKey(start, end) {
|
|
98
|
+
return start && end && start <= end ? `${start}/${end}` : "";
|
|
99
|
+
}
|
|
100
|
+
function fromKey(key) {
|
|
101
|
+
const [start, end] = key.split("/").map(fromISODate);
|
|
102
|
+
return start && end ? { start, end } : null;
|
|
103
|
+
}
|
|
104
|
+
/** The given range as `"start/end"`, each side kept only within `bounds` when they are passed. */
|
|
105
|
+
function givenKey(value, bounds) {
|
|
106
|
+
if (!value)
|
|
107
|
+
return "";
|
|
108
|
+
const [start, end] = [toISODate(value.start), toISODate(value.end)];
|
|
109
|
+
return bounds
|
|
110
|
+
? rangeKey(withinRange(start, bounds), withinRange(end, bounds))
|
|
111
|
+
: `${start}/${end}`;
|
|
112
|
+
}
|
|
113
|
+
/** An impossible day, naming its side, or else an end before the start; `null` for neither. */
|
|
114
|
+
function problemOf(start, end, startShown, endShown) {
|
|
115
|
+
const impossible = [
|
|
116
|
+
start.impossible && `Start date: ${start.impossible}`,
|
|
117
|
+
end.impossible && `End date: ${end.impossible}`,
|
|
118
|
+
].filter(Boolean);
|
|
119
|
+
if (impossible.length > 0)
|
|
120
|
+
return impossible.join(" ");
|
|
121
|
+
if (startShown && endShown && startShown > endShown) {
|
|
122
|
+
return "The end date is before the start date.";
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* `useShownDate` for a range: each side's segments are read on their own, and the range reported is
|
|
128
|
+
* what both sides show, once both are complete, real dates within min/max and the start is on or
|
|
129
|
+
* before the end; otherwise `null`. Each side keeps an impossible day on blur with its own `leaving`
|
|
130
|
+
* state, so one side is read-only only for the blur that leaves it. `problem` is the message for an
|
|
131
|
+
* impossible day, naming its side, or for an end before the start. `startShown` and `endShown`, what
|
|
132
|
+
* the hidden inputs post, are `""` while there is no range.
|
|
133
|
+
*/
|
|
134
|
+
export function useShownRange(props) {
|
|
135
|
+
const { value, defaultValue, onChange, isRequired } = props;
|
|
136
|
+
const [inner, setInner] = useState(value ?? defaultValue ?? null);
|
|
137
|
+
const [start, setStart] = useState({ ...blank, typed: toISODate(inner?.start) });
|
|
138
|
+
const [end, setEnd] = useState({ ...blank, typed: toISODate(inner?.end) });
|
|
139
|
+
const onReadStart = useCallback((next) => setStart(next), []);
|
|
140
|
+
const onReadEnd = useCallback((next) => setEnd(next), []);
|
|
141
|
+
const startSide = useLeaving(start.impossible);
|
|
142
|
+
const endSide = useLeaving(end.impossible);
|
|
143
|
+
const startShown = withinRange(start.typed, props);
|
|
144
|
+
const endShown = withinRange(end.typed, props);
|
|
145
|
+
const shown = rangeKey(startShown, endShown);
|
|
146
|
+
const problem = problemOf(start, end, startShown, endShown);
|
|
147
|
+
const reported = useRef(shown);
|
|
148
|
+
const report = useRef(onChange);
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
report.current = onChange;
|
|
151
|
+
});
|
|
152
|
+
const given = value === undefined ? undefined : givenKey(value, null);
|
|
153
|
+
const givenShown = givenKey(value ?? null, props);
|
|
154
|
+
const lastGiven = useRef(given);
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (given === lastGiven.current)
|
|
157
|
+
return;
|
|
158
|
+
lastGiven.current = given;
|
|
159
|
+
if (given === undefined || given === reported.current)
|
|
160
|
+
return;
|
|
161
|
+
reported.current = givenShown;
|
|
162
|
+
setInner(fromKey(given));
|
|
163
|
+
}, [given, givenShown]);
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (shown === reported.current)
|
|
166
|
+
return;
|
|
167
|
+
reported.current = shown;
|
|
168
|
+
report.current?.(fromKey(shown));
|
|
169
|
+
}, [shown]);
|
|
170
|
+
return {
|
|
171
|
+
inner,
|
|
172
|
+
setInner,
|
|
173
|
+
startShown: shown ? startShown : "",
|
|
174
|
+
endShown: shown ? endShown : "",
|
|
175
|
+
problem,
|
|
176
|
+
onReadStart,
|
|
177
|
+
onReadEnd,
|
|
178
|
+
validate: () => (isRequired && (start.partial || end.partial) ? "Enter the whole date." : null),
|
|
179
|
+
start: startSide,
|
|
180
|
+
end: endSide,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
85
183
|
/** The hidden input a form posts under `name`: the shown date as `YYYY-MM-DD`, or `""`. */
|
|
86
184
|
export function ShownInput({ name, shown, isDisabled, }) {
|
|
87
185
|
return name ? _jsx("input", { type: "hidden", name: name, value: shown, disabled: isDisabled }) : null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hackcove/ui-react",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.7",
|
|
4
4
|
"description": "Hack Cove React components: primitives, typography roles and a safe Markdown renderer, written against Tailwind v4 utilities. Pair with a theme from @hackcove/themes.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"components",
|