@hackcove/ui-react 0.10.5 → 0.10.6

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.
@@ -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;
@@ -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
- /** ‹, a month dropdown, a year dropdown and ›: a birth date decades back is two picks away. */
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 state = useContext(CalendarStateContext);
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 picker = useContext(DatePickerStateContext);
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
- * The picker's popover: header, a Monday-first month grid and a Clear footer. `container` is the
40
- * modal `<dialog>` the picker sits in, if any: a modal makes everything outside it inert and paints
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(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: [_jsxs(Calendar, { firstDayOfWeek: "mon", className: "flex flex-col gap-3", children: [_jsx(CalendarHeader, {}), _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: cellClass }) })] })] }), _jsx("div", { className: "flex justify-end border-t border-slate-200 pt-2", children: _jsx(ClearButton, {}) })] }) }));
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 [dialog, setDialog] = useState(undefined);
33
- const findDialog = useCallback((el) => setDialog(el?.closest("dialog") ?? undefined), []);
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";
@@ -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 [leaving, setLeaving] = useState(false);
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 (read.impossible && !event.currentTarget.contains(event.relatedTarget)) {
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.5",
3
+ "version": "0.10.6",
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",