@usableapp/cardds 0.7.53 → 0.7.55

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,9 +1,10 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { Children, createElement, isValidElement } from 'react';
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Children, createElement, isValidElement, useRef } from 'react';
3
3
  import { cx } from '../cx.js';
4
4
  import { warnOnce } from '../state.js';
5
5
  import { CardHead } from './CardHead.js';
6
6
  import { CardFoot } from './CardFoot.js';
7
+ import { StackHeadCtx, StackPullCtx, stackPaint, useStackCard } from '../sheets/stackCard.js';
7
8
  /**
8
9
  * Card — ONE boundary: head · content · foot. Cards are content the system offers (the words, the thing to read).
9
10
  * The head and the foot are SLOTS: `heading` · `caption` · `end` (the card writes its CardHead first) and `foot` · `footLink`
@@ -11,7 +12,7 @@ import { CardFoot } from './CardFoot.js';
11
12
  * boundary). The order is the card's, never the writer's. A head of its own shape: write `<CardHead>` as the first child instead.
12
13
  * A page with one feature and one CTA may be a single card. Keep everything big.
13
14
  */
14
- export function Card({ tone, sm, row, cover, fold, centre, band, as = 'article', heading, caption, end, level, foot, footLink, className, children, ...rest }) {
15
+ export function Card({ tone, sm, row, cover, fold, centre, band, as = 'article', heading, caption, end, level, foot, footLink, stack, className, style, children, onClick, ref, ...rest }) {
15
16
  const head = heading != null || caption != null || end != null;
16
17
  const tail = foot != null || footLink != null;
17
18
  if (head || tail) {
@@ -21,9 +22,44 @@ export function Card({ tone, sm, row, cover, fold, centre, band, as = 'article',
21
22
  if (tail && parts.some((c) => c.type === CardFoot))
22
23
  warnOnce('card-foot-twice', 'cardds Card: `foot`/`footLink` AND a <CardFoot> child — one foot per card; use one way');
23
24
  }
24
- const props = { className: cx('card', tone && tone !== 1 && `card--${tone}`, sm && 'card--sm', row && 'card--row', cover && 'card--cover', fold && 'card--fold', centre && 'card--centre', band && 'card--band', className), ...rest };
25
+ const own = useRef(null);
26
+ const swallow = useRef(false);
27
+ const pulling = useStackCard(own, stack, swallow);
28
+ const painted = stackPaint(stack, pulling);
29
+ const setRef = (node) => {
30
+ own.current = node;
31
+ const passed = ref;
32
+ if (typeof passed === 'function')
33
+ passed(node);
34
+ else if (passed)
35
+ passed.current = node;
36
+ };
37
+ const click = (e) => {
38
+ onClick?.(e);
39
+ if (!stack || e.defaultPrevented || swallow.current)
40
+ return;
41
+ const target = e.target;
42
+ if (target.closest('.sheet-stack__back')) {
43
+ stack.onClose();
44
+ return;
45
+ }
46
+ if (stack.open && target.closest('.card__head > :is(h1, h2, h3)')) {
47
+ stack.onClose();
48
+ return;
49
+ } // the title folds it; the back stays the keyboard's way
50
+ if (!stack.open && !stack.before && !stack.after)
51
+ stack.onOpen();
52
+ };
53
+ const inner = (body) => (_jsx(StackHeadCtx.Provider, { value: stack ? (stack.before || stack.after ? -1 : 0) : null, children: _jsx(StackPullCtx.Provider, { value: pulling, children: body }) }));
54
+ const props = {
55
+ ...rest,
56
+ ref: setRef,
57
+ className: cx('card', tone && tone !== 1 && `card--${tone}`, sm && 'card--sm', row && 'card--row', cover && 'card--cover', fold && 'card--fold', centre && 'card--centre', band && 'card--band', painted.className, className),
58
+ style: painted.style || style ? { ...painted.style, ...style } : undefined,
59
+ ...(stack ? { 'aria-busy': painted.busy } : {}),
60
+ onClick: stack ? click : onClick,
61
+ };
25
62
  if (!head && !tail)
26
- return createElement(as, props, children);
27
- return createElement(as, props, head && _jsx(CardHead, { heading: heading, caption: caption, end: end, level: level }, "head"), ...Children.toArray(children), // keyed: the slots sit around them
28
- tail && _jsx(CardFoot, { link: footLink, children: foot }, "foot"));
63
+ return createElement(as, props, inner(children));
64
+ return createElement(as, props, inner(_jsxs(_Fragment, { children: [head && _jsx(CardHead, { heading: heading, caption: caption, end: end, level: level }, "head"), Children.toArray(children), tail && _jsx(CardFoot, { link: footLink, children: foot }, "foot")] })));
29
65
  }
@@ -1,4 +1,4 @@
1
- import type { ComponentProps, ReactNode } from 'react';
1
+ import { type ComponentProps, type ReactNode } from 'react';
2
2
  export interface CardHeadProps extends ComponentProps<'div'> {
3
3
  /** the heading (a string becomes an h2 at level `t-h2`) — wraps freely, as many lines as the words need */
4
4
  heading?: ReactNode;
@@ -10,10 +10,7 @@ export interface CardHeadProps extends ComponentProps<'div'> {
10
10
  top?: boolean;
11
11
  /** the heading level to render the string heading with (h2 default; h1 for the one-feature page) */
12
12
  level?: 'h1' | 'h2' | 'display';
13
+ /** the sheet measured this title as wider than its line, so the heading runs sideways */
14
+ long?: boolean;
13
15
  }
14
- /**
15
- * CardHead — the card's head: the heading on the left takes the room, the contextual info hugs the far right.
16
- * Use `heading` (+ `caption`) and `end` for the standard head, or pass children for a custom one
17
- * (the FIRST child takes the room). Flat markup: no wrapper element, no <p> — the caption is a span on a row of its own (card.css). Never put buttons here — they go in the CardFoot.
18
- */
19
- export declare function CardHead({ heading, caption, end, top, level, className, children, ...rest }: CardHeadProps): import("react").JSX.Element;
16
+ export declare function CardHead({ heading, caption, end, top, level, long, className, children, ...rest }: CardHeadProps): import("react").JSX.Element;
@@ -1,11 +1,39 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Children, cloneElement, isValidElement, useContext } from 'react';
2
3
  import { cx } from '../cx.js';
4
+ import { Text } from '../type/Text.js';
5
+ import { StackHeadCtx } from '../sheets/stackCard.js';
3
6
  /**
4
7
  * CardHead — the card's head: the heading on the left takes the room, the contextual info hugs the far right.
5
8
  * Use `heading` (+ `caption`) and `end` for the standard head, or pass children for a custom one
6
9
  * (the FIRST child takes the room). Flat markup: no wrapper element, no <p> — the caption is a span on a row of its own (card.css). Never put buttons here — they go in the CardFoot.
7
10
  */
8
- export function CardHead({ heading, caption, end, top, level = 'h2', className, children, ...rest }) {
11
+ const onTitleKey = (e) => {
12
+ if (e.key !== 'Enter' && e.key !== ' ')
13
+ return;
14
+ e.preventDefault();
15
+ e.currentTarget.click();
16
+ };
17
+ /** the stack's title is the keyboard's way in. The heading renders the tab stop and, when the sheet measured it long, `.is-long`. Nothing sets either on the node afterwards */
18
+ function markTitle(node, tab, long) {
19
+ if (!isValidElement(node))
20
+ return node;
21
+ const el = node;
22
+ const level = el.type === Text ? el.props.level : null;
23
+ const intrinsic = typeof el.type === 'string' && /^h[1-3]$/.test(el.type);
24
+ if (level !== 'h1' && level !== 'h2' && level !== 'display' && !intrinsic)
25
+ return node;
26
+ return cloneElement(el, {
27
+ ...(long ? { className: cx(el.props.className, 'is-long') } : {}),
28
+ ...(tab != null ? {
29
+ tabIndex: tab,
30
+ onKeyDown: (e) => { el.props.onKeyDown?.(e); if (!e.defaultPrevented)
31
+ onTitleKey(e); },
32
+ } : {}),
33
+ });
34
+ }
35
+ export function CardHead({ heading, caption, end, top, level = 'h2', long, className, children, ...rest }) {
36
+ const tab = useContext(StackHeadCtx);
9
37
  const H = level === 'h1' ? 'h1' : 'h2';
10
- return (_jsxs("div", { className: cx('card__head', top && 'card__head--top', className), ...rest, children: [heading != null && _jsx(H, { className: `t-${level}`, children: heading }), caption != null && _jsx("span", { className: "t-caption t-muted", children: caption }), children, end] }));
38
+ return (_jsxs("div", { className: cx('card__head', top && 'card__head--top', className), ...rest, children: [heading != null && _jsx(H, { className: cx(`t-${level}`, long && 'is-long'), tabIndex: tab ?? undefined, onKeyDown: tab != null ? onTitleKey : undefined, children: heading }), caption != null && _jsx("span", { className: "t-caption t-muted", children: caption }), tab == null && !long ? children : Children.map(children, (c) => markTitle(c, tab, !!long)), end] }));
11
39
  }
@@ -1,18 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useRef } from 'react';
2
+ import { useState } from 'react';
3
3
  import { cx } from '../cx.js';
4
+ import { useStepCursor } from '../sheets/stepCursor.js';
5
+ const share = (value, min, max) => (max > min ? Math.min(1, Math.max(0, (value - min) / (max - min))) : 0);
4
6
  /** Slider — one value along a hairline rail with a 48px knob; the whole range is thumb-tall. */
5
7
  export function Slider({ label, glyph, className, style, onInput, ...input }) {
6
- const range = useRef(null);
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 })] }));
8
+ const cursor = useStepCursor();
9
+ const min = Number(input.min) || 0, max = Number(input.max) || 100;
10
+ const { value: given, defaultValue, ...rest } = input;
11
+ const held = given != null && given !== '';
12
+ const [live, setLive] = useState(Number((held ? given : defaultValue) ?? min));
13
+ const shown = held ? Number(given) : live;
14
+ return (_jsxs("label", { className: cx('slider', className), style: { ...style, '--slider-at': String(share(shown, min, max)) }, children: [label != null && _jsx("span", { className: "slider__label", children: label }), glyph != null && _jsx("span", { className: "slider__glyph", "aria-hidden": "true", children: glyph }), _jsx("input", { className: "slider__range", type: "range", onInput: (e) => { if (!held)
15
+ setLive(Number(e.currentTarget.value)); onInput?.(e); }, ...rest, ...(held ? { value: given } : { defaultValue }), ...cursor })] }));
18
16
  }
