@usableapp/cardds 0.2.2 → 0.3.0

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,13 +1,23 @@
1
- import type { ComponentPropsWithoutRef, ReactNode } from 'react';
2
- export interface CalendarProps extends Omit<ComponentPropsWithoutRef<'div'>, 'title'> {
1
+ import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
+ export interface CalendarProps extends Omit<ComponentPropsWithoutRef<'div'>, 'title' | 'onSelect'> {
3
3
  /** the month shown ("June") */
4
4
  title: ReactNode;
5
5
  /** days in the month */
6
6
  days?: number;
7
7
  /** weekday column of the 1st (1 = first column) */
8
8
  start?: number;
9
- /** the selected day */
10
- selected?: number;
9
+ /** CONTROLLED: the picked day, or `null` for none. The app owns it — pair it with `onSelect`. (`undefined` = uncontrolled.) */
10
+ selected?: number | null;
11
+ /** UNCONTROLLED: the day picked on arrival; the calendar then keeps its own */
12
+ defaultSelected?: number;
13
+ /** a day was tapped */
14
+ onSelect?: (day: number) => void;
15
+ /** the head's two buttons: the app turns the month (it owns `title`, `days`, `start`) */
16
+ onPrev?: () => void;
17
+ onNext?: () => void;
18
+ /** their accessible names */
19
+ prevLabel?: string;
20
+ nextLabel?: string;
11
21
  /** softly marked days */
12
22
  marks?: number[];
13
23
  /** strongly marked days */
@@ -19,5 +29,6 @@ export interface CalendarProps extends Omit<ComponentPropsWithoutRef<'div'>, 'ti
19
29
  /** replace the default prev/next icon buttons */
20
30
  nav?: ReactNode;
21
31
  }
22
- /** Calendar — a month grid of 44px round cells: one accent day, optional marks; prev/next in the head. */
23
- export declare function Calendar({ title, days, start, selected, marks, marks2, disabled, weekdays, nav, className, ...rest }: CalendarProps): import("react").JSX.Element;
32
+ /** 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:
33
+ * the app owns which month (`title`, `days`, `start` + `onPrev` / `onNext`) and which day (`selected` + `onSelect`, or `defaultSelected`). */
34
+ 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;
@@ -1,7 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
2
3
  import { cx } from '../cx.js';
3
4
  import { IconBtn } from '../actions/IconBtn.js';
4
- /** Calendar — a month grid of 44px round cells: one accent day, optional marks; prev/next in the head. */
5
- export function Calendar({ title, days = 30, start = 1, selected, marks = [], marks2 = [], disabled = [], weekdays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'], nav, className, ...rest }) {
6
- 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: "Previous" }), _jsx(IconBtn, { sm: true, icon: "chevron-right", label: "Next" })] }))] }), _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) => (_jsx("button", { type: "button", className: cx('calendar__day', marks.includes(d) && 'calendar__day--mark', marks2.includes(d) && 'calendar__day--mark2'), "aria-selected": d === selected ? 'true' : undefined, disabled: disabled.includes(d), style: d === 1 ? { '--start': start } : undefined, children: d }, d)))] })] }));
5
+ /** 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:
6
+ * the app owns which month (`title`, `days`, `start` + `onPrev` / `onNext`) and which day (`selected` + `onSelect`, or `defaultSelected`). */
7
+ export function Calendar({ title, days = 30, start = 1, selected, defaultSelected, onSelect, onPrev, onNext, prevLabel = 'Previous', nextLabel = 'Next', marks = [], marks2 = [], disabled = [], weekdays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'], nav, className, ...rest }) {
8
+ const [inner, setInner] = useState(defaultSelected);
9
+ const day = selected !== undefined ? selected : inner;
10
+ const pick = (d) => { if (selected === undefined)
11
+ setInner(d); onSelect?.(d); };
12
+ 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) => (_jsx("button", { type: "button", className: cx('calendar__day', marks.includes(d) && 'calendar__day--mark', marks2.includes(d) && 'calendar__day--mark2'), "aria-selected": d === day ? 'true' : undefined, onClick: () => pick(d), disabled: disabled.includes(d), style: d === 1 ? { '--start': start } : undefined, children: d }, d)))] })] }));
7
13
  }
@@ -8,4 +8,4 @@ export interface SliderProps extends Omit<ComponentPropsWithoutRef<'input'>, 'cl
8
8
  style?: React.CSSProperties;
9
9
  }
10
10
  /** Slider — one value along a hairline rail with a 48px knob; the whole range is thumb-tall. */
11
- export declare function Slider({ label, glyph, className, style, ...input }: SliderProps): import("react").JSX.Element;
11
+ export declare function Slider({ label, glyph, className, style, onInput, ...input }: SliderProps): import("react").JSX.Element;
@@ -2,13 +2,17 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef } from 'react';
3
3
  import { cx } from '../cx.js';
4
4
  /** Slider — one value along a hairline rail with a 48px knob; the whole range is thumb-tall. */
