@usableapp/cardds 0.2.1 → 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.
package/dist/cardds.css CHANGED
@@ -90,6 +90,8 @@
90
90
  --chip-h: calc(var(--tap-base) * 19 / 24); /* 38 chip height */
91
91
  --bar-h: calc(var(--tap-base) * 4 / 3); /* 64 float bar, day cell, deck actions */
92
92
  --header-h: calc(var(--tap-base) * 5 / 4); /* 60 top bar — .topbar and .appbar alike */
93
+ --card-pad-rest: var(--sp-5); /* 20 a card's padding before any variant — .card publishes it as --card-pad; the sheet's peek strip is built from it where no card is in scope */
94
+ --card-gap-rest: var(--sp-4); /* 16 …and its gap */
93
95
  --screen-pad: var(--sp-4); /* 16 the screen's inset — .sheet-stack cancels it */
94
96
  --map-ring: calc(var(--tap-base) * 10 / 3); /* the rough-area ring on a map (sheet.css .map__ring) — a neighbourhood, not a house */
95
97
  --fab: calc(var(--tap-base) * 3 / 2); /* 72 square floating action */
@@ -521,8 +523,8 @@ button { font: inherit; cursor: pointer; }
521
523
  .card {
522
524
  --card-bg: var(--card-1-bg); /* the slot, readable by children (avatar rings, fold) */
523
525
  --card-ink: var(--card-1-ink);
524
- --card-pad: var(--sp-5); /* published so .bleed can cancel it exactly */
525
- --card-gap: var(--sp-4); /* published so a sheet's peek strip (sheet.css) is exact */
526
+ --card-pad: var(--card-pad-rest); /* published so .bleed can cancel it exactly */
527
+ --card-gap: var(--card-gap-rest); /* published so a sheet's peek strip (sheet.css) is exact */
526
528
  background: var(--card-bg);
527
529
  color: var(--card-ink);
528
530
  border: var(--border-w) solid var(--card-border);
@@ -1105,7 +1107,7 @@ button { font: inherit; cursor: pointer; }
1105
1107
  page. Many sheets stacked as the app's menu are the other system,
1106
1108
  .sheet-stack (stack.css): the same material, its own class and file.
1107
1109
 
1108
- <article class="card sheet sheet--half" data-sheet-states="peek half 3q full">
1110
+ <article class="card sheet sheet--half">
1109
1111
  A tap on the handle TOGGLES the sheet between its own state and peek
1110
1112
  (Lh 2026-09-16) — never a step-by-step climb; a drag snaps to any of
1111
1113
  the listed states, and the state it lands on becomes the one a tap
@@ -1123,14 +1125,12 @@ button { font: inherit; cursor: pointer; }
1123
1125
  ============================================================ */
1124
1126
 
1125
1127
  .sheet {
1126
- --_handle: var(--sp-1);
1127
1128
  /* the strip a peeked sheet shows: the card's own top padding, the handle,
1128
1129
  the gap, one title row (--tap — the head rule below makes every head at
1129
1130
  least that tall) and the gap under it, so the cut lands exactly where
1130
1131
  the first control starts and nothing of it leaks through. Published
1131
1132
  (and registered in tokens.css) so a sheet whose head is taller can
1132
1133
  retune it: .my-sheet { --sheet-peek: … } */
1133
- --sheet-peek: calc(var(--card-pad) + var(--_handle) + 2 * var(--card-gap) + var(--tap));
1134
1134
  --sheet-half: 0.5; /* a share of the sheet's own height */
1135
1135
  --sheet-3q: 0.25; /* three quarters showing: slid by a quarter */
1136
1136
  /* the sheet's own height, seen from inside it: the box it slides in is the
@@ -1165,14 +1165,16 @@ button { font: inherit; cursor: pointer; }
1165
1165
  .sheet--half { --_y: calc(var(--sheet-half) * 100%); --_cover: calc(var(--sheet-half) * var(--_box)); }
1166
1166
  .sheet--3q { --_y: calc(var(--sheet-3q) * 100%); --_cover: calc(var(--sheet-3q) * var(--_box)); }
1167
1167
  .sheet--peek { --_y: calc(100% - var(--sheet-peek)); --_cover: calc(var(--_box) - var(--sheet-peek)); }
1168
+ /* --sheet-peek is declared on the BOX the sheet slides in, not on the sheet: the words behind a peeked sheet
1169
+ have to read it too (step.css keeps that much room under them), and a token only flows down. The sheet
1170
+ inherits it. A sheet whose head is taller retunes it on the box. */
1171
+ :is(.step, .sheet-stage) { --_handle: var(--sp-1); --sheet-peek: calc(var(--card-pad-rest) + var(--_handle) + 2 * var(--card-gap-rest) + var(--tap)); }
1172
+
1168
1173
  /* ---- called sheets (Lh, 2026-09-20): a .step whose .base-content has buttons that each CALL a sheet ----
1169
1174
  .sheet--away = not on the screen: slid all the way out (translate, like every state — the material never
1170
1175
  changes), and hidden once it has left so its shadow does not lie on the step's bottom edge; write it
1171
- with `inert`. A button names its sheet — <button aria-controls="when"> … <article id="when" class="card
1172
- sheet sheet--away" inert data-sheet-states="peek full"> — and cardds.js does the rest: a tap slides that
1173
- sheet up to full and sends any other one away; the handle takes it down to peek, never further, so the
1174
- words and the other buttons are back in reach; the same button again raises it from peek to full.
1175
- Nothing waits at peek before the first call. No bar on such a screen (actions.css).
1176
+ with `inert` (the React Sheet does). WHO brings it onto the screen is the app's business — a button, a route:
1177
+ cardds ties no component to another. Nothing waits at peek before the first call.
1176
1178
  WHAT IT IS FOR: the base offers WAYS IN — sign in · new phone · join — and each button calls a sheet
1177
1179
  that holds that way's WHOLE form, its submit on the sheet's foot, leading to the next page. The base
1178
1180
  carries no control. It is NOT for a form taken apart — one sheet per field, a "send" left on the base:
@@ -1202,6 +1204,12 @@ button { font: inherit; cursor: pointer; }
1202
1204
  the cut. A length from the box's container units (--_box), since the
1203
1205
  cut is a share of the SHEET's height, not of the body's. */
1204
1206
  scroll-padding-bottom: var(--_cover);
1207
+ /* …and its content has to be able to LEAVE that part: the same length as bottom padding, so the last row
1208
+ scrolls up to the visible edge at half and 3q as it does at full — everything between the head and the
1209
+ foot can be reached at any state (Lh, 2026-09-21). Padding, not a shorter box: nothing that shows moves
1210
+ when the state changes, only how far the body scrolls; a child that fills the body (a composer) fills
1211
+ the part that shows. */
1212
+ padding-bottom: var(--_cover);
1205
1213
  }
1206
1214
  /* ---- the sheet's foot: the button that FINISHES the sheet, on the sheet (Lh, 2026-09-20) ----
1207
1215
  A screen with a sheet has no bar in front of it: the button that completes what the sheet asks — the
@@ -1228,8 +1236,13 @@ button { font: inherit; cursor: pointer; }
1228
1236
  .sheet:has(> .card__foot) > .sheet__body { margin-bottom: calc(var(--_foot) - var(--card-pad)); } /* the body stops above the strip (the card's own padding is the rest) */
1229
1237
  .sheet.is-dragging > .card__foot { transition: none; }
1230
1238
  :is(.sheet--peek, .sheet--away) > .card__foot { opacity: 0; visibility: hidden; transition: translate var(--motion-sheet) ease, opacity var(--motion-sheet) ease, visibility 0s linear var(--motion-sheet); }
1239
+ /* that padding can be taller than the body's own box (a short box: a small phone with the keyboard up) and a
1240
+ box is never smaller than its padding — so the pinned parts must not give way to it: the body runs past
1241
+ the sheet's clipped edge instead, where nothing shows anyway. At peek and away no part of the body shows. */
1242
+ .sheet > :not(.sheet__body) { flex-shrink: 0; }
1243
+ :is(.sheet--peek, .sheet--away) > .sheet__body { padding-bottom: 0; }
1231
1244
  .sheet__body > * { min-width: 0; } /* as .card > *: children shrink to the track, never widen it */
1232
- .sheet__body:last-child { margin-bottom: calc(-1 * var(--card-pad)); padding-bottom: var(--card-pad); }
1245
+ .sheet__body:last-child { margin-bottom: calc(-1 * var(--card-pad)); padding-bottom: calc(var(--card-pad) + var(--_cover)); }
1233
1246
 
1234
1247
  /* grab handle — first child of the sheet. Drawn as a small bar, but it is
1235
1248
  the thumb's grab area for the whole strip above the title: a pseudo widens
@@ -1390,6 +1403,12 @@ button { font: inherit; cursor: pointer; }
1390
1403
  /* raised: the sheet floats above a footnote strip on the scrim. Its bottom
1391
1404
  edge shows, so it gets its corners and border back; full still stops at
1392
1405
  the top bar's line, because the top edge never moved. */
1406
+ /* EVERY sheet has one shape (Lh, 2026-09-21): the handle and the head stay at the top and never scroll · the
1407
+ foot's button stands at the bottom, and ABOVE the keyboard when there is one · everything between them
1408
+ scrolls, as far as it needs to. In a .step the keyboard's room is the step's (--kb, step.css); on a stage
1409
+ the sheet gives it up itself, as --_lift — the same token a raised sheet floats on. */
1410
+ .sheet-stage > .sheet { --_lift: var(--kb, 0rem); bottom: var(--_lift); }
1411
+ .sheet-stage > .sheet--raised { --_lift: calc(var(--tap-lg) + var(--kb, 0rem)); }
1393
1412
  .sheet--raised { --_lift: var(--tap-lg); bottom: var(--_lift); border-radius: var(--r-card); border-bottom: var(--border-w) solid var(--card-border); }
1394
1413
  .sheet-stage__foot {
1395
1414
  position: absolute;
@@ -1480,8 +1499,9 @@ button { font: inherit; cursor: pointer; }
1480
1499
  :has(> .action-bar) .step { margin-bottom: calc(var(--bar-reserve) + var(--kb, 0rem)); }
1481
1500
  /* LAYERS (Lh, 2026-09-20): the base, then the sheet over it, then the top bar. A bar is the BASE's — the
1482
1501
  move of a page of words — so a step with a sheet has NO bar: the sheet finishes on its own .card__foot
1483
- (sheet.css), and a form lives whole in ONE sheet, never item by item on the base. Should a bar be written
1484
- beside a sheet anyway it stands down (actions.css + cardds.js) — a net for a mistake, not a layout. */
1502
+ (sheet.css), and a form lives whole in ONE sheet, never item by item on the base. That is how cardds's
1503
+ own screens are composed (the guide); the framework does not police it — a component never changes
1504
+ because another one is there. */
1485
1505
 
1486
1506
  /* the reading block: a .base-content (base.css) — the same words-on-the-base
1487
1507
  block as anywhere else (it was .step__card, and a .card before 2026-09-16;
@@ -1500,11 +1520,10 @@ button { font: inherit; cursor: pointer; }
1500
1520
  color: var(--on-surface);
1501
1521
  }
1502
1522
  /* the reading block is the .base-content — or the .pages that holds several of them, a walk of words
1503
- (card.css). --sheet-room: the strip a CALLED sheet keeps at peek (sheet.css, "called sheets"), published
1504
- on the step by cardds.js while one is on the screen, so the buttons under it can scroll clear of it;
1505
- nothing without one. */
1523
+ (card.css). Behind a PEEKED sheet the words keep the strip's room under them, so whatever is down there
1524
+ (the buttons that call the other sheets) scrolls clear of it — CSS alone: the box declares --sheet-peek. */
1525
+ .step:has(> .sheet--peek) > :is(.base-content, .pages) { padding-bottom: var(--sheet-peek); }
1506
1526
  .step > :is(.base-content, .pages) {
1507
- padding-bottom: var(--sheet-room, 0rem);
1508
1527
  flex: 0 1 auto;
1509
1528
  }
1510
1529
  .step > :is(.base-content, .pages) {
@@ -1879,14 +1898,9 @@ button { font: inherit; cursor: pointer; }
1879
1898
  <button class="icon-btn icon-btn--invert" aria-label="ถัดไป">…</button>
1880
1899
  </div> — or the same two in an .action-bar__tier--pager under a control */
1881
1900
  .action-bar--pager { align-items: center; }
1882
- /* The bar belongs to the BASE (Lh, 2026-09-20): it is the move of a page of words — a pager turning
1883
- them, or one wide button. It is never in front of a sheet: a sheet that is on the screen covers it
1884
- (step.css, "layers"), a peeking one included, and the button that finishes a sheet is the sheet's own
1885
- foot (sheet.css). So a pager never works beside a sheet — that screen has work that cannot be walked
1886
- past — and neither does any other bar: while a sheet is on (only .sheet--away, a sheet not called yet,
1887
- is not), the bar takes no pointer here and cardds.js makes it inert, so the keyboard cannot reach what
1888
- the eye cannot see. */
1889
- :has(.step > .sheet:not(.sheet--away)) > .action-bar { pointer-events: none; }
1901
+ /* The bar belongs to the BASE: the move of a page of words — a pager turning them, or one wide button. A screen
1902
+ with a sheet finishes on the sheet's own foot instead (sheet.css). Guidance for composing, not a rule the
1903
+ CSS enforces: a component never changes because another one is there. */
1890
1904
  /* the page dots: under the card in a .screen__centre group; tight gaps */
1891
1905
  .pager__at { flex: 0 1 auto; min-width: 0; overflow: hidden; display: flex; align-items: center; gap: var(--sp-1); }
1892
1906
  .pager__at > * {
@@ -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
  }