@hackcove/ui-react 0.10.4 → 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.
- 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/StackedBar.d.ts +2 -0
- package/dist/StackedBar.js +1 -1
- package/dist/StatTile.d.ts +8 -2
- package/dist/StatTile.js +14 -3
- package/dist/TrendChart.d.ts +34 -0
- package/dist/TrendChart.js +51 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/shownDate.d.ts +41 -0
- package/dist/shownDate.js +100 -2
- package/dist/trend.d.ts +26 -0
- package/dist/trend.js +64 -0
- package/package.json +1 -1
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/StackedBar.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { type TextTone } from "./typography.js";
|
|
|
9
9
|
* five `TextTone` names; a chart with more than five parts is why these two live here.
|
|
10
10
|
*/
|
|
11
11
|
export type VizTone = TextTone | "neutral" | "violet";
|
|
12
|
+
/** Tone → text utility, painted as a fill. The five `TextTone` colours are shared; the two extra ones are remapped by every theme. */
|
|
13
|
+
export declare const VIZ_TONE: Record<VizTone, string>;
|
|
12
14
|
/** One part of a `StackedBar`: its `label`, its `value` (its share is `value` over the sum) and its colour. */
|
|
13
15
|
export type StackedSegment = {
|
|
14
16
|
label: string;
|
package/dist/StackedBar.js
CHANGED
|
@@ -3,7 +3,7 @@ import { cx } from "./cx.js";
|
|
|
3
3
|
import { announcedPercent, BAR_TRACK, sharePercent } from "./ProgressBar.js";
|
|
4
4
|
import { TEXT_TONE } from "./typography.js";
|
|
5
5
|
/** Tone → text utility, painted as a fill. The five `TextTone` colours are shared; the two extra ones are remapped by every theme. */
|
|
6
|
-
const VIZ_TONE = {
|
|
6
|
+
export const VIZ_TONE = {
|
|
7
7
|
...TEXT_TONE,
|
|
8
8
|
neutral: "text-neutral-500",
|
|
9
9
|
violet: "text-purple-700",
|
package/dist/StatTile.d.ts
CHANGED
|
@@ -5,9 +5,10 @@ export type StatTileTone = "neutral" | "positive" | "warn" | "negative";
|
|
|
5
5
|
/**
|
|
6
6
|
* One headline figure: an uppercase caption over a large tabular-nums value, with an optional
|
|
7
7
|
* hint line beneath. `tone` colours the value; `accent` adds a bar across the top edge in the same
|
|
8
|
-
* tone. Pass the value already
|
|
8
|
+
* tone. `trend` draws a small line under the value in the same colour. Pass the value already
|
|
9
|
+
* formatted.
|
|
9
10
|
*/
|
|
10
|
-
export declare function StatTile({ label, value, hint, tone, accent, ...rest }: {
|
|
11
|
+
export declare function StatTile({ label, value, hint, tone, accent, trend, ...rest }: {
|
|
11
12
|
/** The small uppercase caption over the value. */
|
|
12
13
|
label: ReactNode;
|
|
13
14
|
/** The figure, already formatted, large and in tabular figures. */
|
|
@@ -18,4 +19,9 @@ export declare function StatTile({ label, value, hint, tone, accent, ...rest }:
|
|
|
18
19
|
tone?: StatTileTone;
|
|
19
20
|
/** A bar across the top edge in the tone's colour. */
|
|
20
21
|
accent?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* The figure over time, oldest first: a sparkline under the value, in its colour, on a scale
|
|
24
|
+
* from 0. It is decoration, so say the trend in `hint` as well.
|
|
25
|
+
*/
|
|
26
|
+
trend?: readonly number[];
|
|
21
27
|
} & Unstyled<Omit<HTMLAttributes<HTMLDivElement>, "children">>): ReactElement;
|
package/dist/StatTile.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { cx } from "./cx.js";
|
|
3
3
|
import { BAR_FILL } from "./ProgressBar.js";
|
|
4
|
+
import { drawn, largest, plotY, runs, scaleMax } from "./trend.js";
|
|
4
5
|
import { Overline } from "./typography.js";
|
|
5
6
|
const VALUE = {
|
|
6
7
|
neutral: "text-slate-900",
|
|
@@ -17,8 +18,18 @@ const BAR = {
|
|
|
17
18
|
/**
|
|
18
19
|
* One headline figure: an uppercase caption over a large tabular-nums value, with an optional
|
|
19
20
|
* hint line beneath. `tone` colours the value; `accent` adds a bar across the top edge in the same
|
|
20
|
-
* tone. Pass the value already
|
|
21
|
+
* tone. `trend` draws a small line under the value in the same colour. Pass the value already
|
|
22
|
+
* formatted.
|
|
21
23
|
*/
|
|
22
|
-
export function StatTile({ label, value, hint, tone = "neutral", accent = false, ...rest }) {
|
|
23
|
-
return (_jsxs("div", { ...rest, className: "relative overflow-hidden rounded-xl border border-slate-200/70 bg-white p-5 shadow-sm", children: [accent && (_jsx("span", { "aria-hidden": "true", className: cx("absolute inset-x-0 top-0 h-1", BAR[tone]) })), _jsx(Overline, { as: "p", children: label }), _jsx("p", { className: cx("mt-2 text-3xl font-bold tabular-nums", VALUE[tone]), children: value }), hint != null && _jsx("p", { className: "mt-1 text-xs text-slate-500", children: hint })] }));
|
|
24
|
+
export function StatTile({ label, value, hint, tone = "neutral", accent = false, trend, ...rest }) {
|
|
25
|
+
return (_jsxs("div", { ...rest, className: "relative overflow-hidden rounded-xl border border-slate-200/70 bg-white p-5 shadow-sm", children: [accent && (_jsx("span", { "aria-hidden": "true", className: cx("absolute inset-x-0 top-0 h-1", BAR[tone]) })), _jsx(Overline, { as: "p", children: label }), _jsx("p", { className: cx("mt-2 text-3xl font-bold tabular-nums", VALUE[tone]), children: value }), trend !== undefined && trend.length > 0 && _jsx(Sparkline, { values: trend, tone: tone }), hint != null && _jsx("p", { className: "mt-1 text-xs text-slate-500", children: hint })] }));
|
|
26
|
+
}
|
|
27
|
+
/** The tile's trend line: edge to edge, `h-8`, in the value's colour. Hidden from assistive tech. */
|
|
28
|
+
function Sparkline({ values, tone, }) {
|
|
29
|
+
const max = scaleMax(largest([values]));
|
|
30
|
+
const step = values.length > 1 ? 100 / (values.length - 1) : 0;
|
|
31
|
+
return (_jsx("svg", { "aria-hidden": "true", viewBox: "0 0 100 100", preserveAspectRatio: "none", className: cx("mt-3 block h-8 w-full overflow-visible", VALUE[tone]), children: runs(values.map((value, i) => {
|
|
32
|
+
const y = drawn(value);
|
|
33
|
+
return y === null ? null : { x: values.length > 1 ? step * i : 50, y: plotY(y, max) };
|
|
34
|
+
})).map((points) => (_jsx("polyline", { points: points, fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", vectorEffect: "non-scaling-stroke" }, points))) }));
|
|
24
35
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import type { Passthrough } from "./props.js";
|
|
3
|
+
import { type VizTone } from "./StackedBar.js";
|
|
4
|
+
/**
|
|
5
|
+
* One series of a `TrendChart`: its `label` (the table's column, the Legend's entry), its colour,
|
|
6
|
+
* and one value per bucket in the buckets' order. A `null`, or a list shorter than the buckets, is
|
|
7
|
+
* a missing value.
|
|
8
|
+
*/
|
|
9
|
+
export type TrendSeries = {
|
|
10
|
+
label: string;
|
|
11
|
+
tone: VizTone;
|
|
12
|
+
values: readonly (number | null)[];
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* A chart of one or two series over ordered buckets (weeks, months), as bars or a line, on a
|
|
16
|
+
* y-axis from 0. The y-axis is labelled 0, half and a rounded maximum; the x-axis names the
|
|
17
|
+
* buckets. A screen reader gets the same figures as a table.
|
|
18
|
+
*/
|
|
19
|
+
export declare function TrendChart({ label, buckets, series, kind, ...rest }: {
|
|
20
|
+
/** What the chart shows: its accessible name and the caption of its table. */
|
|
21
|
+
label: string;
|
|
22
|
+
/** The x-axis labels, in order (`"W12"`, `"Mar"`). */
|
|
23
|
+
buckets: readonly string[];
|
|
24
|
+
/**
|
|
25
|
+
* One or two series, each `{ label, tone, values }`. A `null` value, or a `values` list shorter
|
|
26
|
+
* than `buckets`, is missing. Two series draw a Legend under the chart.
|
|
27
|
+
*/
|
|
28
|
+
series: readonly [TrendSeries] | readonly [TrendSeries, TrendSeries];
|
|
29
|
+
/**
|
|
30
|
+
* `bar` for a count in each bucket, `line` for a level over time.
|
|
31
|
+
* @default bar
|
|
32
|
+
*/
|
|
33
|
+
kind?: "bar" | "line";
|
|
34
|
+
} & Passthrough): ReactElement;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Legend, VIZ_TONE } from "./StackedBar.js";
|
|
3
|
+
import { drawn, largest, plotY, round, runs, scaleMax } from "./trend.js";
|
|
4
|
+
/** The most x labels drawn; past it, every k-th label is, starting with the first. */
|
|
5
|
+
const MAX_X_LABELS = 8;
|
|
6
|
+
/** Share of a bucket's width the bars of one bucket take, centred; the rest is the gap between buckets. */
|
|
7
|
+
const BAR_SPAN = 0.7;
|
|
8
|
+
const FORMAT = new Intl.NumberFormat("en-US");
|
|
9
|
+
/** A value as the table says it: as given, or blank when missing. */
|
|
10
|
+
const spoken = (value) => value == null || !Number.isFinite(value) ? null : FORMAT.format(value);
|
|
11
|
+
/**
|
|
12
|
+
* A chart of one or two series over ordered buckets (weeks, months), as bars or a line, on a
|
|
13
|
+
* y-axis from 0. The y-axis is labelled 0, half and a rounded maximum; the x-axis names the
|
|
14
|
+
* buckets. A screen reader gets the same figures as a table.
|
|
15
|
+
*/
|
|
16
|
+
export function TrendChart({ label, buckets, series, kind = "bar", ...rest }) {
|
|
17
|
+
const values = series.map((s) => buckets.map((_, i) => drawn(s.values[i])));
|
|
18
|
+
const max = scaleMax(largest(values));
|
|
19
|
+
const ticks = [max, max / 2, 0];
|
|
20
|
+
return (_jsxs("figure", { ...rest, "aria-label": label, className: "flex flex-col gap-3", children: [_jsxs("div", { "aria-hidden": "true", className: "flex gap-2 pt-2 text-xs tabular-nums text-slate-500", children: [_jsx(YAxis, { ticks: ticks, max: max }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-1.5", children: [_jsxs("div", { className: "relative h-48", children: [ticks.map((tick) => (_jsx("span", { className: "absolute inset-x-0 border-t border-slate-200", style: { top: `${plotY(tick, max)}%` } }, tick))), _jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 size-full overflow-visible", children: kind === "bar" ? (_jsx(Bars, { series: series, values: values, max: max })) : (_jsx(Lines, { series: series, values: values, max: max })) })] }), _jsx(XAxis, { buckets: buckets })] })] }), series.length === 2 && (_jsx(Legend, { items: series.map((s) => ({ label: s.label, tone: s.tone })) })), _jsx(FigureTable, { label: label, buckets: buckets, series: series })] }));
|
|
21
|
+
}
|
|
22
|
+
/** The y labels, each centred on its gridline; the widest (the top) sets the column's width. */
|
|
23
|
+
function YAxis({ ticks, max }) {
|
|
24
|
+
return (_jsxs("div", { className: "relative h-48 shrink-0 text-right", children: [_jsx("span", { className: "invisible block", children: FORMAT.format(max) }), ticks.map((tick) => (_jsx("span", { className: "absolute right-0 -translate-y-1/2", style: { top: `${plotY(tick, max)}%` }, children: FORMAT.format(tick) }, tick)))] }));
|
|
25
|
+
}
|
|
26
|
+
/** One equal column per bucket; past 8 buckets, every k-th label from the first. */
|
|
27
|
+
function XAxis({ buckets }) {
|
|
28
|
+
const every = Math.max(1, Math.ceil(buckets.length / MAX_X_LABELS));
|
|
29
|
+
return (_jsx("div", { className: "grid text-center", style: { gridTemplateColumns: `repeat(${buckets.length}, minmax(0, 1fr))` }, children: buckets.map((bucket, i) => (_jsx("span", { className: "whitespace-nowrap", children: i % every === 0 ? bucket : null }, `${bucket}-${i}`))) }));
|
|
30
|
+
}
|
|
31
|
+
/** A rect per present value, the series side by side in each bucket. */
|
|
32
|
+
function Bars({ series, values, max }) {
|
|
33
|
+
const width = 100 / Math.max(1, values[0]?.length ?? 1);
|
|
34
|
+
const bar = (width * BAR_SPAN) / series.length;
|
|
35
|
+
const inset = (width * (1 - BAR_SPAN)) / 2;
|
|
36
|
+
return (_jsx(_Fragment, { children: series.map((s, si) => values[si]?.map((value, i) => {
|
|
37
|
+
if (value === null)
|
|
38
|
+
return null;
|
|
39
|
+
const y = plotY(value, max);
|
|
40
|
+
return (_jsx("rect", { "data-bucket": i, className: VIZ_TONE[s.tone], fill: "currentColor", x: round(width * i + inset + bar * si), y: round(y), width: round(bar), height: round(100 - y) }, `${si}-${i}`));
|
|
41
|
+
})) }));
|
|
42
|
+
}
|
|
43
|
+
/** A polyline per run of present values, through the middle of each bucket. */
|
|
44
|
+
function Lines({ series, values, max }) {
|
|
45
|
+
const width = 100 / Math.max(1, values[0]?.length ?? 1);
|
|
46
|
+
return (_jsx(_Fragment, { children: series.map((s, si) => runs((values[si] ?? []).map((value, i) => value === null ? null : { x: width * (i + 0.5), y: plotY(value, max) })).map((points) => (_jsx("polyline", { className: VIZ_TONE[s.tone], points: points, fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", vectorEffect: "non-scaling-stroke" }, `${si}-${points}`)))) }));
|
|
47
|
+
}
|
|
48
|
+
/** The chart's figures for a screen reader: a row per bucket, a column per series. */
|
|
49
|
+
function FigureTable({ label, buckets, series, }) {
|
|
50
|
+
return (_jsxs("table", { className: "sr-only", children: [_jsx("caption", { children: label }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("td", {}), series.map((s, si) => (_jsx("th", { scope: "col", children: s.label }, si)))] }) }), _jsx("tbody", { children: buckets.map((bucket, i) => (_jsxs("tr", { children: [_jsx("th", { scope: "row", children: bucket }), series.map((s, si) => (_jsx("td", { children: spoken(s.values[i]) }, si)))] }, `${bucket}-${i}`))) })] }));
|
|
51
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export { StatTile, type StatTileTone } from "./StatTile.js";
|
|
|
6
6
|
export { ProgressBar, type BarSize, type BarTone } from "./ProgressBar.js";
|
|
7
7
|
export { SplitBar } from "./SplitBar.js";
|
|
8
8
|
export { Legend, StackedBar, type LegendItem, type StackedSegment, type VizTone, } from "./StackedBar.js";
|
|
9
|
+
export { TrendChart, type TrendSeries } from "./TrendChart.js";
|
|
9
10
|
export { Markdown } from "./Markdown.js";
|
|
10
11
|
export { AppShell, AppShellBrand, type AppShellBreakpoint, AppShellHideBelow, appShellRootClassFor, } from "./AppShell.js";
|
|
11
12
|
export { Container } from "./Container.js";
|
|
@@ -83,5 +84,6 @@ export { TextLink, type TextLinkSize } from "./TextLink.js";
|
|
|
83
84
|
export { ToolCallCard } from "./ToolCallCard.js";
|
|
84
85
|
export { Caption, Display, Lead, Overline, PageHeading, Prose, SectionHeading, SubsectionHeading, type SubsectionHeadingSize, Text, type TextTone, type TypographyColor, type TypographyProps, } from "./typography.js";
|
|
85
86
|
export { DateField, type DateFieldProps } from "./DateField.js";
|
|
87
|
+
export { DateRangePicker, type DateRange, type DateRangePickerProps, type DateRangePreset, } from "./DateRangePicker.js";
|
|
86
88
|
export { fromISODate, toISODate } from "./isoDate.js";
|
|
87
89
|
export type { CalendarDate } from "@internationalized/date";
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ export { StatTile } from "./StatTile.js";
|
|
|
6
6
|
export { ProgressBar } from "./ProgressBar.js";
|
|
7
7
|
export { SplitBar } from "./SplitBar.js";
|
|
8
8
|
export { Legend, StackedBar, } from "./StackedBar.js";
|
|
9
|
+
export { TrendChart } from "./TrendChart.js";
|
|
9
10
|
export { Markdown } from "./Markdown.js";
|
|
10
11
|
export { AppShell, AppShellBrand, AppShellHideBelow, appShellRootClassFor, } from "./AppShell.js";
|
|
11
12
|
export { Container } from "./Container.js";
|
|
@@ -79,4 +80,5 @@ export { TextLink } from "./TextLink.js";
|
|
|
79
80
|
export { ToolCallCard } from "./ToolCallCard.js";
|
|
80
81
|
export { Caption, Display, Lead, Overline, PageHeading, Prose, SectionHeading, SubsectionHeading, Text, } from "./typography.js";
|
|
81
82
|
export { DateField } from "./DateField.js";
|
|
83
|
+
export { DateRangePicker, } from "./DateRangePicker.js";
|
|
82
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/dist/trend.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scale shared by `TrendChart` and the `StatTile` sparkline. Internal: not exported from
|
|
3
|
+
* `index.ts`.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* The top of a y-axis that starts at 0: the smallest of 2, 4 or 10 times a whole power of ten
|
|
7
|
+
* (1, 10, 100, …) that is at least `top`. Its half is then a whole number: 1 gives 2, 3 gives
|
|
8
|
+
* 4, 5 gives 10, 13 gives 20. Nothing above 0 gives 2.
|
|
9
|
+
*/
|
|
10
|
+
export declare function scaleMax(top: number): number;
|
|
11
|
+
/** A value as drawn: a negative one is 0, and a missing or non-finite one is `null`. */
|
|
12
|
+
export declare function drawn(value: number | null | undefined): number | null;
|
|
13
|
+
/** The largest drawn value of every list, or 0. */
|
|
14
|
+
export declare function largest(lists: readonly (readonly (number | null)[])[]): number;
|
|
15
|
+
/** `value` as a height on a 0 to 100 plot, measured down from the top (SVG's y). */
|
|
16
|
+
export declare function plotY(value: number, max: number): number;
|
|
17
|
+
/**
|
|
18
|
+
* The polylines of a line through `points`, broken at every missing one: one `points` string per
|
|
19
|
+
* run of present values. A run of one point repeats it, so a round cap draws it as a dot.
|
|
20
|
+
*/
|
|
21
|
+
export declare function runs(points: readonly ({
|
|
22
|
+
x: number;
|
|
23
|
+
y: number;
|
|
24
|
+
} | null)[]): string[];
|
|
25
|
+
/** Four decimals, so the markup stays short and stable. */
|
|
26
|
+
export declare function round(n: number): number;
|
package/dist/trend.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scale shared by `TrendChart` and the `StatTile` sparkline. Internal: not exported from
|
|
3
|
+
* `index.ts`.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* The top of a y-axis that starts at 0: the smallest of 2, 4 or 10 times a whole power of ten
|
|
7
|
+
* (1, 10, 100, …) that is at least `top`. Its half is then a whole number: 1 gives 2, 3 gives
|
|
8
|
+
* 4, 5 gives 10, 13 gives 20. Nothing above 0 gives 2.
|
|
9
|
+
*/
|
|
10
|
+
export function scaleMax(top) {
|
|
11
|
+
for (let power = 1;; power *= 10) {
|
|
12
|
+
for (const step of [2, 4, 10]) {
|
|
13
|
+
if (step * power >= top)
|
|
14
|
+
return step * power;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** A value as drawn: a negative one is 0, and a missing or non-finite one is `null`. */
|
|
19
|
+
export function drawn(value) {
|
|
20
|
+
if (value == null || !Number.isFinite(value))
|
|
21
|
+
return null;
|
|
22
|
+
return Math.max(0, value);
|
|
23
|
+
}
|
|
24
|
+
/** The largest drawn value of every list, or 0. */
|
|
25
|
+
export function largest(lists) {
|
|
26
|
+
let top = 0;
|
|
27
|
+
for (const list of lists) {
|
|
28
|
+
for (const value of list)
|
|
29
|
+
top = Math.max(top, drawn(value) ?? 0);
|
|
30
|
+
}
|
|
31
|
+
return top;
|
|
32
|
+
}
|
|
33
|
+
/** `value` as a height on a 0 to 100 plot, measured down from the top (SVG's y). */
|
|
34
|
+
export function plotY(value, max) {
|
|
35
|
+
return 100 - (value / max) * 100;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The polylines of a line through `points`, broken at every missing one: one `points` string per
|
|
39
|
+
* run of present values. A run of one point repeats it, so a round cap draws it as a dot.
|
|
40
|
+
*/
|
|
41
|
+
export function runs(points) {
|
|
42
|
+
const out = [];
|
|
43
|
+
let run = [];
|
|
44
|
+
const close = () => {
|
|
45
|
+
const [only] = run;
|
|
46
|
+
if (run.length === 1 && only !== undefined)
|
|
47
|
+
run.push(only);
|
|
48
|
+
if (run.length > 0)
|
|
49
|
+
out.push(run.join(" "));
|
|
50
|
+
run = [];
|
|
51
|
+
};
|
|
52
|
+
for (const point of points) {
|
|
53
|
+
if (point === null)
|
|
54
|
+
close();
|
|
55
|
+
else
|
|
56
|
+
run.push(`${round(point.x)},${round(point.y)}`);
|
|
57
|
+
}
|
|
58
|
+
close();
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/** Four decimals, so the markup stays short and stable. */
|
|
62
|
+
export function round(n) {
|
|
63
|
+
return Math.round(n * 10000) / 10000;
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hackcove/ui-react",
|
|
3
|
-
"version": "0.10.
|
|
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",
|