@@ -1,6 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from '../cx.js';
3
+ import { useStepCursor } from '../sheets/stepCursor.js';
3
4
  /** Composer — a big write-in area (display type) with a bottom bar: icon · hint · send. The write-in step's sheet content. */
4
5
  export function Composer({ hint, lead, send, className, style, ...textarea }) {
5
- return (_jsxs("div", { className: cx('composer', className), style: style, children: [_jsx("textarea", { className: "composer__input", ...textarea }), _jsxs("div", { className: "composer__bar", children: [lead, hint != null && _jsx("span", { className: "composer__hint", children: hint }), send] })] }));
6
+ const cursor = useStepCursor();
7
+ return (_jsxs("div", { className: cx('composer', className), style: style, children: [_jsx("textarea", { className: "composer__input", ...textarea, ...cursor }), _jsxs("div", { className: "composer__bar", children: [lead, hint != null && _jsx("span", { className: "composer__hint", children: hint }), send] })] }));
6
8
  }
@@ -4,6 +4,7 @@ import { watchSize } from '../state.js';
4
4
  import { formatOk, useCheck } from './check.js';
5
5
  import { cx } from '../cx.js';
6
6
  import { Icon } from '../type/Icon.js';
7
+ import { useStepCursor } from '../sheets/stepCursor.js';
7
8
  /** the digits of `v`, written into `mask`; a mark is written only when a digit comes after it */
