@usableapp/cardds 0.7.107 → 0.7.109

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,4 +1,5 @@
1
1
  import { type ComponentProps, type ReactNode } from 'react';
2
+ import { type Span } from './span.js';
2
3
  export interface CalendarProps extends Omit<ComponentProps<'div'>, 'title' | 'onSelect'> {
3
4
  /** the month shown ("June") */
4
5
  title: ReactNode;
@@ -12,6 +13,12 @@ export interface CalendarProps extends Omit<ComponentProps<'div'>, 'title' | 'on
12
13
  defaultSelected?: number;
13
14
  /** a day was tapped */
14
15
  onSelect?: (day: number) => void;
16
+ /** CONTROLLED, a run of days instead of one: `[first, last]` (the same day twice for one), or `null` for none; pair it with `onSpanChange`. Given (or `defaultSpan` / `onSpanChange`), the calendar is a span pick: a drag across the days covers them, or a tap on the first and a tap on the last; `selected` is not read */
17
+ span?: Span | null;
18
+ /** UNCONTROLLED: the run covered on arrival; the calendar then keeps its own */
19
+ defaultSpan?: Span;
20
+ /** the member covered a run: the finger lifted after a drag, or the second tap closed it */
21
+ onSpanChange?: (span: Span) => void;
15
22
  /** the previous-month button was tapped: the app turns the month (it owns `title`, `days`, `start`) */
16
23
  onPrev?: () => void;
17
24
  /** the next-month button was tapped: the app turns the month, as with `onPrev` */
@@ -24,7 +31,7 @@ export interface CalendarProps extends Omit<ComponentProps<'div'>, 'title' | 'on
24
31
  marks?: number[];
25
32
  /** strongly marked days */
26
33
  marks2?: number[];
27
- /** days that cannot be picked */
34
+ /** days that cannot be picked (a span never covers one: the run stops before it) */
28
35
  disabled?: number[];
29
36
  /** weekday headers, 7 */
30
37
  weekdays?: string[];
@@ -32,5 +39,7 @@ export interface CalendarProps extends Omit<ComponentProps<'div'>, 'title' | 'on
32
39
  nav?: ReactNode;
33
40
  }
34
41
  /** Calendar — a month grid of 44px round cells: one accent day, optional marks; prev/next in the head. It shows ONE month and knows no dates:
35
- * the app owns which month (`title`, `days`, `start` + `onPrev` / `onNext`) and which day (`selected` + `onSelect`, or `defaultSelected`). On a sheet, never in a Card: the sheet is the paper the member fills in (Lh 2026-10-18). */
36
- export declare function Calendar({ title, days, start, selected, defaultSelected, onSelect, onPrev, onNext, prevLabel, nextLabel, marks, marks2, disabled, weekdays, nav, className, ...rest }: CalendarProps): import("react").JSX.Element;
42
+ * the app owns which month (`title`, `days`, `start` + `onPrev` / `onNext`) and which day (`selected` + `onSelect`, or `defaultSelected`).
43
+ * A run of days instead of one (the asker says from when to when, Lh 2026-10-21): `span` + `onSpanChange`, or `defaultSpan` — a drag across
44
+ * the days covers them, or a tap on the first day and a tap on the last; the whole run is one highlight, as a finger drags over words. On a sheet, never in a Card: the sheet is the paper the member fills in (Lh 2026-10-18). */
45
+ export declare function Calendar({ title, days, start, selected, defaultSelected, onSelect, span, defaultSpan, onSpanChange, onPrev, onNext, prevLabel, nextLabel, marks, marks2, disabled, weekdays, nav, className, ...rest }: CalendarProps): import("react").JSX.Element;
@@ -4,12 +4,24 @@ import { cx } from '../cx.js';
4
4
  import { IconBtn } from '../actions/IconBtn.js';
5
5
  import { word } from '../words.js';
6
6
  import { calendarDay } from './calendarDay.js';
