@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.
@@ -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
+ }
@@ -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;
@@ -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",
@@ -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 formatted.
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 formatted.
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";
@@ -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;
@@ -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.4",
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",