8
9
  export function applyMask(v, mask) {
9
10
  const d = v.replace(/\D/g, '');
@@ -36,6 +37,7 @@ export function Field({ icon, action, label, error, format, digits, formatError,
36
37
  };
37
38
  const { say } = useCheck((strict) => { const w = verdict(strict); if (w != null)
38
39
  setBad(true); return w; }, () => { ref.current?.focus(); ref.current?.select(); });
40
+ const cursor = useStepCursor();
39
41
  /* a placeholder longer than the box SHRINKS to show whole (Lh 2026-09-25): its width is measured in the input's own font,
40
42
  and `--_ph-fit` (the share that fits, ≤ 1) scales the placeholder's size — again whenever the box or the font changes */
41
43
  useLayoutEffect(() => {
@@ -70,7 +72,7 @@ export function Field({ icon, action, label, error, format, digits, formatError,
70
72
  }
71
73
  // eslint-disable-next-line react-hooks/exhaustive-deps -- when the app's error comes or changes
72
74
  }, [error]);
73
- return (_jsxs("label", { className: cx('field', action != null && 'field--action', wrong && 'field--error', className), style: style, children: [icon && _jsx(Icon, { name: icon, size: "sm" }), _jsx("input", { ref: ref, "aria-label": label, "aria-invalid": wrong || undefined, ...input, onBlur: (e) => { onBlur?.(e); if (!checked)
75
+ return (_jsxs("label", { className: cx('field', action != null && 'field--action', wrong && 'field--error', className), style: style, children: [icon && _jsx(Icon, { name: icon, size: "sm" }), _jsx("input", { ref: ref, "aria-label": label, "aria-invalid": wrong || undefined, ...input, ...cursor, onBlur: (e) => { onBlur?.(e); if (!checked)
74
76
  return; const w = verdict(false); setBad(w != null); say(w); }, onChange: (e) => {
75
77
  if (mask) {
76
78
  const el = e.currentTarget, before = el.value.slice(0, el.selectionStart ?? el.value.length).replace(/\D/g, '').length;
@@ -1,10 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from '../cx.js';
3
+ import { useStepCursor } from '../sheets/stepCursor.js';
3
4
  /**
4
5
  * FileBtn — a Btn that opens the device's file picker: the button IS the label of a hidden file input, so the
5
6
  * whole button is the tap target and nothing is forwarded by script. `accept="image/*" capture="environment"`
6
7
  * opens the camera; without `capture` the gallery. The picked file arrives in `onChange` like any input.
7
8
  */
8
9
  export function FileBtn({ primary, quiet, block, className, children, ...input }) {
9
- return (_jsxs("label", { className: cx('btn', primary && 'btn--primary', quiet && 'btn--quiet', block && 'btn--block', className), children: [_jsx("input", { type: "file", className: "t-sr", ...input }), children] }));
10
+ const cursor = useStepCursor();
11
+ return (_jsxs("label", { className: cx('btn', primary && 'btn--primary', quiet && 'btn--quiet', block && 'btn--block', className), children: [_jsx("input", { type: "file", className: "t-sr", ...input, ...cursor }), children] }));
10
12
  }
package/dist/forms/Pin.js CHANGED
@@ -4,6 +4,7 @@ import { useControlled, useLatest } from '../state.js';
4
4
  import { cx } from '../cx.js';
5
5
  import { useCheck } from './check.js';
6
6
  import { word } from '../words.js';
7
+ import { useStepCursor } from '../sheets/stepCursor.js';
7
8
  const RESET_MS = 900; // a code that does not match: red + shake this long, then the cells empty for a new try
8
9
  /**
9
10
  * Pin — big code entry, one digit per pill; a filled cell turns accent. Lives in a Sheet.
@@ -13,6 +14,7 @@ const RESET_MS = 900; // a code that does not match: red + shake this long, then
13
14
  */
14
15
  export function Pin({ length = 6, value, defaultValue = '', label = word('digit'), onChange, autoFocus, error, match, formatError, secret, className, onAnimationEnd, ...rest }) {
15
16
  const ref = useRef(null);
17
+ const cursor = useStepCursor();
16
18
  const [typed, request] = useControlled(value, defaultValue, onChange);
17
19
  const code = typed.replace(/\D/g, '').slice(0, length);
18
20
  const cell = (i) => ref.current?.querySelectorAll('.pin__cell')[i];
@@ -171,7 +173,7 @@ export function Pin({ length = 6, value, defaultValue = '', label = word('digit'
171
173
  setFresh(null); onAnimationEnd?.(e); }, children: Array.from({ length }, (_, i) => (
172
174
  // no maxLength: the browser would cut a pasted code before this ever saw it. A cell holds one digit — typing into a full one
173
175
  // replaces it, so its digit is selected whenever the cell takes the focus
174
- _jsx("input", { className: cx('pin__cell', secret && fresh === i && 'is-fresh'), inputMode: "numeric", pattern: "[0-9]*", placeholder: " ", value: code[i] ?? '', "aria-label": `${label} ${i + 1}`, "aria-invalid": wrong || undefined, autoComplete: i === 0 && !secret ? 'one-time-code' : 'off', onChange: input(i), onPaste: paste, onKeyDown: key(i), onFocus: (e) => {
176
+ _jsx("input", { className: cx('pin__cell', secret && fresh === i && 'is-fresh'), inputMode: "numeric", pattern: "[0-9]*", placeholder: " ", value: code[i] ?? '', "aria-label": `${label} ${i + 1}`, "aria-invalid": wrong || undefined, ...(i === 0 ? cursor : undefined), autoComplete: i === 0 && !secret ? 'one-time-code' : 'off', onChange: input(i), onPaste: paste, onKeyDown: key(i), onFocus: (e) => {
175
177
  if (!restart.current)
176
178
  e.currentTarget.select();
177
179
  if (performance.now() - tapped.current > 700 && window.matchMedia('(pointer: coarse)').matches && !keyboardUp())
@@ -1,15 +1,17 @@
1
1
  import { type ReactNode } from 'react';
2
2
  export type LayerValue = string | number;
3
+ export type LayerSearch = 'keep' | 'drop';
3
4
  type EntryState = Record<string, unknown>;
4
5
  export interface LayerHistoryProps {
5
6
  /** the CURRENT history entry's state, as a value of this render — the hook re-reads it on every back / forward / push
6
7
  * (react-router: `useLocation().state`) */
7
8
  state: unknown;
8
- /** a NEW entry for the same page with this state: the path kept, the query dropped (react-router:
9
- * `navigate({ pathname }, { state })`) */
10
- push: (state: EntryState) => void;
11
- /** the SAME entry, its state replaced: the path kept, the query dropped (`navigate({ pathname }, { replace: true, state })`) */
12
- replace: (state: EntryState) => void;
9
+ /** a NEW entry for the same page with this state. The path is kept. The query is kept unless the second argument is `'drop'` (react-router:
10
+ * `navigate({ pathname, search: search === 'drop' ? '' : location.search }, { state })`) */
11
+ push: (state: EntryState, search?: LayerSearch) => void;
12
+ /** the SAME entry, its state replaced. The path is kept. The query is kept unless the second argument is `'drop'`
13
+ * (`navigate({ pathname, search: search === 'drop' ? '' : location.search }, { replace: true, state })`) */
14
+ replace: (state: EntryState, search?: LayerSearch) => void;
13
15
  /** back by this many entries (`navigate(-entries)`) */
14
16
  back: (entries: number) => void;
15
17
  children?: ReactNode;
@@ -40,6 +42,8 @@ export interface LayerApi {
40
42
  * give what is inside a new `key` per opening.
41
43
  */
42
44
  export declare function useLayer(name: string): LayerApi;
45
+ /** useOptionalLayer — the same layer as `useLayer`, or `null` when no `LayerHistory` is above. A missing provider does not warn. */
46
+ export declare function useOptionalLayer(name: string): LayerApi | null;
43
47
  /**
44
48
  * useLayers — every layer over this screen: `up` (how many), and `closeAll(then)` — take them ALL off, then run `then`: before a
45
49
  * move that must leave nothing of this page's layers under it (signed out → replace with the sign-in page). The entries are
@@ -33,6 +33,26 @@ const useHistory = () => {
33
33
  warnOnce('layer-history', 'cardds useLayer: no <LayerHistory> above — hand the router over once, around the screens; until then a layer never comes up.');
34
34
  return h ?? NONE;
35
35
  };
36
+ const layerApi = (name, handle) => {
37
+ const { state, push, replace, back } = handle;
38
+ const { rest, layers } = read(state);
39
+ const layer = layers[name] ?? null;
40
+ const others = () => Object.fromEntries(Object.entries(layers).filter(([key]) => key !== name));
41
+ return {
42
+ value: layer?.value ?? null,
43
+ open: (to) => (layer ? replace({ ...rest, layers: { ...layers, [name]: { value: to, pushed: layer.pushed } } }, 'keep') : push({ ...rest, layers: { ...layers, [name]: { value: to, pushed: true } } }, 'keep')),
44
+ // already up on an entry the app pushed (the link opened where the layer was): it stays a pushed one, or the close would leave that entry behind
45
+ arrive: (as) => replace({ ...rest, layers: { ...layers, [name]: { value: as, pushed: layer?.pushed ?? false } } }, 'drop'),
46
+ close: () => {
47
+ if (!layer)
48
+ return;
49
+ if (layer.pushed)
50
+ back(1);
51
+ else
52
+ replace({ ...rest, layers: others() }, 'keep');
53
+ },
54
+ };
55
+ };
36
56
  /**
37
57
  * useLayer — ONE layer of this screen, by its name: a whole bottom sheet, a modal. Back takes it off; the screen stays.
38
58
  * `const way = useLayer('way')` → `<CallSheet open={way.value} onOpenChange={(up, i) => (up ? way.open(i!) : way.close())}>`.
@@ -40,26 +60,13 @@ const useHistory = () => {
40
60
  * give what is inside a new `key` per opening.
41
61
  */
42
62
  export function useLayer(name) {
43
- const { state, push, replace, back } = useHistory();
44
- return useMemo(() => {
45
- const { rest, layers } = read(state);
46
- const layer = layers[name] ?? null;
47
- const others = () => Object.fromEntries(Object.entries(layers).filter(([key]) => key !== name));
48
- return {
49
- value: layer?.value ?? null,
50
- open: (to) => (layer ? replace({ ...rest, layers: { ...layers, [name]: { value: to, pushed: layer.pushed } } }) : push({ ...rest, layers: { ...layers, [name]: { value: to, pushed: true } } })),
51
- // already up on an entry the app pushed (the link opened where the layer was): it stays a pushed one, or the close would leave that entry behind
52
- arrive: (as) => replace({ ...rest, layers: { ...layers, [name]: { value: as, pushed: layer?.pushed ?? false } } }),
53
- close: () => {
54
- if (!layer)
55
- return;
56
- if (layer.pushed)
57
- back(1);
58
- else
59
- replace({ ...rest, layers: others() });
60
- },
61
- };
62
- }, [state, push, replace, back, name]);
63
+ const handle = useHistory();
64
+ return useMemo(() => layerApi(name, handle), [handle, name]);
65
+ }
66
+ /** useOptionalLayer — the same layer as `useLayer`, or `null` when no `LayerHistory` is above. A missing provider does not warn. */
67
+ export function useOptionalLayer(name) {
68
+ const handle = useContext(Ctx);
69
+ return useMemo(() => (handle ? layerApi(name, handle) : null), [handle, name]);
63
70
  }
64
71
  /**
65
72
  * useLayers — every layer over this screen: `up` (how many), and `closeAll(then)` — take them ALL off, then run `then`: before a
@@ -21,6 +21,12 @@ export interface SheetProps extends Omit<ComponentProps<'article'>, 'onChange'>
21
21
  states?: string | readonly Resting[];
22
22
  /** the grab handle; `false` for a sheet with no handle, a string sets its accessible name */
23
23
  handle?: boolean | string;
24
+ /** the grab mark. `line` (default) or `dots`: both sit in the card's top padding, the same distance from the edge, so the title does not move */
25
+ mark?: 'line' | 'dots';
26
+ /** the card tone slot, as `Card tone` — 2 and up add `card--N`. The head shows it; with `paper` the content does not */
27
+ tone?: 1 | 2 | 3 | 4 | 5;
28
+ /** the content is white paper and the head keeps the sheet's colour. Omit it and the content is the same colour as the head */
29
+ paper?: boolean;
24
30
  }
25
31
  /**
26
32
  * Sheet — the SINGLE sheet: one card as a bottom sheet holding what the member ACTS on. Always the full height of its
@@ -43,4 +49,4 @@ export interface SheetProps extends Omit<ComponentProps<'article'>, 'onChange'>
43
49
  * crossing and never one continuous move, whatever the app's wiring (both states set in one render, or the next raised from the
44
50
  * first's `onSettled`) — the app adds no timer.
45
51
  */
46
- export declare function Sheet({ state, defaultState, onStateChange, onSettled, states, handle, className, children, onFocus, ...rest }: SheetProps): import("react").JSX.Element;
52
+ export declare function Sheet({ state, defaultState, onStateChange, onSettled, states, handle, mark, tone, paper, className, children, onFocus, ...rest }: SheetProps): import("react").JSX.Element;
@@ -1,12 +1,22 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useRef } from 'react';
2
+ import { Children, cloneElement, isValidElement, useCallback, useEffect, useRef, useState } from 'react';
3
3
  import { focusWhenActive, useControlled, watchSize } from '../state.js';
4
+ import { CardHead } from '../cards/CardHead.js';
5
+ import { SheetHead } from './SheetHead.js';
4
6
  import { cx } from '../cx.js';
5
7
  import { word } from '../words.js';
6
8
  import { ORDER, slid, stopsOf, useFootRoom, usePeekStrip, useTitleRun } from './sheetMeasure.js';
7
9
  import { useSheetSlide } from './sheetSlide.js';
8
10
  import { useSheetDrag } from './sheetDrag.js';
9
11
  const RANK = { away: 0, peek: 1, half: 2, '3q': 3, full: 4 };
12
+ /** the sheet's own head renders `.is-long` from the measure. A heading further down is not that title. */
13
+ function markLong(node, long) {
14
+ if (!long || !isValidElement(node))
15
+ return node;
16
+ if (node.type !== CardHead && node.type !== SheetHead)
17
+ return node;
18
+ return cloneElement(node, { long: true });
19
+ }
10
20
  /**
11
21
  * Sheet — the SINGLE sheet: one card as a bottom sheet holding what the member ACTS on. Always the full height of its
12
22
  * box — a Step or a SheetStage — a state is only how far it slid (translate; the material never changes size).
@@ -28,7 +38,7 @@ const RANK = { away: 0, peek: 1, half: 2, '3q': 3, full: 4 };
28
38
  * crossing and never one continuous move, whatever the app's wiring (both states set in one render, or the next raised from the
29
39
  * first's `onSettled`) — the app adds no timer.
30
40
  */
31
- export function Sheet({ state, defaultState = 'half', onStateChange, onSettled, states, handle = true, className, children, onFocus, ...rest }) {
41
+ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled, states, handle = true, mark = 'line', tone, paper, className, children, onFocus, ...rest }) {
32
42
  const [current, request] = useControlled(state, defaultState, onStateChange);
33
43
  const ref = useRef(null);
34
44
  const handleRef = useRef(null);
@@ -102,7 +112,8 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
102
112
  });
103
113
  }, [reveal]);
104
114
  usePeekStrip(ref);
105
- useTitleRun(ref);
115
+ const [titleLong, setTitleLong] = useState(false);
116
+ useTitleRun(ref, setTitleLong);
106
117
  useFootRoom(ref);
107
118
  /* the handle held and moved: the sheet follows, and asks for the nearest state on release — sheetDrag.ts */
108
119
  const { down, move, up, afterDrag } = useSheetDrag(ref, allowed, (to) => request(to, 'drag'));
@@ -120,5 +131,5 @@ export function Sheet({ state, defaultState = 'half', onStateChange, onSettled,
120
131
  return; // the click that follows a drag is not a tap
121
132
  request(current === 'peek' ? (allowed().includes(home.current) ? home.current : allowed().find((s) => s !== 'peek') ?? 'half') : 'peek', 'tap');
122
133
  };
123
- return (_jsxs("article", { ref: ref, className: cx('card sheet', `sheet--${shown}`, className), inert: current === 'away' || held || undefined, onFocus: focus, ...rest, children: [handle !== false && (_jsx("button", { ref: handleRef, className: "sheet__handle", type: "button", "aria-label": typeof handle === 'string' ? handle : word('resize'), onClick: tap, onPointerDown: down, onPointerMove: move, onPointerUp: up, onPointerCancel: up })), children] }));
134
+ return (_jsxs("article", { ref: ref, className: cx('card sheet', tone && tone !== 1 && `card--${tone}`, paper && 'sheet--paper', `sheet--${shown}`, className), inert: current === 'away' || held || undefined, onFocus: focus, ...rest, children: [handle !== false && (_jsx("button", { ref: handleRef, className: cx('sheet__handle', mark === 'dots' && 'sheet__handle--dots'), type: "button", "aria-label": typeof handle === 'string' ? handle : word('resize'), onClick: tap, onPointerDown: down, onPointerMove: move, onPointerUp: up, onPointerCancel: up })), Children.map(children, (node) => markLong(node, titleLong))] }));
124
135
  }
@@ -1,4 +1,4 @@
1
- import type { ComponentProps } from 'react';
1
+ import { type ComponentProps } from 'react';
2
2
  export interface SheetBodyProps extends ComponentProps<'div'> {
3
3
  }
4
4
  /** SheetBody — everything under a Sheet's head: the one thing that scrolls; its scroll padding keeps a focused control above the cut. Content never goes straight into the Sheet. */
@@ -1,6 +1,9 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useContext } from 'react';
2
3
  import { cx } from '../cx.js';
4
+ import { StackPullCtx } from './stackCard.js';
3
5
  /** SheetBody — everything under a Sheet's head: the one thing that scrolls; its scroll padding keeps a focused control above the cut. Content never goes straight into the Sheet. */
4
6
  export function SheetBody({ className, ...rest }) {
5
- return _jsx("div", { className: cx('sheet__body', className), ...rest });
7
+ const pulling = useContext(StackPullCtx);
8
+ return _jsx("div", { className: cx('sheet__body', pulling && 'is-refreshing', className), ...rest });
6
9
  }
@@ -8,6 +8,8 @@ export interface SheetHeadProps extends Omit<ComponentProps<'div'>, 'title'> {
8
8
  sub?: ReactNode;
9
9
  /** the round button on the right */
10
10
  trail?: ReactNode;
11
+ /** the sheet measured this title as wider than its line, so it runs sideways */
12
+ long?: boolean;
11
13
  }
12
14
  /** SheetHead — the stage sheet's three-column head: round button · centred title + sub · round button. A step sheet uses a CardHead instead. */
13
- export declare function SheetHead({ lead, title, sub, trail, className, ...rest }: SheetHeadProps): import("react").JSX.Element;
15
+ export declare function SheetHead({ lead, title, sub, trail, long, className, ...rest }: SheetHeadProps): import("react").JSX.Element;
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from '../cx.js';
3
3
  /** SheetHead — the stage sheet's three-column head: round button · centred title + sub · round button. A step sheet uses a CardHead instead. */
4
- export function SheetHead({ lead, title, sub, trail, className, ...rest }) {
5
- return (_jsxs("div", { className: cx('sheet__head', className), ...rest, children: [lead ?? _jsx("span", {}), _jsxs("div", { children: [_jsx("h2", { className: "sheet__title", children: title }), sub != null && _jsx("span", { className: "sheet__sub", children: sub })] }), trail ?? _jsx("span", {})] }));
4
+ export function SheetHead({ lead, title, sub, trail, long, className, ...rest }) {
5
+ return (_jsxs("div", { className: cx('sheet__head', className), ...rest, children: [lead ?? _jsx("span", {}), _jsxs("div", { children: [_jsx("h2", { className: cx('sheet__title', long && 'is-long'), children: title }), sub != null && _jsx("span", { className: "sheet__sub", children: sub })] }), trail ?? _jsx("span", {})] }));
6
6
  }
@@ -30,9 +30,8 @@ export interface SheetStackProps extends ComponentProps<'div'> {
30
30
  * with a picture over the menu); the `base` slot is rendered after the sheets, so it never takes a sheet's slot. This is the sheet STACK; a single bottom sheet is `Sheet`.
31
31
  *
32
32
  * A COMPONENT, not a wiring: WHICH sheet is open is the app's (`open` + `onOpenChange`, or `defaultOpen`) — so a button anywhere, a route or
33
- * the phone's back button can open and close a section. Its own behaviour stays inside and only asks: a tap on a peeked head, or a pull up on
34
- * that head (the title, the back button, or the head itself — not a tab), opens it. The open sheet's StackBack or its title, tapped or pulled
35
- * down, folds it. A pull down in the content, already at the top, asks `onRefresh` and does not fold; `refreshing` fades that sheet's four grip dots up and down while the app reloads. How the sheets MOVE is its own too: sheets are rigid — the tapped one slides up, the ones after it leave below, the ones
36
- * before tuck under nearest-first; closing reverses in two beats. Only `translate` (stack.css); it sets classes, never a size.
33
+ * the phone's back button can open and close a section. WHAT the sheet does with that, and with `refreshing`, lives on the card (`stack`):
34
+ * the place classes, the grip, the pull. The stack only says which index. Sheets are rigid — the open one slides up, the ones after it leave
35
+ * below, the ones before tuck under nearest-first; closing reverses in two beats. Only `translate` (stack.css); never a size.
37
36
  */
38
- export declare function SheetStack({ mode, open, defaultOpen, onOpenChange, onRefresh, refreshing, fanned, closed, base, className, children, onClick, onKeyDown, ...rest }: SheetStackProps): import("react").JSX.Element;
37
+ export declare function SheetStack({ mode, open, defaultOpen, onOpenChange, onRefresh, refreshing, fanned, closed, base, className, children, ...rest }: SheetStackProps): import("react").JSX.Element;