5
- export function Slider({ label, glyph, className, style, ...input }) {
5
+ export function Slider({ label, glyph, className, style, onInput, ...input }) {
6
6
  const range = useRef(null);
7
- // the knob's place is published by cardds.js on every input event; on mount it publishes once
8
- // for the value the slider starts at, so a glyph sits on the knob before the first move
9
- useEffect(() => {
10
- if (glyph != null && range.current)
11
- range.current.dispatchEvent(new Event('input', { bubbles: true }));
12
- }, [glyph]);
13
- return (_jsxs("label", { className: cx('slider', className), style: style, children: [label != null && _jsx("span", { className: "slider__label", children: label }), glyph != null && _jsx("span", { className: "slider__glyph", "aria-hidden": "true", children: glyph }), _jsx("input", { ref: range, className: "slider__range", type: "range", ...input })] }));
7
+ const box = useRef(null);
8
+ // the knob's place along the rail, 0..1, published on the slider as --slider-at so the glyph can ride the knob (choice.css)
9
+ const place = () => {
10
+ const r = range.current;
11
+ if (!r || !box.current)
12
+ return;
13
+ const min = Number(r.min) || 0, max = Number(r.max) || 100;
14
+ box.current.style.setProperty('--slider-at', String(max > min ? Math.min(1, Math.max(0, (Number(r.value) - min) / (max - min))) : 0));
15
+ };
16
+ useEffect(place); // after every render: a controlled value, a new min / max
17
+ return (_jsxs("label", { ref: box, className: cx('slider', className), style: style, children: [label != null && _jsx("span", { className: "slider__label", children: label }), glyph != null && _jsx("span", { className: "slider__glyph", "aria-hidden": "true", children: glyph }), _jsx("input", { ref: range, className: "slider__range", type: "range", onInput: (e) => { place(); onInput?.(e); }, ...input })] }));
14
18
  }
@@ -1,19 +1,22 @@
1
1
  import { type ComponentPropsWithoutRef } from 'react';
2
- export interface PinProps extends Omit<ComponentPropsWithoutRef<'div'>, 'onChange'> {
2
+ export interface PinProps extends Omit<ComponentPropsWithoutRef<'div'>, 'onChange' | 'defaultValue'> {
3
3
  /** how many digits (4 or 6) */
4
4
  length?: number;
5
- /** digits already typed, left to right ("03") */
5
+ /** CONTROLLED: the digits so far, left to right ("03"). The app owns it — pair it with `onChange`. */
6
6
  value?: string;
7
+ /** UNCONTROLLED: the digits it starts with; the Pin then keeps its own */
8
+ defaultValue?: string;
7
9
  /** accessible name prefix for each cell ("Digit" → "Digit 1") */
8
10
  label?: string;
9
- /** the code so far, after every keystroke; `complete` once every cell holds a digit */
11
+ /** the code so far, after every change; `complete` once every cell holds a digit */
10
12
  onChange?: (code: string, complete: boolean) => void;
11
13
  /** focus the first empty cell on mount */
12
14
  autoFocus?: boolean;
13
15
  }
14
16
  /**
15
17
  * Pin — big code entry, one digit per pill; a filled cell turns accent. Lives in a Sheet.
16
- * Typing a digit moves to the next cell, Backspace in an empty cell moves back, a pasted code fills every cell
17
- * (cardds.js, delegated — the cells stay plain inputs). The first cell takes the SMS one-time-code autofill.
18
+ * Its own behaviour, inside it: typing a digit moves to the next cell, Backspace in an empty cell moves back and clears, the
19
+ * arrows walk the cells, a pasted code fills them from the one it landed in; only digits stay. The first cell takes the SMS
20
+ * one-time-code autofill. The code is a VALUE, controlled like an input: `value` + `onChange`, or `defaultValue`.
18
21
  */
19
- export declare function Pin({ length, value, label, onChange, autoFocus, className, ...rest }: PinProps): import("react").JSX.Element;
22
+ export declare function Pin({ length, value, defaultValue, label, onChange, autoFocus, className, ...rest }: PinProps): import("react").JSX.Element;
package/dist/forms/Pin.js CHANGED
@@ -1,27 +1,56 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useEffect, useRef } from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
3
3
  import { cx } from '../cx.js';
4
4
  /**
5
5
  * Pin — big code entry, one digit per pill; a filled cell turns accent. Lives in a Sheet.
6
- * Typing a digit moves to the next cell, Backspace in an empty cell moves back, a pasted code fills every cell
7
- * (cardds.js, delegated — the cells stay plain inputs). The first cell takes the SMS one-time-code autofill.
6
+ * Its own behaviour, inside it: typing a digit moves to the next cell, Backspace in an empty cell moves back and clears, the
7
+ * arrows walk the cells, a pasted code fills them from the one it landed in; only digits stay. The first cell takes the SMS
8
+ * one-time-code autofill. The code is a VALUE, controlled like an input: `value` + `onChange`, or `defaultValue`.
8
9
  */
9
- export function Pin({ length = 6, value = '', label = 'Digit', onChange, autoFocus, className, ...rest }) {
10
+ export function Pin({ length = 6, value, defaultValue = '', label = 'Digit', onChange, autoFocus, className, ...rest }) {
10
11
  const ref = useRef(null);
12
+ const [inner, setInner] = useState(defaultValue);
13
+ const code = (value ?? inner).replace(/\D/g, '').slice(0, length);
14
+ const cell = (i) => ref.current?.querySelectorAll('.pin__cell')[i];
15
+ const set = (next) => {
16
+ next = next.slice(0, length);
17
+ if (value === undefined)
18
+ setInner(next);
19
+ if (next !== code)
20
+ onChange?.(next, next.length === length);
21
+ };
11
22
  useEffect(() => {
12
- const el = ref.current;
13
- if (!el || !onChange)
14
- return;
15
- const on = (e) => { const d = e.detail; onChange(d.code, d.complete); };
16
- el.addEventListener('cardds:pin', on);
17
- return () => el.removeEventListener('cardds:pin', on);
18
- }, [onChange]);
19
- useEffect(() => {
20
- if (!autoFocus)
21
- return;
22
- const cells = ref.current?.querySelectorAll('.pin__cell');
23
- if (cells)
24
- ([...cells].find((c) => !c.value) ?? cells[cells.length - 1])?.focus();
23
+ if (autoFocus)
24
+ cell(Math.min(code.length, length - 1))?.focus();
25
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- on mount only
25
26
  }, [autoFocus]);
26
- return (_jsx("div", { ref: ref, className: cx('pin', className), ...rest, children: Array.from({ length }, (_, i) => (_jsx("input", { className: "pin__cell", inputMode: "numeric", pattern: "[0-9]*", placeholder: " ", defaultValue: value[i] ?? '', "aria-label": `${label} ${i + 1}`, autoComplete: i === 0 ? 'one-time-code' : 'off' }, i))) }));
27
+ const input = (i) => (e) => {
28
+ const digits = e.target.value.replace(/\D/g, '');
29
+ const at = Math.min(i, code.length); // a code has no gaps: a digit typed further along lands in the first empty cell
30
+ if (!digits) {
31
+ set(code.slice(0, at) + code.slice(at + 1));
32
+ return;
33
+ }
34
+ set(code.slice(0, at) + digits + code.slice(at + digits.length)); // a paste (or a typed run) spills over the cells that follow
35
+ cell(Math.min(at + digits.length, length - 1))?.focus();
36
+ };
37
+ const key = (i) => (e) => {
38
+ if (e.key === 'Backspace' && !e.currentTarget.value && i > 0) {
39
+ e.preventDefault();
40
+ set(code.slice(0, i - 1) + code.slice(i));
41
+ cell(i - 1)?.focus();
42
+ }
43
+ else if (e.key === 'ArrowLeft' && i > 0) {
44
+ e.preventDefault();
45
+ cell(i - 1)?.focus();
46
+ }
47
+ else if (e.key === 'ArrowRight' && i < length - 1) {
48
+ e.preventDefault();
49
+ cell(i + 1)?.focus();
50
+ }
51
+ };
52
+ return (_jsx("div", { ref: ref, className: cx('pin', className), ...rest, children: Array.from({ length }, (_, i) => (
53
+ // no maxLength: the browser would cut a pasted code before this ever saw it. A cell holds one digit — typing into a full one
54
+ // replaces it, so its digit is selected whenever the cell takes the focus
55
+ _jsx("input", { className: "pin__cell", inputMode: "numeric", pattern: "[0-9]*", placeholder: " ", value: code[i] ?? '', "aria-label": `${label} ${i + 1}`, autoComplete: i === 0 ? 'one-time-code' : 'off', onChange: input(i), onKeyDown: key(i), onFocus: (e) => e.currentTarget.select() }, i))) }));
27
56
  }
@@ -1,4 +1,4 @@
1
- import type { ComponentPropsWithoutRef, ReactNode } from 'react';
1
+ import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
2
  export interface PostcardProps extends ComponentPropsWithoutRef<'div'> {
3
3
  /** the photo on the front; none = the wireframe box */
4
4
  photo?: string | null;
@@ -7,14 +7,18 @@ export interface PostcardProps extends ComponentPropsWithoutRef<'div'> {
7
7
  name: ReactNode;
8
8
  /** the words on the back */
9
9
  caption?: ReactNode;
10
- /** start turned over */
10
+ /** CONTROLLED: the back is showing. The app owns it — pair it with `onFlip`. */
11
11
  flipped?: boolean;
12
+ /** UNCONTROLLED: start turned over; the card then keeps its own side */
13
+ defaultFlipped?: boolean;
14
+ /** a face was tapped: the side it asks to show */
15
+ onFlip?: (flipped: boolean) => void;
12
16
  frontLabel?: string;
13
17
  backLabel?: string;
14
18
  }
15
19
  /**
16
20
  * Postcard — a member's introduction as a card in the hand: the photo (or the wireframe box) with the name in front,
17
- * the caption on the back; a tap on either face turns it over (cardds.js toggles `is-flipped`, `--motion-flip`).
21
+ * the caption on the back; a tap on either face turns it over (`--motion-flip`). Which side shows is a state: `flipped` + `onFlip`, or `defaultFlipped`.
18
22
  * A voice note, a like, a reply go OUTSIDE it, in a Row under it — a face is a button and holds no button.
19
23
  */
20
- export declare function Postcard({ photo, alt, name, caption, flipped, frontLabel, backLabel, className, ...rest }: PostcardProps): import("react").JSX.Element;
24
+ export declare function Postcard({ photo, alt, name, caption, flipped, defaultFlipped, onFlip, frontLabel, backLabel, className, ...rest }: PostcardProps): import("react").JSX.Element;
@@ -1,10 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
2
3
  import { cx } from '../cx.js';
3
4
  /**
4
5
  * Postcard — a member's introduction as a card in the hand: the photo (or the wireframe box) with the name in front,
5
- * the caption on the back; a tap on either face turns it over (cardds.js toggles `is-flipped`, `--motion-flip`).
6
+ * the caption on the back; a tap on either face turns it over (`--motion-flip`). Which side shows is a state: `flipped` + `onFlip`, or `defaultFlipped`.
6
7
  * A voice note, a like, a reply go OUTSIDE it, in a Row under it — a face is a button and holds no button.
7
8
  */
8
- export function Postcard({ photo, alt = '', name, caption, flipped, frontLabel = 'พลิกดูด้านหลัง', backLabel = 'พลิกดูด้านหน้า', className, ...rest }) {
9
- return (_jsxs("div", { className: cx('postcard', flipped && 'is-flipped', className), ...rest, children: [_jsxs("button", { className: "postcard__face postcard__front", type: "button", "aria-label": frontLabel, children: [photo ? _jsx("img", { src: photo, alt: alt }) : alt ? _jsx("div", { className: "ph", role: "img", "aria-label": alt }) : _jsx("div", { className: "ph" }), _jsx("span", { className: "postcard__label", children: name })] }), _jsxs("button", { className: "postcard__face postcard__back", type: "button", "aria-label": backLabel, children: [caption != null && _jsx("p", { className: "postcard__caption", children: caption }), _jsx("span", { className: "postcard__label", children: name })] })] }));
9
+ export function Postcard({ photo, alt = '', name, caption, flipped, defaultFlipped = false, onFlip, frontLabel = 'พลิกดูด้านหลัง', backLabel = 'พลิกดูด้านหน้า', className, ...rest }) {
10
+ const [inner, setInner] = useState(defaultFlipped);
11
+ const back = flipped ?? inner;
12
+ const turn = () => { if (flipped === undefined)
13
+ setInner(!back); onFlip?.(!back); };
14
+ return (_jsxs("div", { className: cx('postcard', back && 'is-flipped', className), ...rest, children: [_jsxs("button", { className: "postcard__face postcard__front", type: "button", "aria-label": frontLabel, onClick: turn, children: [photo ? _jsx("img", { src: photo, alt: alt }) : alt ? _jsx("div", { className: "ph", role: "img", "aria-label": alt }) : _jsx("div", { className: "ph" }), _jsx("span", { className: "postcard__label", children: name })] }), _jsxs("button", { className: "postcard__face postcard__back", type: "button", "aria-label": backLabel, onClick: turn, children: [caption != null && _jsx("p", { className: "postcard__caption", children: caption }), _jsx("span", { className: "postcard__label", children: name })] })] }));
10
15
  }
@@ -1,13 +1,25 @@
1
1
  import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
- export interface PickerProps extends ComponentPropsWithoutRef<'div'> {
2
+ export interface PickerProps extends Omit<ComponentPropsWithoutRef<'div'>, 'onChange' | 'defaultValue'> {
3
+ /** CONTROLLED: the chosen PickerItem's `value`. The app owns it — pair it with `onChange`. */
4
+ value?: string;
5
+ /** UNCONTROLLED: the value it starts on; the picker then keeps its own */
6
+ defaultValue?: string;
7
+ /** the member settled on a value — scrolled the rail to it, or tapped it */
8
+ onChange?: (value: string) => void;
3
9
  /** the unit under the rail ("hours") */
4
10
  unit?: ReactNode;
5
11
  label?: string;
6
12
  }
7
- /** Picker — a horizontal snap picker, the centre value big between two accent rules; children are PickerItems. */
8
- export declare function Picker({ unit, label, className, children, ...rest }: PickerProps): import("react").JSX.Element;
9
- export interface PickerItemProps extends ComponentPropsWithoutRef<'button'> {
10
- selected?: boolean;
13
+ /**
14
+ * Picker — a horizontal snap picker, the centre value big between two accent rules; children are PickerItems.
15
+ * It holds a VALUE, controlled like an input: `value` + `onChange`, or `defaultValue`. The value IS the item standing in the centre
16
+ * of the rail: the picker scrolls the chosen one there, and when the member scrolls (or taps an item) it asks for the one that
17
+ * settled in the centre. The CSS lights the centred item by scroll position; the app never reads the DOM to know what was picked.
18
+ */
19
+ export declare function Picker({ value, defaultValue, onChange, unit, label, className, children, ...rest }: PickerProps): import("react").JSX.Element;
20
+ export interface PickerItemProps extends Omit<ComponentPropsWithoutRef<'button'>, 'value'> {
21
+ /** what choosing this item reports; default = its own text */
22
+ value?: string;
11
23
  }
12
- /** PickerItem — one value on the Picker rail. */
13
- export declare function PickerItem({ selected, className, ...rest }: PickerItemProps): import("react").JSX.Element;
24
+ /** PickerItem — one value on the Picker rail; a tap brings it to the centre. */
25
+ export declare function PickerItem({ value, className, children, onClick, ...rest }: PickerItemProps): import("react").JSX.Element;
@@ -1,18 +1,56 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useRef } from 'react';
2
+ import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';
3
3
  import { cx } from '../cx.js';
4
- /** Picker — a horizontal snap picker, the centre value big between two accent rules; children are PickerItems. */
5
- export function Picker({ unit, label, className, children, ...rest }) {
4
+ const PickerContext = createContext(null);
5
+ /**
6
+ * Picker — a horizontal snap picker, the centre value big between two accent rules; children are PickerItems.
7
+ * It holds a VALUE, controlled like an input: `value` + `onChange`, or `defaultValue`. The value IS the item standing in the centre
8
+ * of the rail: the picker scrolls the chosen one there, and when the member scrolls (or taps an item) it asks for the one that
9
+ * settled in the centre. The CSS lights the centred item by scroll position; the app never reads the DOM to know what was picked.
10
+ */
11
+ export function Picker({ value, defaultValue, onChange, unit, label, className, children, ...rest }) {
12
+ const [inner, setInner] = useState(defaultValue);
13
+ const current = value ?? inner;
6
14
  const rail = useRef(null);
7
- useEffect(() => {
8
- // start on the selected value, as the demo page does: the CSS lights the item by scroll position
9
- const r = rail.current, on = r?.querySelector('[aria-selected="true"]');
10
- if (r && on)
11
- r.scrollLeft = on.offsetLeft - r.clientWidth / 3;
12
- }, []);
13
- return (_jsxs("div", { className: cx('picker', className), "aria-label": label, ...rest, children: [_jsx("div", { className: "picker__rail", role: "listbox", ref: rail, children: children }), unit != null && _jsx("span", { className: "picker__unit", children: unit })] }));
15
+ const mounted = useRef(false);
16
+ const settle = useRef(null);
17
+ const pick = (next) => {
18
+ if (next === current)
19
+ return;
20
+ if (value === undefined)
21
+ setInner(next);
22
+ onChange?.(next);
23
+ };
24
+ const items = () => [...(rail.current?.querySelectorAll(':scope > .picker__item') ?? [])];
25
+ const column = () => (rail.current ? rail.current.clientWidth / 3 : 0); // the rail is three columns wide; item i stands in the centre at i × one column
26
+ /* the chosen value stands in the centre — at once on arrival, sliding after */
27
+ useLayoutEffect(() => {
28
+ const r = rail.current, i = items().findIndex((el) => el.dataset.value === current);
29
+ if (r && i >= 0 && Math.abs(r.scrollLeft - i * column()) > 1)
30
+ r.scrollTo({ left: i * column(), behavior: mounted.current ? 'smooth' : 'auto' });
31
+ mounted.current = true;
32
+ });
33
+ useEffect(() => () => { if (settle.current)
34
+ clearTimeout(settle.current); }, []);
35
+ /* the rail came to rest: whatever stands in the centre is what the member chose */
36
+ const scrolled = () => {
37
+ if (settle.current)
38
+ clearTimeout(settle.current);
39
+ settle.current = setTimeout(() => {
40
+ const r = rail.current, col = column();
41
+ if (!r || !col)
42
+ return;
43
+ const at = items()[Math.round(r.scrollLeft / col)]?.dataset.value;
44
+ if (at !== undefined)
45
+ pick(at);
46
+ }, 120);
47
+ };
48
+ return (_jsx(PickerContext.Provider, { value: { value: current, pick }, children: _jsxs("div", { className: cx('picker', className), "aria-label": label, "data-value": current, ...rest, children: [_jsx("div", { className: "picker__rail", role: "listbox", ref: rail, onScroll: scrolled, children: children }), unit != null && _jsx("span", { className: "picker__unit", children: unit })] }) }));
14
49
  }
15
- /** PickerItem — one value on the Picker rail. */
16
- export function PickerItem({ selected, className, ...rest }) {
17
- return _jsx("button", { type: "button", role: "option", "aria-selected": selected ? 'true' : 'false', className: cx('picker__item', className), ...rest });
50
+ /** PickerItem — one value on the Picker rail; a tap brings it to the centre. */
51
+ export function PickerItem({ value, className, children, onClick, ...rest }) {
52
+ const picker = useContext(PickerContext);
53
+ const own = value ?? (typeof children === 'string' || typeof children === 'number' ? String(children) : undefined);
54
+ return (_jsx("button", { type: "button", role: "option", "data-value": own, "aria-selected": own !== undefined && picker?.value === own ? 'true' : 'false', className: cx('picker__item', className), onClick: (e) => { onClick?.(e); if (!e.defaultPrevented && own !== undefined)
55
+ picker?.pick(own); }, ...rest, children: children }));
18
56
  }
@@ -6,12 +6,12 @@ export interface BaseContentProps extends ComponentPropsWithoutRef<'section'> {
6
6
  * maybe a CtaPack. Content has three homes (Card · Sheet · the base); this is the third one, named.
7
7
  * Was Section (and, inside a Step, StepCard) until 2026-09-16 — one block, one name.
8
8
  * In a Step it is the reading block (first child; the step's MOVE goes to the ActionBar, never here). It can move two ways
9
- * (Lh 2026-09-20): several of them in a `Pages` = a walk of words the round pager turns (slideTo); or a CtaPack of CALLERS —
10
- * `Btn aria-controls={a Sheet's id}` — WAYS IN (sign in · new phone · join), each calling a `Sheet state="away"` that holds that way's
11
- * whole form and its submit. The base itself carries no control; a form is never taken apart across it. Never both: a pager never works beside a sheet.
9
+ * (Lh 2026-09-20): several of them in a `Pages` = a walk of words the round pager turns (slideTo); or a CtaPack of WAYS IN
10
+ * (sign in · new phone · join) — plain `Btn`s; what each one opens (a `Sheet` holding that way's whole form) is the APP's wiring, not
11
+ * cardds's. The base itself carries no control; a form is never taken apart across it. Never both: a pager never works beside a sheet.
12
12
  */
13
13
  export declare function BaseContent({ className, ...rest }: BaseContentProps): import("react").JSX.Element;
14
14
  export interface CtaPackProps extends ComponentPropsWithoutRef<'div'> {
15
15
  }
16
- /** CtaPack — the buttons under a BaseContent's words, wrapping on one line. In a Step it holds CALLERS only (buttons that call a sheet, `aria-controls`) — the Step's move is its ActionBar. */
16
+ /** CtaPack — the buttons under a BaseContent's words, wrapping on one line. In a Step it holds ways in (each opens a sheet — the app wires that); the Step's own move is never here. */
17
17
  export declare function CtaPack({ className, ...rest }: CtaPackProps): import("react").JSX.Element;
@@ -5,14 +5,14 @@ import { cx } from '../cx.js';
5
5
  * maybe a CtaPack. Content has three homes (Card · Sheet · the base); this is the third one, named.
6
6
  * Was Section (and, inside a Step, StepCard) until 2026-09-16 — one block, one name.
7
7
  * In a Step it is the reading block (first child; the step's MOVE goes to the ActionBar, never here). It can move two ways
8
- * (Lh 2026-09-20): several of them in a `Pages` = a walk of words the round pager turns (slideTo); or a CtaPack of CALLERS —
9
- * `Btn aria-controls={a Sheet's id}` — WAYS IN (sign in · new phone · join), each calling a `Sheet state="away"` that holds that way's
10
- * whole form and its submit. The base itself carries no control; a form is never taken apart across it. Never both: a pager never works beside a sheet.
8
+ * (Lh 2026-09-20): several of them in a `Pages` = a walk of words the round pager turns (slideTo); or a CtaPack of WAYS IN
9
+ * (sign in · new phone · join) — plain `Btn`s; what each one opens (a `Sheet` holding that way's whole form) is the APP's wiring, not
10
+ * cardds's. The base itself carries no control; a form is never taken apart across it. Never both: a pager never works beside a sheet.
11
11
  */
12
12
  export function BaseContent({ className, ...rest }) {
13
13
  return _jsx("section", { className: cx('base-content', className), ...rest });
14
14
  }
15
- /** CtaPack — the buttons under a BaseContent's words, wrapping on one line. In a Step it holds CALLERS only (buttons that call a sheet, `aria-controls`) — the Step's move is its ActionBar. */
15
+ /** CtaPack — the buttons under a BaseContent's words, wrapping on one line. In a Step it holds ways in (each opens a sheet — the app wires that); the Step's own move is never here. */
16
16
  export function CtaPack({ className, ...rest }) {
17
17
  return _jsx("div", { className: cx('cta-pack', className), ...rest });
18
18
  }
@@ -1,8 +1,19 @@
1
1
  import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
2
  export interface ModalProps extends ComponentPropsWithoutRef<'article'> {
3
+ /** CONTROLLED: on the screen or not (default `true`). The app owns it. Set it `false` and the modal leaves the way it came — its
4
+ * opening played backwards — then calls `onClosed`, where the app stops rendering it (and its `SheetStage ask`). */
5
+ open?: boolean;
6
+ /** the leaving has finished (at once, when motion is reduced) — unmount here */
7
+ onClosed?: () => void;
3
8
  }
4
- /** Modal — the ask: ONE card at the centre of a `SheetStage ask`, two parts — a `Lift` (the thing being asked about, light, scrolls when tall) and a `Drawer` (the dark end that asks). No border. Two beats on appearing: drops in from above to the centre · the drawer comes out below while the lift moves up. A click on any button in the Drawer plays them backwards and fires `cardds:modal` on the stage (`detail.answer` = the button's `data-answer`) for the app to remove it. */
5
- export declare function Modal({ className, ...rest }: ModalProps): import("react").JSX.Element;
9
+ /**
10
+ * Modal — the ask: ONE card at the centre of a `SheetStage ask`, two parts — a `Lift` (the thing being asked about, light, scrolls
11
+ * when tall) and a `Drawer` (the dark end that asks). No border. Two beats on appearing: drops in from above, then the drawer comes
12
+ * out below while the lift moves up.
13
+ * A COMPONENT, not a wiring: it does not know its buttons. They are plain `IconBtn`s / `Btn`s with the app's `onClick` — the app
14
+ * records the answer and sets `open={false}`; the modal's one behaviour is how it leaves.
15
+ */
16
+ export declare function Modal({ open, onClosed, className, ...rest }: ModalProps): import("react").JSX.Element;
6
17
  export interface LiftProps extends ComponentPropsWithoutRef<'div'> {
7
18
  }
8
19
  /** Lift — the light part of a `Modal`: the case, request or item the question is about (a CardHead, rows, a timeline); scrolls inside when tall. */
@@ -13,7 +24,7 @@ export interface DrawerProps extends Omit<ComponentPropsWithoutRef<'div'>, 'titl
13
24
  /** the fine print under it */
14
25
  caption?: ReactNode;
15
26
  }
16
- /** Drawer — the dark end of a `Modal`: the question, its fine print, and a `BtnTrio` (or two buttons); give each button a `data-answer` — any of them closes the modal. */
27
+ /** Drawer — the dark end of a `Modal`: the question, its fine print, and a `BtnTrio` (or two buttons) — plain buttons with the app's `onClick`; the Drawer does nothing with them. */
17
28
  export declare function Drawer({ title, caption, className, children, ...rest }: DrawerProps): import("react").JSX.Element;
18
29
  export interface BtnTrioProps extends ComponentPropsWithoutRef<'div'> {
19
30
  }
@@ -1,15 +1,39 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Children, Fragment } from 'react';
2
+ import { Children, Fragment, useEffect, useRef } from 'react';
3
3
  import { cx } from '../cx.js';
4
- /** Modal — the ask: ONE card at the centre of a `SheetStage ask`, two parts — a `Lift` (the thing being asked about, light, scrolls when tall) and a `Drawer` (the dark end that asks). No border. Two beats on appearing: drops in from above to the centre · the drawer comes out below while the lift moves up. A click on any button in the Drawer plays them backwards and fires `cardds:modal` on the stage (`detail.answer` = the button's `data-answer`) for the app to remove it. */
5
- export function Modal({ className, ...rest }) {
6
- return _jsx("article", { className: cx('card modal', className), ...rest });
4
+ /**
5
+ * Modal — the ask: ONE card at the centre of a `SheetStage ask`, two parts — a `Lift` (the thing being asked about, light, scrolls
6
+ * when tall) and a `Drawer` (the dark end that asks). No border. Two beats on appearing: drops in from above, then the drawer comes
7
+ * out below while the lift moves up.
8
+ * A COMPONENT, not a wiring: it does not know its buttons. They are plain `IconBtn`s / `Btn`s with the app's `onClick` — the app
9
+ * records the answer and sets `open={false}`; the modal's one behaviour is how it leaves.
10
+ */
11
+ export function Modal({ open = true, onClosed, className, ...rest }) {
12
+ const ref = useRef(null);
13
+ const closed = useRef(onClosed);
14
+ closed.current = onClosed;
15
+ const shown = useRef(true); // the animations stand played forwards
16
+ useEffect(() => {
17
+ const el = ref.current;
18
+ if (!el || open === shown.current)
19
+ return;
20
+ shown.current = open;
21
+ const anims = el.getAnimations({ subtree: true });
22
+ anims.forEach((a) => a.reverse()); // the same keyframes, backwards to leave, forwards again to return — no closing keyframes to keep in step
23
+ if (open)
24
+ return;
25
+ let live = true;
26
+ void Promise.all(anims.map((a) => a.finished.catch(() => undefined))).then(() => { if (live)
27
+ closed.current?.(); });
28
+ return () => { live = false; };
29
+ }, [open]);
30
+ return _jsx("article", { ref: ref, className: cx('card modal', className), ...rest });
7
31
  }
8
32
  /** Lift — the light part of a `Modal`: the case, request or item the question is about (a CardHead, rows, a timeline); scrolls inside when tall. */
9
33
  export function Lift({ className, ...rest }) {
10
34
  return _jsx("div", { className: cx('modal__lift', className), ...rest });
11
35
  }
12
- /** Drawer — the dark end of a `Modal`: the question, its fine print, and a `BtnTrio` (or two buttons); give each button a `data-answer` — any of them closes the modal. */
36
+ /** Drawer — the dark end of a `Modal`: the question, its fine print, and a `BtnTrio` (or two buttons) — plain buttons with the app's `onClick`; the Drawer does nothing with them. */
13
37
  export function Drawer({ title, caption, className, children, ...rest }) {
14
38
  return (_jsxs("div", { className: cx('modal__drawer', className), ...rest, children: [_jsx("h2", { className: "t-h2", children: title }), caption != null && _jsx("p", { className: "t-caption", children: caption }), children] }));
15
39
  }
@@ -1,24 +1,37 @@
1
1
  import type { ComponentPropsWithoutRef } from 'react';
2
+ /** how far the sheet slid. `away` = not on the screen (slid out, inert) — a state the app sets, never one a drag or a tap lands on */
2
3
  export type SheetState = 'peek' | 'half' | '3q' | 'full' | 'away';
3
- export interface SheetProps extends ComponentPropsWithoutRef<'article'> {
4
- /** how far the sheet slid: `peek` (handle + title row) · `half` (default) · `3q` (three quarters showing) · `full` (its top edge at the top bar) — a class, never a height. `away` = a CALLED sheet not on the screen yet (slid out, inert): a `Btn aria-controls={its id}` in the Step's BaseContent calls it up to full and sends any other away. A tap on the handle toggles between this state and peek; a drag snaps to any of `states` */
4
+ /** what asked for the change: the handle's `tap`, a `drag` released, `focus` in a peeked sheet, the `keyboard` (or anything else) moving the cut */
5
+ export type SheetChangeReason = 'tap' | 'drag' | 'focus' | 'keyboard';
6
+ declare const ORDER: readonly ["peek", "half", "3q", "full"];
7
+ type Resting = (typeof ORDER)[number];
8
+ export interface SheetProps extends Omit<ComponentPropsWithoutRef<'article'>, 'onChange'> {
9
+ /** CONTROLLED: where the sheet is. The app owns it — pair it with `onStateChange`, or the sheet cannot move (like `value` without `onChange`).
10
+ * `peek` (handle + title row) · `half` · `3q` · `full` (its top edge at the top bar) · `away` (not on the screen). A class, never a height. */
5
11
  state?: SheetState;
12
+ /** UNCONTROLLED: where it starts (default `half`); the sheet then keeps its own state. Ignored when `state` is given. */
13
+ defaultState?: SheetState;
14
+ /** the sheet ASKS to move — the handle was tapped or dragged, a field in a peeked sheet took focus, the keyboard moved the cut.
15
+ * Controlled: set your state to it (or don't — the sheet stays). This is where an app ties a sheet to its router / back button. */
16
+ onStateChange?: (state: SheetState, reason: SheetChangeReason) => void;
17
+ /** the states a drag snaps to and a reveal may climb through — "peek half full", or an array (default all four) */
18
+ states?: string | readonly Resting[];
6
19
  /** floats above a footnote strip on a dim stage (bottom corners and border back) */
7
20
  raised?: boolean;
8
- /** the grab handle (tap / drag / keyboard via cardds.js); `false` for a sheet with no handle, a string sets its accessible name */
21
+ /** the grab handle; `false` for a sheet with no handle, a string sets its accessible name */
9
22
  handle?: boolean | string;
10
- /** which states a drag snaps to, e.g. "peek half full" (default all four: peek half 3q full) */
11
- states?: string;
12
23
  }
13
24
  /**
14
- * Sheet — the SINGLE sheet: one card as a bottom sheet holding what the member ACTS on (controls, or small `Card sm`
15
- * content cards). Always the full height of its box — a Step or a SheetStage — a state is only how far it slid.
16
- * Shape: handle · head (a CardHead, a SheetHead — or ONE label Row carrying a Check/Toggle, when the sheet's
17
- * whole ask is that switch and peek must show it) · SheetBody (the only part that scrolls). Written top-down.
18
- * It FINISHES on itself: a `CardFoot` as its last child holds the one button that completes it — standing at the bottom at
19
- * half / 3q / full, gone at peek. Never an ActionBar in front of a sheet (a bar is the base's). Nothing in the body commits
20
- * on its own. Older note — its own buttons only on a
21
- * screen with no bar (the map sheet). Called sheets: several `Sheet state="away" states="peek full"` in one Step, each with an `id` a
22
- * button names — one on the screen at a time; never beside a pager. This is the single sheet; the menu of stacked sheets is `SheetStack`.
25
+ * Sheet — the SINGLE sheet: one card as a bottom sheet holding what the member ACTS on. Always the full height of its
26
+ * box — a Step or a SheetStage — a state is only how far it slid (translate; the material never changes size).
27
+ * ONE SHAPE, written top-down: handle · head (a CardHead / SheetHead — pinned, never scrolls) · SheetBody (scrolls, as far as
28
+ * it needs) · CardFoot (the button that finishes the sheet — at the bottom, above the keyboard; gone at peek).
29
+ *
30
+ * It is a COMPONENT, not a wiring: it knows nothing of what opens it. The app owns `state` and sets it from whatever it
31
+ * likes — a button, a route, a timer: `<Btn onClick={() => setSheet('full')}>` … `<Sheet state={sheet} onStateChange={setSheet}>`.
32
+ * Its own behaviour stays inside it and only ever ASKS (`onStateChange`): a tap on the handle toggles its own state ↔ peek,
33
+ * a drag snaps to the nearest of `states`, focus in a peeked sheet raises it, and when the cut moves (the keyboard) it
34
+ * climbs until the focused control has room above the foot.
23
35
  */
24
- export declare function Sheet({ state, raised, handle, states, className, children, ...rest }: SheetProps): import("react").JSX.Element;
36
+ export declare function Sheet({ state, defaultState, onStateChange, states, raised, handle, className, children, onFocus, ...rest }: SheetProps): import("react").JSX.Element;
37
+ export {};