7
+ import { useSpan } from './span.js';
7
8
  /** Calendar — a month grid of 44px round cells: one accent day, optional marks; prev/next in the head. It shows ONE month and knows no dates:
8
- * the app owns which month (`title`, `days`, `start` + `onPrev` / `onNext`) and which day (`selected` + `onSelect`, or `defaultSelected`). On a sheet, never in a Card: the sheet is the paper the member fills in (Lh 2026-10-18). */
9
- export function Calendar({ title, days = 30, start = 1, selected, defaultSelected, onSelect, onPrev, onNext, prevLabel = word('previous'), nextLabel = word('next'), marks = [], marks2 = [], disabled = [], weekdays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'], nav, className, ...rest }) {
9
+ * the app owns which month (`title`, `days`, `start` + `onPrev` / `onNext`) and which day (`selected` + `onSelect`, or `defaultSelected`).
10
+ * A run of days instead of one (the asker says from when to when, Lh 2026-10-21): `span` + `onSpanChange`, or `defaultSpan` — a drag across
11
+ * the days covers them, or a tap on the first day and a tap on the last; the whole run is one highlight, as a finger drags over words. On a sheet, never in a Card: the sheet is the paper the member fills in (Lh 2026-10-18). */
12
+ export function Calendar({ title, days = 30, start = 1, selected, defaultSelected, onSelect, span, defaultSpan, onSpanChange, onPrev, onNext, prevLabel = word('previous'), nextLabel = word('next'), marks = [], marks2 = [], disabled = [], weekdays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'], nav, className, ...rest }) {
10
13
  const [day, pick] = useControlled(selected, defaultSelected, onSelect);
11
- return (_jsxs("div", { className: cx('calendar', className), ...rest, children: [_jsxs("div", { className: "calendar__head", children: [_jsx("span", { className: "t-title", children: title }), nav ?? (_jsxs("span", { className: "icon-row", children: [_jsx(IconBtn, { sm: true, icon: "chevron-left", label: prevLabel, onClick: onPrev }), _jsx(IconBtn, { sm: true, icon: "chevron-right", label: nextLabel, onClick: onNext })] }))] }), _jsxs("div", { className: "calendar__grid", children: [weekdays.map((d) => _jsx("span", { className: "calendar__wd", children: d }, d)), Array.from({ length: days }, (_, i) => i + 1).map((d) => (calendarDay({
12
- key: d, num: d, mark: marks.includes(d), mark2: marks2.includes(d), selected: d === day, disabled: disabled.includes(d),
13
- style: d === 1 ? { '--start': start } : undefined, onPick: () => pick(d),
14
+ const spanning = span !== undefined || defaultSpan !== undefined || onSpanChange !== undefined;
15
+ // the hook counts cells from 0; the app counts days from 1
16
+ const run = useSpan({
17
+ span: span ? [span[0] - 1, span[1] - 1] : span, defaultSpan: defaultSpan && [defaultSpan[0] - 1, defaultSpan[1] - 1],
18
+ onSpanChange: onSpanChange && ((s) => onSpanChange([s[0] + 1, s[1] + 1])),
19
+ blocked: (i) => i < 0 || i >= days || disabled.includes(i + 1),
20
+ });
21
+ return (_jsxs("div", { className: cx('calendar', spanning && 'calendar--span', className), ...rest, children: [_jsxs("div", { className: "calendar__head", children: [_jsx("span", { className: "t-title", children: title }), nav ?? (_jsxs("span", { className: "icon-row", children: [_jsx(IconBtn, { sm: true, icon: "chevron-left", label: prevLabel, onClick: onPrev }), _jsx(IconBtn, { sm: true, icon: "chevron-right", label: nextLabel, onClick: onNext })] }))] }), _jsxs("div", { className: "calendar__grid", ...(spanning ? run.box : {}), children: [weekdays.map((d) => _jsx("span", { className: "calendar__wd", children: d }, d)), Array.from({ length: days }, (_, i) => i + 1).map((d) => (calendarDay({
22
+ key: d, num: d, mark: marks.includes(d), mark2: marks2.includes(d), selected: !spanning && d === day, disabled: disabled.includes(d),
23
+ style: d === 1 ? { '--start': start } : undefined,
24
+ onPick: () => (spanning ? run.tap(d - 1) : pick(d)),
25
+ ...(spanning ? { index: d - 1, span: run.at(d - 1) } : {}),
14
26
  })))] })] }));
15
27
  }
@@ -28,6 +28,12 @@ export interface CalendarWeeksProps extends Omit<ComponentProps<'div'>, 'onSelec
28
28
  defaultSelected?: string;
29
29
  /** a day was tapped: its `key` */
30
30
  onSelect?: (key: string) => void;
31
+ /** CONTROLLED, a run of days instead of one: `[first, last]` by `key` (the same key twice for one day), or `null` for none; pair it with `onSpanChange`. Given (or `defaultSpan` / `onSpanChange`), the weeks are a span pick: a tap on the first day and a tap on the last covers the days between, across the weeks; a drag along a row covers it too (up and down, the box scrolls); `selected` is not read */
32
+ span?: [string, string] | null;
33
+ /** UNCONTROLLED: the run covered on arrival; the weeks then keep their own */
34
+ defaultSpan?: [string, string];
35
+ /** the member covered a run: the finger lifted after a drag, or the second tap closed it */
36
+ onSpanChange?: (span: [string, string]) => void;
31
37
  /** weekday heads, 7 */
32
38
  weekdays?: string[];
33
39
  /** given, the label is a button: the app opens its own month / year pick. Not given, the label is plain words */
@@ -37,5 +43,7 @@ export interface CalendarWeeksProps extends Omit<ComponentProps<'div'>, 'onSelec
37
43
  * the month · year of the first row in sight and the weekday heads. It knows no dates, as Calendar: the app builds `weeks` (each a `label` and 7 days
38
44
  * with `key`, `num`, `today`, `disabled`, `mark`) and owns the day (`selected` + `onSelect`, or `defaultSelected`); a tap on the label (`onLabelClick`)
39
45
  * is the app's to answer with its month / year pick. It opens at the week that holds today (Lh 2026-10-19): the first row in sight is the week
40
- * whose day has `today`, at once and without a scroll to watch; with no `today` in the data it opens at the first week. For a day within the coming weeks on a step with little height. On a sheet, never in a Card. */
41
- export declare function CalendarWeeks({ weeks, show, selected, defaultSelected, onSelect, weekdays, onLabelClick, className, ref, ...rest }: CalendarWeeksProps): import("react").JSX.Element;
46
+ * whose day has `today`, at once and without a scroll to watch; with no `today` in the data it opens at the first week. For a day within the coming weeks on a step with little height.
47
+ * A run of days instead of one (the asker says from when to when, Lh 2026-10-21): `span` + `onSpanChange`, or `defaultSpan` — a tap on the first day
48
+ * and a tap on the last covers the days between, across the weeks; a drag along a row covers it too, while a finger moving up or down still scrolls the box. On a sheet, never in a Card. */
49
+ export declare function CalendarWeeks({ weeks, show, selected, defaultSelected, onSelect, span, defaultSpan, onSpanChange, weekdays, onLabelClick, className, ref, ...rest }: CalendarWeeksProps): import("react").JSX.Element;
@@ -3,13 +3,20 @@ import { useLayoutEffect, useRef, useState } from 'react';
3
3
  import { useControlled, setRefs } from '../state.js';
4
4
  import { cx } from '../cx.js';
5
5
  import { calendarDay } from './calendarDay.js';
6
+ import { useSpan, spanByKey } from './span.js';
6
7
  /** CalendarWeeks — a run of weeks that scrolls week by week, `show` rows in sight (2 by default), Calendar's own 44px round cells; a sticky head holds
7
8
  * the month · year of the first row in sight and the weekday heads. It knows no dates, as Calendar: the app builds `weeks` (each a `label` and 7 days
8
9
  * with `key`, `num`, `today`, `disabled`, `mark`) and owns the day (`selected` + `onSelect`, or `defaultSelected`); a tap on the label (`onLabelClick`)
9
10
  * is the app's to answer with its month / year pick. It opens at the week that holds today (Lh 2026-10-19): the first row in sight is the week
10
- * whose day has `today`, at once and without a scroll to watch; with no `today` in the data it opens at the first week. For a day within the coming weeks on a step with little height. On a sheet, never in a Card. */
11
- export function CalendarWeeks({ weeks, show = 2, selected, defaultSelected, onSelect, weekdays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'], onLabelClick, className, ref, ...rest }) {
11
+ * whose day has `today`, at once and without a scroll to watch; with no `today` in the data it opens at the first week. For a day within the coming weeks on a step with little height.
12
+ * A run of days instead of one (the asker says from when to when, Lh 2026-10-21): `span` + `onSpanChange`, or `defaultSpan` — a tap on the first day
13
+ * and a tap on the last covers the days between, across the weeks; a drag along a row covers it too, while a finger moving up or down still scrolls the box. On a sheet, never in a Card. */
14
+ export function CalendarWeeks({ weeks, show = 2, selected, defaultSelected, onSelect, span, defaultSpan, onSpanChange, weekdays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'], onLabelClick, className, ref, ...rest }) {
12
15
  const [day, pick] = useControlled(selected, defaultSelected, onSelect);
16
+ const spanning = span !== undefined || defaultSpan !== undefined || onSpanChange !== undefined;
17
+ const all = weeks.flatMap((w) => w.days);
18
+ const covered = useSpan({ ...spanByKey(all.map((d) => d.key), span, defaultSpan, onSpanChange), blocked: (i) => !all[i] || !!all[i].disabled });
19
+ let at = 0;
13
20
  const [first, setFirst] = useState(0);
14
21
  const box = useRef(null);
15
22
  // the box opens at today's week whenever the run itself is new (its days, not its identity: an app that rebuilds `weeks` on each render
@@ -58,7 +65,14 @@ export function CalendarWeeks({ weeks, show = 2, selected, defaultSelected, onSe
58
65
  return () => { watch.disconnect(); el.removeEventListener('scroll', onScroll); cancelAnimationFrame(frame); };
59
66
  }, [show, run, todayAt]);
60
67
  const label = weeks[Math.min(first, weeks.length - 1)]?.label;
61
- return (_jsxs("div", { ref: setRefs(box, ref), className: cx('calendar', 'calendar--weeks', className), ...rest, children: [_jsxs("div", { className: "calendar__weeks-head", children: [onLabelClick
68
+ return (_jsxs("div", { ref: setRefs(box, ref), className: cx('calendar', 'calendar--weeks', spanning && 'calendar--span', className), ...rest, ...(spanning ? covered.box : {}), children: [_jsxs("div", { className: "calendar__weeks-head", children: [onLabelClick
62
69
  ? _jsx("button", { type: "button", className: "calendar__label", onClick: onLabelClick, children: label })
63
- : _jsx("span", { className: "calendar__label", children: label }), _jsx("div", { className: "calendar__grid", children: weekdays.map((d) => _jsx("span", { className: "calendar__wd", children: d }, d)) })] }), weeks.map((w, i) => (_jsx("div", { className: "calendar__grid calendar__week", children: w.days.map((d) => calendarDay({ key: d.key, num: d.num, mark: d.mark === 'soft', mark2: d.mark === 'strong', selected: d.key === day, today: d.today, disabled: d.disabled, onPick: () => pick(d.key) })) }, w.days[0]?.key ?? i)))] }));
70
+ : _jsx("span", { className: "calendar__label", children: label }), _jsx("div", { className: "calendar__grid", children: weekdays.map((d) => _jsx("span", { className: "calendar__wd", children: d }, d)) })] }), weeks.map((w, i) => (_jsx("div", { className: "calendar__grid calendar__week", children: w.days.map((d) => {
71
+ const i = at++;
72
+ return calendarDay({
73
+ key: d.key, num: d.num, mark: d.mark === 'soft', mark2: d.mark === 'strong', selected: !spanning && d.key === day, today: d.today, disabled: d.disabled,
74
+ onPick: () => (spanning ? covered.tap(i) : pick(d.key)),
75
+ ...(spanning ? { index: i, span: covered.at(i) } : {}),
76
+ });
77
+ }) }, w.days[0]?.key ?? i)))] }));
64
78
  }
@@ -0,0 +1,36 @@
1
+ import { type ComponentProps, type ReactNode } from 'react';
2
+ export interface Hour {
3
+ /** the app's id for the hour ("09:00"): what `onSpanChange` and `onSelect` hand back */
4
+ key: string;
5
+ /** what the cell shows ("9", "๙") */
6
+ num: ReactNode;
7
+ /** the hour cannot be picked (already past, outside the asked span) */
8
+ disabled?: boolean;
9
+ /** a soft mark (the asked hours) or a strong one (booked) */
10
+ mark?: 'soft' | 'strong';
11
+ }
12
+ export interface HourGridProps extends Omit<ComponentProps<'div'>, 'onSelect'> {
13
+ /** the hours, in order; the app builds them (which hours, in whose numerals) */
14
+ hours: Hour[];
15
+ /** the words over the grid: what the numbers are ("hour of the day", "น.") */
16
+ label?: ReactNode;
17
+ /** CONTROLLED, from when to when: `[first, last]` by `key` (the same key twice for one hour), or `null` for none; pair it with `onSpanChange`. Given (or `defaultSpan` / `onSpanChange`), a drag across the hours covers them, or a tap on the first and a tap on the last; `selected` is not read */
18
+ span?: [string, string] | null;
19
+ /** UNCONTROLLED: the run covered on arrival; the grid then keeps its own */
20
+ defaultSpan?: [string, string];
21
+ /** the member covered a run: the finger lifted after a drag, or the second tap closed it */
22
+ onSpanChange?: (span: [string, string]) => void;
23
+ /** CONTROLLED, one hour: the picked hour's `key`, or `null` for none; pair it with `onSelect` */
24
+ selected?: string | null;
25
+ /** UNCONTROLLED: the hour picked on arrival */
26
+ defaultSelected?: string;
27
+ /** an hour was tapped: its `key` */
28
+ onSelect?: (key: string) => void;
29
+ }
30
+ /** HourGrid — the hours of a day as Calendar's own 44px round cells, `--hour-grid-cols` to a row (6 by default), with a label over them. From when
31
+ * to when (the asker's hours, Lh 2026-10-21): `span` + `onSpanChange`, or `defaultSpan` — one drag across the hours covers them, or a tap on the
32
+ * first and a tap on the last; the whole run is one highlight, and it never jumps a disabled hour.
33
+ * One hour instead: `selected` + `onSelect`, or `defaultSelected`. The helper answers within the asked hours: the app marks them (`mark: 'soft'`)
34
+ * and disables the rest, and the helper's own span stays inside. It knows no clock: the app builds `hours` (`key`, `num`, `disabled`, `mark`).
35
+ * On a sheet, never in a Card. */
36
+ export declare function HourGrid({ hours, label, span, defaultSpan, onSpanChange, selected, defaultSelected, onSelect, className, ...rest }: HourGridProps): import("react").JSX.Element;
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useControlled } from '../state.js';
3
+ import { cx } from '../cx.js';
4
+ import { calendarDay } from './calendarDay.js';
5
+ import { useSpan, spanByKey } from './span.js';
6
+ /** HourGrid — the hours of a day as Calendar's own 44px round cells, `--hour-grid-cols` to a row (6 by default), with a label over them. From when
7
+ * to when (the asker's hours, Lh 2026-10-21): `span` + `onSpanChange`, or `defaultSpan` — one drag across the hours covers them, or a tap on the
8
+ * first and a tap on the last; the whole run is one highlight, and it never jumps a disabled hour.
9
+ * One hour instead: `selected` + `onSelect`, or `defaultSelected`. The helper answers within the asked hours: the app marks them (`mark: 'soft'`)
10
+ * and disables the rest, and the helper's own span stays inside. It knows no clock: the app builds `hours` (`key`, `num`, `disabled`, `mark`).
11
+ * On a sheet, never in a Card. */
12
+ export function HourGrid({ hours, label, span, defaultSpan, onSpanChange, selected, defaultSelected, onSelect, className, ...rest }) {
13
+ const spanning = span !== undefined || defaultSpan !== undefined || onSpanChange !== undefined;
14
+ const [hour, pick] = useControlled(selected, defaultSelected, onSelect);
15
+ const run = useSpan({ ...spanByKey(hours.map((h) => h.key), span, defaultSpan, onSpanChange), blocked: (i) => !hours[i] || !!hours[i].disabled });
16
+ return (_jsxs("div", { className: cx('calendar', 'hour-grid', spanning && 'calendar--span', className), ...rest, children: [label != null && _jsx("span", { className: "calendar__label", children: label }), _jsx("div", { className: "calendar__grid", ...(spanning ? run.box : {}), children: hours.map((h, i) => calendarDay({
17
+ key: h.key, num: h.num, mark: h.mark === 'soft', mark2: h.mark === 'strong', selected: !spanning && h.key === hour, disabled: h.disabled,
18
+ onPick: () => (spanning ? run.tap(i) : pick(h.key)),
19
+ ...(spanning ? { index: i, span: run.at(i) } : {}),
20
+ })) })] }));
21
+ }
@@ -1,6 +1,9 @@
1
1
  import { type CSSProperties, type ReactNode } from 'react';
2
- /** one `.calendar__day` cell: the button Calendar and CalendarWeeks both draw, so the cell and every `--calendar-day-*` knob are one */
3
- export declare function calendarDay({ key, num, mark, mark2, selected, today, disabled, style, onPick }: {
2
+ import type { SpanAt } from './span.js';
3
+ /** one `.calendar__day` cell: the button Calendar, CalendarWeeks and HourGrid all draw, so the cell and every `--calendar-day-*` knob are one.
4
+ * In a span (`index` given): the cell carries its order for the span mechanic, and `span` says where it stands under the one highlight
5
+ * (`aria-pressed` says whether it is covered); a key on the cell (not a pointer, which the grid handles) is a tap */
6
+ export declare function calendarDay({ key, num, mark, mark2, selected, today, disabled, style, onPick, index, span }: {
4
7
  key: string | number;
5
8
  num: ReactNode;
6
9
  mark?: boolean;
@@ -10,4 +13,6 @@ export declare function calendarDay({ key, num, mark, mark2, selected, today, di
10
13
  disabled?: boolean;
11
14
  style?: CSSProperties;
12
15
  onPick: () => void;
16
+ index?: number;
17
+ span?: SpanAt;
13
18
  }): import("react").JSX.Element;
@@ -1,6 +1,9 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { cx } from '../cx.js';
3
- /** one `.calendar__day` cell: the button Calendar and CalendarWeeks both draw, so the cell and every `--calendar-day-*` knob are one */
4
- export function calendarDay({ key, num, mark, mark2, selected, today, disabled, style, onPick }) {
5
- return (_jsx("button", { type: "button", className: cx('calendar__day', mark && 'calendar__day--mark', mark2 && 'calendar__day--mark2'), "aria-selected": selected ? 'true' : undefined, "aria-current": today ? 'date' : undefined, onClick: onPick, disabled: disabled, style: style, children: num }, key));
3
+ /** one `.calendar__day` cell: the button Calendar, CalendarWeeks and HourGrid all draw, so the cell and every `--calendar-day-*` knob are one.
4
+ * In a span (`index` given): the cell carries its order for the span mechanic, and `span` says where it stands under the one highlight
5
+ * (`aria-pressed` says whether it is covered); a key on the cell (not a pointer, which the grid handles) is a tap */
6
+ export function calendarDay({ key, num, mark, mark2, selected, today, disabled, style, onPick, index, span }) {
7
+ return (_jsx("button", { type: "button", className: cx('calendar__day', mark && 'calendar__day--mark', mark2 && 'calendar__day--mark2', span && `calendar__day--span-${span}`), "aria-selected": selected ? 'true' : undefined, "aria-pressed": index === undefined ? undefined : span ? 'true' : 'false', "aria-current": today ? 'date' : undefined, onClick: index === undefined ? onPick : (e) => { if (e.detail === 0)
8
+ onPick(); }, disabled: disabled, style: style, "data-span": index, children: num }, key));
6
9
  }
@@ -0,0 +1,34 @@
1
+ import { type PointerEvent as ReactPointerEvent } from 'react';
2
+ /** a run of cells, by their index in the holder's order: `[first, last]`, the same index twice for one cell */
3
+ export type Span = [number, number];
4
+ /** where a cell stands in the span: the one cell of a one-cell span, the first, one between, the last — or nowhere */
5
+ export type SpanAt = 'one' | 'start' | 'in' | 'end' | undefined;
6
+ /** the span mechanic, written once for Calendar, CalendarWeeks and HourGrid (Lh 2026-10-21: the asker says from when to when).
7
+ * The holder numbers its cells in order (`data-span` = the index), spreads `box` on the grid, and asks `at(i)` for each cell's class.
8
+ * One drag across the cells covers them: the first cell under the finger is one end, the last the other, whichever way the finger
9
+ * went. Two taps do the same without a drag: a tap makes a one-cell span, a second tap on another cell closes it (a third starts over).
10
+ * A cell that is `blocked` (disabled) is never covered: the run stops before it, so a span never jumps a day that cannot be picked.
11
+ * Controlled like an input: `span` + `onSpanChange`, or `defaultSpan`; while the finger is down the cells show the live run and the
12
+ * app is asked once, when it lifts. A pointer the browser takes away (the box scrolled instead) covers nothing. */
13
+ export declare function useSpan({ span, defaultSpan, onSpanChange, blocked }: {
14
+ span: Span | null | undefined;
15
+ defaultSpan?: Span;
16
+ onSpanChange?: (span: Span) => void;
17
+ blocked: (i: number) => boolean;
18
+ }): {
19
+ span: Span | null;
20
+ at: (i: number) => SpanAt;
21
+ tap: (i: number) => void;
22
+ box: {
23
+ onPointerDown: (e: ReactPointerEvent<HTMLElement>) => void;
24
+ onPointerMove: (e: ReactPointerEvent<HTMLElement>) => void;
25
+ onPointerUp: () => void;
26
+ onPointerCancel: () => void;
27
+ };
28
+ };
29
+ /** the same span by the holder's keys (CalendarWeeks, HourGrid): useSpan's `span`, `defaultSpan` and `onSpanChange` from a `[key, key]` span, the ask handed back in keys */
30
+ export declare function spanByKey(keys: string[], span: [string, string] | null | undefined, defaultSpan: [string, string] | undefined, onSpanChange: ((span: [string, string]) => void) | undefined): {
31
+ span: Span | null | undefined;
32
+ defaultSpan: Span | undefined;
33
+ onSpanChange: ((s: Span) => void) | undefined;
34
+ };
@@ -0,0 +1,80 @@
1
+ import { useRef, useState } from 'react';
2
+ import { useControlled } from '../state.js';
3
+ const sorted = (a, b) => (a <= b ? [a, b] : [b, a]);
4
+ /** the span mechanic, written once for Calendar, CalendarWeeks and HourGrid (Lh 2026-10-21: the asker says from when to when).
5
+ * The holder numbers its cells in order (`data-span` = the index), spreads `box` on the grid, and asks `at(i)` for each cell's class.
6
+ * One drag across the cells covers them: the first cell under the finger is one end, the last the other, whichever way the finger
7
+ * went. Two taps do the same without a drag: a tap makes a one-cell span, a second tap on another cell closes it (a third starts over).
8
+ * A cell that is `blocked` (disabled) is never covered: the run stops before it, so a span never jumps a day that cannot be picked.
9
+ * Controlled like an input: `span` + `onSpanChange`, or `defaultSpan`; while the finger is down the cells show the live run and the
10
+ * app is asked once, when it lifts. A pointer the browser takes away (the box scrolled instead) covers nothing. */
11
+ export function useSpan({ span, defaultSpan, onSpanChange, blocked }) {
12
+ const [held, request] = useControlled(span, defaultSpan ?? null, onSpanChange);
13
+ const [live, setLive] = useState(null);
14
+ const drag = useRef(null);
15
+ // the farthest cell toward `to` the run from `from` reaches before a blocked cell
16
+ const reach = (from, to) => {
17
+ const step = to < from ? -1 : 1;
18
+ let at = from;
19
+ while (at !== to && !blocked(at + step))
20
+ at += step;
21
+ return at;
22
+ };
23
+ const cellAt = (box, x, y) => {
24
+ const cell = document.elementFromPoint(x, y)?.closest('[data-span]');
25
+ return cell && box.contains(cell) ? Number(cell.dataset.span) : null;
26
+ };
27
+ // a tap on `i` (a finger that did not move, or a key on the cell): the second end of a one-cell span, else a new one-cell span
28
+ const tap = (i) => {
29
+ const one = held && held[0] === held[1] ? held[0] : null;
30
+ request(one !== null && one !== i ? sorted(one, reach(one, i)) : [i, i]);
31
+ };
32
+ const onPointerDown = (e) => {
33
+ const cell = e.target.closest('[data-span]');
34
+ if (!cell || !e.currentTarget.contains(cell))
35
+ return;
36
+ const i = Number(cell.dataset.span);
37
+ if (blocked(i))
38
+ return;
39
+ e.currentTarget.setPointerCapture(e.pointerId);
40
+ drag.current = { from: i, to: i, moved: false };
41
+ setLive([i, i]);
42
+ };
43
+ const onPointerMove = (e) => {
44
+ const d = drag.current;
45
+ if (!d)
46
+ return;
47
+ const i = cellAt(e.currentTarget, e.clientX, e.clientY);
48
+ if (i === null || i === d.to)
49
+ return;
50
+ d.moved = true;
51
+ d.to = reach(d.from, i);
52
+ setLive(sorted(d.from, d.to));
53
+ };
54
+ const onPointerUp = () => {
55
+ const d = drag.current;
56
+ if (!d)
57
+ return;
58
+ drag.current = null;
59
+ setLive(null);
60
+ if (d.moved)
61
+ request(sorted(d.from, d.to));
62
+ else
63
+ tap(d.from);
64
+ };
65
+ const onPointerCancel = () => { drag.current = null; setLive(null); };
66
+ const shown = live ?? held ?? null;
67
+ const at = (i) => {
68
+ if (!shown)
69
+ return undefined;
70
+ if (shown[0] === shown[1])
71
+ return i === shown[0] ? 'one' : undefined;
72
+ return i === shown[0] ? 'start' : i === shown[1] ? 'end' : i > shown[0] && i < shown[1] ? 'in' : undefined;
73
+ };
74
+ return { span: shown, at, tap, box: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel } };
75
+ }
76
+ /** the same span by the holder's keys (CalendarWeeks, HourGrid): useSpan's `span`, `defaultSpan` and `onSpanChange` from a `[key, key]` span, the ask handed back in keys */
77
+ export function spanByKey(keys, span, defaultSpan, onSpanChange) {
78
+ const of = (s) => (s ? [keys.indexOf(s[0]), keys.indexOf(s[1])] : s);
79
+ return { span: of(span), defaultSpan: of(defaultSpan) ?? undefined, onSpanChange: onSpanChange && ((s) => onSpanChange([keys[s[0]], keys[s[1]]])) };
80
+ }
package/dist/index.d.ts CHANGED
@@ -53,6 +53,8 @@ export * from './choice/Check.js';
53
53
  export * from './choice/DayStrip.js';
54
54
  export * from './choice/Calendar.js';
55
55
  export * from './choice/CalendarWeeks.js';
56
+ export * from './choice/HourGrid.js';
57
+ export type { Span, SpanAt } from './choice/span.js';
56
58
  export * from './choice/Mood.js';
57
59
  export * from './choice/Slider.js';
58
60
  export * from './numbers/Ring.js';
package/dist/index.js CHANGED
@@ -57,6 +57,7 @@ export * from './choice/Check.js';
57
57
  export * from './choice/DayStrip.js';
58
58
  export * from './choice/Calendar.js';
59
59
  export * from './choice/CalendarWeeks.js';
60
+ export * from './choice/HourGrid.js';
60
61
  export * from './choice/Mood.js';
61
62
  export * from './choice/Slider.js';
62
63
  export * from './numbers/Ring.js';
@@ -8,7 +8,8 @@ export interface JourneyBadgeProps extends ComponentProps<'span'> {
8
8
  sub?: ReactNode;
9
9
  /** no fill: a date box beside a title (Jun / 18) */
10
10
  plain?: boolean;
11
- /** pinned to a CardMedia's corner (rating, price) */
11
+ /** pinned to a CardMedia's corner (rating, price, a distance). On a CardMedia: the paper (`--card-1-bg`), its corner concentric with the media's;
12
+ * a theme's `--tile-badge-bg` / `--tile-badge-radius` still win. Ruled (Lh 2026-10-21) */
12
13
  corner?: boolean;
13
14
  /** a date in a card's head: as tall as the title block beside it, no taller — two lines beside a one-line title (day over number · month),
14
15
  * three beside a two-line title (day · number · month). Write it as the head's last child, after the title block */
@@ -15,9 +15,11 @@ export interface KvRowProps extends ComponentProps<'div'> {
15
15
  /** KvRow — one key · value line (48px tall); the value may hold a Chip, an Avatar, an Icon. */
16
16
  export declare function KvRow({ label, className, children, ...rest }: KvRowProps): import("react").JSX.Element;
17
17
  export interface KvGridProps extends ComponentProps<'div'> {
18
+ /** the one number in the middle of the room it stands in (a turning card's back, a Centre): the grid takes its holder's height and stands its cells in the middle of it, each cell's value and caption centred */
19
+ centre?: boolean;
18
20
  }
19
- /** KvGrid — value-over-label cells split by a rule (Sex | Weight); children are KVCells. */
20
- export declare function KvGrid({ className, ...rest }: KvGridProps): import("react").JSX.Element;
21
+ /** KvGrid — value-over-label cells split by a rule (Sex | Weight); children are KVCells. `centre`: one cell at the middle of a card's back (a code the member copies). */
22
+ export declare function KvGrid({ centre, className, ...rest }: KvGridProps): import("react").JSX.Element;
21
23
  export interface KvCellProps extends ComponentProps<'div'> {
22
24
  /** an icon before the value */
23
25
  icon?: IconName;
package/dist/lists/Kv.js CHANGED
@@ -9,9 +9,9 @@ export function Kv({ lined, className, ...rest }) {
9
9
  export function KvRow({ label, className, children, ...rest }) {
10
10
  return (_jsxs("div", { className: cx('kv__row', className), ...rest, children: [_jsx("dt", { className: "kv__key", children: label }), _jsx("dd", { className: "kv__val", children: children })] }));
11
11
  }
12
- /** KvGrid — value-over-label cells split by a rule (Sex | Weight); children are KVCells. */
13
- export function KvGrid({ className, ...rest }) {
14
- return _jsx("div", { className: cx('kv-grid', className), ...rest });
12
+ /** KvGrid — value-over-label cells split by a rule (Sex | Weight); children are KVCells. `centre`: one cell at the middle of a card's back (a code the member copies). */
13
+ export function KvGrid({ centre, className, ...rest }) {
14
+ return _jsx("div", { className: cx('kv-grid', centre && 'kv-grid--centre', className), ...rest });
15
15
  }
16
16
  /** KvCell — an icon, a big value, a caption label. */
17
17
  export function KvCell({ icon, value, label, className, ...rest }) {
@@ -1,6 +1,6 @@
1
1
  import type { ComponentProps, ReactNode } from 'react';
2
- /** what asked for the move: a `tap` on another step, a `swipe` across the steps, the step's `skip` link */
3
- export type SheetStepsReason = 'tap' | 'swipe' | 'skip';
2
+ /** what asked for the move: a `tap` on another step, the step's `skip` link */
3
+ export type SheetStepsReason = 'tap' | 'skip';
4
4
  export interface SheetStepsProps extends ComponentProps<'div'> {
5
5
  /** optional: a `Btn` that finishes, under the steps — always in sight, `disabled` until EVERY step is done (its work, or
6
6
  * skipped: `at` has reached `count`). None = the task ends when `at` reaches `count` — the last step's own action, or its skip. */
@@ -17,10 +17,10 @@ export interface SheetStepsProps extends ComponentProps<'div'> {
17
17
  reached?: number;
18
18
  /** UNCONTROLLED: the step it starts on (default 0) */
19
19
  defaultAt?: number;
20
- /** the steps ASK to move — a tap on another step, a swipe, a skip. Controlled: set `at` to it (or don't). */
20
+ /** the steps ASK to move — a tap on another step, a skip. Controlled: set `at` to it (or don't). */
21
21
  onAtChange?: (at: number, reason: SheetStepsReason) => void;
22
- /** true: the member may go back and forth — a tap on a done or next step opens it, an up/down swipe moves one step.
23
- * false (default): only forward — the done steps stay in the pile, never opened again (a swipe up passes only a `skippable` step). */
22
+ /** true: the member may go back and forth — a tap on a done or next step opens it.
23
+ * false (default): only forward — the done steps stay in the pile, never opened again. */
24
24
  reversible?: boolean;
25
25
  /** true: each step's number in a disc before its title — a white disc on the step's bar (Lh 2026-09-25). Default: titles only. */
26
26
  numbered?: boolean;
@@ -32,7 +32,7 @@ export interface SheetStepsProps extends ComponentProps<'div'> {
32
32
  * steps on the last one. Goes in a Sheet's `Body`.
33
33
  * The sheet may be dragged down to peek and back — the body keeps where it was scrolled.
34
34
  */
35
- export declare function SheetSteps({ at, reached, defaultAt, onAtChange, reversible, numbered, finish, className, style, children, onFocus, onBlur, onPointerDown, onPointerUp, onPointerCancel, ...rest }: SheetStepsProps): import("react").JSX.Element;
35
+ export declare function SheetSteps({ at, reached, defaultAt, onAtChange, reversible, numbered, finish, className, style, children, onFocus, onBlur, ...rest }: SheetStepsProps): import("react").JSX.Element;
36
36
  export interface SheetStepProps extends Omit<ComponentProps<'li'>, 'title'> {
37
37
  /** the step's name — always shown: over the open step, on the folded line of a done one */
38
38
  title: ReactNode;
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Children, cloneElement, createContext, isValidElement, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
- import { focusWhenActive, swipeOf, useControlled } from '../state.js';
3
+ import { focusWhenActive, useControlled } from '../state.js';
4
4
  import { cx } from '../cx.js';
5
5
  import { Icon } from '../type/Icon.js';
6
6
  import { Link } from '../actions/Link.js';
@@ -10,7 +10,6 @@ import { word } from '../words.js';
10
10
  import { usePile } from './sheetStepsPile.js';
11
11
  import { StepCursor } from './stepCursor.js';
12
12
  const MOVE_MS = 350; // = --motion-sheet: the accordion's beat
13
- const SWIPE_REM = 3; // an up/down move longer than this (and mostly up/down) is a swipe
14
13
  /* the open step's first field — the one that takes the cursor, and the one a typed character is handed to (an input or a textarea) */
15
14
  const NOW_FIELD = '.sheet-step--now .sheet-step__inner :is(input:not([type=checkbox], [type=radio], [type=hidden]), textarea)';
16
15
  const coarse = () => typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches; // a phone: its keyboard follows a finger, not a script
@@ -22,7 +21,7 @@ const StepCtx = createContext(null);
22
21
  * steps on the last one. Goes in a Sheet's `Body`.
23
22
  * The sheet may be dragged down to peek and back — the body keeps where it was scrolled.
24
23
  */
25
- export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible = false, numbered = false, finish, className, style, children, onFocus, onBlur, onPointerDown, onPointerUp, onPointerCancel, ...rest }) {
24
+ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible = false, numbered = false, finish, className, style, children, onFocus, onBlur, ...rest }) {
26
25
  const [current, request] = useControlled(at, defaultAt, onAtChange);
27
26
  const steps = Children.toArray(children).filter(isValidElement);
28
27
  const count = steps.length;
@@ -42,7 +41,6 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
42
41
  shown.current = current;
43
42
  moving.current = performance.now() + MOVE_MS + 100;
44
43
  } // from the very render that changes it: the old field's blur and the keyboard's jump come in that same commit
45
- const press = useRef(null);
46
44
  /* the pile: where the body is scrolled (the two latest done rows in sight), and the glide of its jumps — sheetStepsPile.ts */
47
45
  const { settle, glideNow } = usePile(ref, moving);
48
46
  const go = (i, reason) => {
@@ -119,29 +117,6 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
119
117
  setTimeout(() => { if (!typing(document.activeElement) && body)
120
118
  body.scrollTo({ top: 0 }); }, 0); // Tab to the next field keeps it up
121
119
  };
122
- /* an up/down swipe turns the wheel one step — unless it started in content that can still scroll that way */
123
- const down = (e) => {
124
- onPointerDown?.(e);
125
- press.current = e.isPrimary && !e.target.closest('input, textarea, select, .pin, .picker, .slider') ? { x: e.clientX, y: e.clientY } : null;
126
- };
127
- const up = (e) => {
128
- onPointerUp?.(e);
129
- const p = press.current;
130
- press.current = null;
131
- if (!p)
132
- return;
133
- const dy = swipeOf(p, e, 'y', SWIPE_REM * parseFloat(getComputedStyle(document.documentElement).fontSize), 2);
134
- if (!dy)
135
- return;
136
- const body = e.target.closest('.sheet-step__body');
137
- if (body && (dy < 0 ? body.scrollTop + body.clientHeight < body.scrollHeight - 1 : body.scrollTop > 0))
138
- return; // the content's own scroll
139
- const to = current + (dy < 0 ? 1 : -1);
140
- if (reversible && to < count)
141
- go(to, 'swipe'); // a swipe never passes the last step — only its own action or its skip does
142
- else if (dy < 0 && ref.current?.querySelector('.sheet-step--now[data-skippable]'))
143
- go(to, 'skip'); // forward only, and only past a step that may be skipped
144
- };
145
120
  /* typed while the cursor waits in the holder (the fold is playing — or, on an iPhone, the keyboard never followed the cursor into the
146
121
  new step: a focus set by script does not always take the keyboard with it, and every digit landed here and was dropped —
147
122
  timebank, Lh 2026-10-03): what was typed belongs to the open step's first field. Hand it over, and the cursor with it —
@@ -157,7 +132,7 @@ export function SheetSteps({ at, reached, defaultAt = 0, onAtChange, reversible
157
132
  Object.getOwnPropertyDescriptor(proto, 'value').set.call(field, field.value + typed);
158
133
  field.dispatchEvent(new Event('input', { bubbles: true }));
159
134
  };
160
- return (_jsxs("div", { ref: ref, className: cx('sheet-steps', !reversible && 'sheet-steps--locked', className), style: style, onFocus: focusIn, onBlur: focusOut, onPointerDown: down, onPointerUp: up, onPointerCancel: (e) => { onPointerCancel?.(e); press.current = null; }, ...rest, children: [_jsx("input", { ref: holderRef, className: "sheet-steps__holder", "aria-hidden": true, tabIndex: -1, autoComplete: "off", onFocus: () => setParked(true), onBlur: () => setParked(false), onInput: handOver }), _jsx("ol", { className: "sheet-steps__track", children: steps.map((step, i) => (_jsx(StepCtx.Provider, { value: { numbered, i, at: current, furthest: reach, count, reversible, go, park, parked }, children: step }, step.key ?? i))) }), finish != null && ( // always in sight; it takes taps only once every step is done (or skipped)
135
+ return (_jsxs("div", { ref: ref, className: cx('sheet-steps', !reversible && 'sheet-steps--locked', className), style: style, onFocus: focusIn, onBlur: focusOut, ...rest, children: [_jsx("input", { ref: holderRef, className: "sheet-steps__holder", "aria-hidden": true, tabIndex: -1, autoComplete: "off", onFocus: () => setParked(true), onBlur: () => setParked(false), onInput: handOver }), _jsx("ol", { className: "sheet-steps__track", children: steps.map((step, i) => (_jsx(StepCtx.Provider, { value: { numbered, i, at: current, furthest: reach, count, reversible, go, park, parked }, children: step }, step.key ?? i))) }), finish != null && ( // always in sight; it takes taps only once every step is done (or skipped)
161
136
  _jsx("div", { className: "sheet-steps__finish", children: isValidElement(finish) ? cloneElement(finish, { disabled: reach < count || finish.props.disabled }) : finish }))] }));
162
137
  }
163
138
  /* the step's format checks, as one group — see SheetStep */
package/docs/reference.md CHANGED
@@ -608,7 +608,7 @@ between at 40% down the screen (see *Anatomy*, "Where the centre is"):
608
608
  control the member fills in or picks stands on a sheet: a `Sheet`'s
609
609
  `Body`, a `SheetStep`, or a stack sheet. That is `Field`, `Composer`
610
610
  and `ComposerArea`, `Pin`, `Dropdown`, `Slider`, `Toggle`, `Check`,
611
- `ChipPick`, `Calendar`, `DayStrip` and `Day`, `Picker`, `Mood`,
611
+ `ChipPick`, `Calendar`, `CalendarWeeks`, `HourGrid`, `DayStrip` and `Day`, `Picker`, `Mood`,
612
612
  `AvatarPick`, `FileBtn`, `AddRow`, `MapPick`, a `Tile pick` and a
613
613
  `Tile upload`. A card is for reading and pressing buttons only; never put
614
614
  a form, or one control of it, in a card. `cardds check` rule
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.107",
3
+ "version": "0.7.109",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "description": "card-first mobile design system, React-first: the components in src/ are thin wrappers over the CSS contract (css/*.css stays the only truth); gallery/ shows every story live (npm run dev), tests/ measures the geometry.",