@usableapp/cardds 0.7.1 → 0.7.3
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/CHANGELOG.md +35 -0
- package/README.md +28 -0
- package/css/actions.css +4 -0
- package/css/layover.css +48 -18
- package/css/lists.css +3 -0
- package/css/media.css +2 -0
- package/css/stack.css +19 -1
- package/dist/actions/Chip.d.ts +3 -1
- package/dist/actions/Chip.js +2 -2
- package/dist/actions/Dropdown.d.ts +3 -1
- package/dist/actions/Dropdown.js +2 -2
- package/dist/cardds.css +76 -19
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/layover/Banner.d.ts +5 -6
- package/dist/layover/Banner.js +5 -3
- package/dist/lists/Row.d.ts +4 -2
- package/dist/lists/Row.js +6 -4
- package/dist/sheets/Drawer.d.ts +6 -2
- package/dist/sheets/Drawer.js +18 -2
- package/dist/sheets/StackSheet.js +21 -2
- package/dist/templates/Splash.d.ts +22 -0
- package/dist/templates/Splash.js +32 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
> For: a project that consumes `@usableapp/cardds` and its AI — what changed between versions, written as before → after, so a screen can be brought up to date without reading the source. Newest first. A consumer never edits cardds; if something here leaves you without a way to do what you did before, report the gap.
|
|
4
4
|
|
|
5
|
+
## 0.7.3 — 2026-09-24
|
|
6
|
+
|
|
7
|
+
### New
|
|
8
|
+
- `Modal onDismiss(reason)` — a tap on the scrim (anywhere outside the card) or Esc asks to leave unanswered
|
|
9
|
+
(`'scrim' | 'escape'`); the app sets `open={false}` and the modal leaves as usual. Without `onDismiss` the scrim does
|
|
10
|
+
nothing — a question that must be answered.
|
|
11
|
+
|
|
12
|
+
## 0.7.2 — 2026-09-24
|
|
13
|
+
|
|
14
|
+
### `Banner` is one light band (breaking)
|
|
15
|
+
- Before: `summary` was a `Chip on` (with its chevron Icon) that opened a `card--3` body under it; `tone` 2 | 3.
|
|
16
|
+
After: ONE band — a light tint (`--ink-tint`), a hairline above and below, edge to edge of its card or screen; the
|
|
17
|
+
summary line and the body share it. `summary` = words (the chevron is the Banner's own); `tone` is gone. Fix:
|
|
18
|
+
`<Banner summary={<Chip on>Save 2 h… <Icon …/></Chip>}>` → `<Banner summary="Save 2 h…">`. Knobs `--banner-bg`,
|
|
19
|
+
`--banner-border`, `--banner-summary-pad-y`, `--banner-summary-font`. A read-only note inside a sheet or card is a
|
|
20
|
+
`Banner`, not a `Card sm`.
|
|
21
|
+
|
|
22
|
+
### New
|
|
23
|
+
- `Splash` (template): the app's own mark (its Lottie player, an img — cardds plays nothing) + one `text` line, the group's
|
|
24
|
+
middle on `--screen-centre`; `done` (the app is ready) · `min` (ms at least) · `onLeft` (after the fade; unmount then);
|
|
25
|
+
reduced motion = no fade. Knobs `--splash-mark-w`, `--splash-mark-x` / `-y` (nudge a lottie's drawing onto the line),
|
|
26
|
+
`--splash-at`, `--splash-bg`, `--splash-text-ink`, `--splash-gap`.
|
|
27
|
+
- `Rows heading` — a list grouped (Today · This week), not spaced out. Knobs `--rows-head-ink`, `--rows-group-gap`.
|
|
28
|
+
- `Chip muted`, `Dropdown muted` — an untouched default reads quiet; drop `muted` once the member picks.
|
|
29
|
+
- README rules 11–14: a screen is scanned, not read — edges, show don't explain, quiet defaults, a frame says "tap me"
|
|
30
|
+
(inside a card only a control has an edge; a count is `Text caption muted`, never a `Chip`).
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- `Modal` with no `Drawer` stretched to the whole stage in Safari 18 (iOS 18.6): it is centred by `top: 50%` +
|
|
34
|
+
`translate: -50%` now.
|
|
35
|
+
- `Tile stat` has no frame (it is read, not pressed).
|
|
36
|
+
- `StackSheet` head: tabs 8px apart (`--stack-sheet-tabs-gap`), the title on the discs' middle
|
|
37
|
+
(`--stack-sheet-title-shift`), ONE line always — a title that does not fit runs sideways, one way, and starts again
|
|
38
|
+
(`--stack-sheet-run-time`); reduced motion = an ellipsis.
|
|
39
|
+
|
|
5
40
|
## 0.7.1 — 2026-09-24
|
|
6
41
|
|
|
7
42
|
### The bar fixed from outside is gone
|
package/README.md
CHANGED
|
@@ -497,6 +497,34 @@ Demo: the *skeleton* block at the top of `elements.html`.
|
|
|
497
497
|
`--on-accent` to its own ink and bg, so every accent consumer (buttons,
|
|
498
498
|
checks, toggles, rings, selected days) inverts without extra rules.
|
|
499
499
|
|
|
500
|
+
**A screen is scanned, not read** (Lh 2026-09-24) — a member arrives with one
|
|
501
|
+
question and hunts for its answer. Rules 11–13 serve that:
|
|
502
|
+
|
|
503
|
+
11. **Everything holds to edges.** Every element touches at least two edges —
|
|
504
|
+
the card's, the column's, or a line made by the thing above it (a row, the
|
|
505
|
+
avatar + name line). Words to READ are aligned start, values end (the
|
|
506
|
+
receipt: items left, prices right; `.card__head`, `.kv`, `.row`); centred
|
|
507
|
+
only what is not read — dots, a lone number, an icon, a mark. Empty room is
|
|
508
|
+
not filled with more content; a new edge is made instead (line the buttons
|
|
509
|
+
up with the avatar).
|
|
510
|
+
12. **Show, don't explain.** Where a known picture carries it — an avatar for a
|
|
511
|
+
person, an icon, a `Ring`/`Track`/`Band`, a `Chip` for a category — use it
|
|
512
|
+
instead of words. Never add a label, a caption line, a comparison or a
|
|
513
|
+
tooltip to explain what the picture already says (the retired overline was
|
|
514
|
+
that mistake). Long lists are differentiated, not spaced out: group them
|
|
515
|
+
(`Rows heading` per group), put the avatar where a person is named.
|
|
516
|
+
13. **Emphasis is the difference from the neighbours.** An untouched default
|
|
517
|
+
is quiet (grey, thin border, no fill — `Chip muted`, `Dropdown muted`);
|
|
518
|
+
only what the member changed, or the one thing asked of them, takes the
|
|
519
|
+
accent. To make one thing stand out, calm what surrounds it — never
|
|
520
|
+
bolder, never a second colour.
|
|
521
|
+
14. **A frame says "tap me".** Inside a card, only a control draws its own
|
|
522
|
+
edge (a chip, a field, a pick tile, a small `Card sm` to pick). What is
|
|
523
|
+
only READ never gets a frame — no card in a card: a stat tile stands on
|
|
524
|
+
the card's ground, a note is a `Banner` (a light band, a rule above and
|
|
525
|
+
below, edge to edge), a count is words (`Text caption muted`), never a
|
|
526
|
+
chip. A chip or tile that looks pressable and is not lies.
|
|
527
|
+
|
|
500
528
|
## A project's theme
|
|
501
529
|
|
|
502
530
|
A project retunes cardds from ONE file of its own, and nothing else. Copy
|
package/css/actions.css
CHANGED
|
@@ -158,6 +158,8 @@
|
|
|
158
158
|
letters' own middle, in em so it follows the text size (Lh 2026-09-22: "ตัวหนังสือกับไฟเสมอกัน") */
|
|
159
159
|
.dot--b { background: var(--dot-b); }
|
|
160
160
|
.chip--on { background: var(--chip-on-bg, var(--accent)); color: var(--chip-on-ink, var(--on-accent)); border-color: transparent; }
|
|
161
|
+
/* muted: an untouched default — the words wash out, the edge stays; emphasis is the difference from the neighbours (README 13) */
|
|
162
|
+
.chip--muted { color: var(--chip-muted-ink, var(--card-muted)); }
|
|
161
163
|
/* dark-slot inversion lives in card.css (.card--3 :is(...)) */
|
|
162
164
|
|
|
163
165
|
.chip-row { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
|
|
@@ -187,6 +189,8 @@
|
|
|
187
189
|
text-align: left;
|
|
188
190
|
cursor: pointer;
|
|
189
191
|
}
|
|
192
|
+
/* muted: the value is still the default (README 13) */
|
|
193
|
+
.dropdown--muted .dropdown__btn { color: var(--dropdown-muted-ink, var(--card-muted)); }
|
|
190
194
|
.dropdown__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
191
195
|
.dropdown__btn > .icon { transition: rotate 0.2s ease; }
|
|
192
196
|
.dropdown:has(.menu:popover-open) .dropdown__btn > .icon { rotate: 180deg; }
|
package/css/layover.css
CHANGED
|
@@ -31,12 +31,11 @@
|
|
|
31
31
|
Reduced motion: the finished state, no beats; closing fires at once. */
|
|
32
32
|
.modal {
|
|
33
33
|
--drawer-h: 14rem;
|
|
34
|
-
position: absolute; /* at the stage's centre, and kept there as it grows:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
share the rest. NOT `height: fit-content` — WebKit/Safari sizes it wrong (Lh 2026-09-24, iOS 18.6) */
|
|
34
|
+
position: absolute; /* at the stage's centre, and kept there as it grows: its middle is pinned at 50% and */
|
|
35
|
+
top: 50%; /* translate -50% of its OWN height re-centres it on every height. NOT inset 0 + margin auto + */
|
|
36
|
+
inset-inline: 0; /* align-self: center — Safari 18 ignores align-self on an absolute box in a block container, */
|
|
37
|
+
margin-inline: auto; /* so the card stretched to the whole stage (iOS 18.6, Lh 2026-09-24); nor height: fit-content */
|
|
38
|
+
translate: 0 -50%;
|
|
40
39
|
width: calc(100% - 2 * var(--screen-pad));
|
|
41
40
|
z-index: 2; /* over the dim stage's scrim (z 1) */
|
|
42
41
|
max-height: calc(100cqh - 2 * var(--sp-5)); /* cqh: the stage (a size container) */
|
|
@@ -80,8 +79,8 @@
|
|
|
80
79
|
.modal { animation: modal-drop calc(2 * var(--motion-sheet)) ease both; }
|
|
81
80
|
.modal__drawer { animation: modal-open calc(2 * var(--motion-sheet)) ease both; }
|
|
82
81
|
@keyframes modal-drop {
|
|
83
|
-
0% { translate: 0 -100cqh; } /* above the stage */
|
|
84
|
-
50%, 100% { translate: 0
|
|
82
|
+
0% { translate: 0 calc(-50% - 100cqh); } /* above the stage */
|
|
83
|
+
50%, 100% { translate: 0 -50%; } /* 1. at the centre — and it stays: beat 2 is the drawer's */
|
|
85
84
|
}
|
|
86
85
|
@keyframes modal-open {
|
|
87
86
|
0%, 50% { height: 0; padding-block: 0; } /* inside the card */
|
|
@@ -166,16 +165,29 @@
|
|
|
166
165
|
@starting-style { .menu:popover-open { opacity: 0; translate: 0 calc(-1 * var(--sp-2)); } }
|
|
167
166
|
}
|
|
168
167
|
|
|
169
|
-
/* ---- banner:
|
|
170
|
-
<details class="banner"><summary class="
|
|
171
|
-
<div class="
|
|
172
|
-
|
|
173
|
-
.banner
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
168
|
+
/* ---- banner: ONE band — the line that opens it and the body share one ground (Lh 2026-09-24: "ปุ่มกับเนื้อหาใช้พื้นที่เดียวกัน") ----
|
|
169
|
+
<details class="banner"><summary class="banner__summary">Save 2 h… <svg class="icon icon--xs"/></summary>
|
|
170
|
+
<div class="banner__body">…</div></details>
|
|
171
|
+
A light tint (not a card tone) with a slightly darker rule above and below, edge to edge of its card or screen (Lh 2026-09-24). */
|
|
172
|
+
.banner {
|
|
173
|
+
display: grid;
|
|
174
|
+
background: var(--banner-bg, var(--ink-tint));
|
|
175
|
+
color: var(--banner-ink, inherit);
|
|
176
|
+
border-block: var(--border-w) solid var(--banner-border, var(--hairline));
|
|
177
|
+
/* a band edge to edge of what holds it — the card's or the screen's inset; no radius, no side border: a band, not a card */
|
|
178
|
+
margin-inline: calc(-1 * var(--card-pad, var(--screen-pad)));
|
|
179
|
+
padding-inline: var(--card-pad, var(--screen-pad));
|
|
180
|
+
}
|
|
181
|
+
.banner__summary {
|
|
182
|
+
list-style: none; cursor: pointer;
|
|
183
|
+
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
|
|
184
|
+
padding-block: var(--banner-summary-pad-y, calc(var(--sp-base) * 1.5)); font: var(--banner-summary-font, var(--type-caption)); font-weight: 400;
|
|
185
|
+
}
|
|
186
|
+
.banner__summary::-webkit-details-marker { display: none; }
|
|
187
|
+
.banner__summary .icon { transition: rotate 0.2s ease; }
|
|
188
|
+
.banner[open] .banner__summary .icon { rotate: 180deg; }
|
|
189
|
+
.banner__body { display: grid; gap: var(--banner-body-gap, var(--sp-1)); padding-bottom: var(--sp-4); }
|
|
190
|
+
.banner::details-content { width: 100%; }
|
|
179
191
|
@supports (interpolate-size: allow-keywords) {
|
|
180
192
|
@media (prefers-reduced-motion: no-preference) {
|
|
181
193
|
.banner { interpolate-size: allow-keywords; }
|
|
@@ -289,3 +301,21 @@
|
|
|
289
301
|
.deck > .card:nth-last-child(n + 4) { display: none; }
|
|
290
302
|
.deck__actions { display: flex; justify-content: center; gap: var(--deck-actions-gap, var(--sp-8)); }
|
|
291
303
|
.deck__actions > .icon-btn { width: var(--bar-h); height: var(--bar-h); }
|
|
304
|
+
|
|
305
|
+
/* ---- splash: the app's mark at the human centre while it boots (Lh 2026-09-24) ----
|
|
306
|
+
<section class="screen screen--fill splash"><div class="splash__group"><div class="splash__mark">{the app's lottie/img}</div>
|
|
307
|
+
<p class="splash__text">…</p></div></section>
|
|
308
|
+
The group's MIDDLE is on --screen-centre (40% down), the same line a centred card uses. The mark is the app's own content —
|
|
309
|
+
a lottie box often carries empty room around its drawing — the app sets the knobs below, cardds does not guess it. */
|
|
310
|
+
.splash { position: relative; background: var(--splash-bg, var(--surface)); transition: opacity var(--motion-sheet) ease; }
|
|
311
|
+
.splash--leaving { opacity: 0; }
|
|
312
|
+
.splash__group {
|
|
313
|
+
position: absolute; inset-inline: var(--screen-pad); top: var(--splash-at, var(--screen-centre)); translate: 0 -50%;
|
|
314
|
+
display: grid; justify-items: center; gap: var(--splash-gap, var(--sp-4)); text-align: center;
|
|
315
|
+
}
|
|
316
|
+
.splash__mark { width: var(--splash-mark-w, 60%); max-width: 100%; translate: var(--splash-mark-x, 0) var(--splash-mark-y, 0); }
|
|
317
|
+
/* each app's lottie is its own size and carries its own empty room: --splash-mark-w sizes the box, --splash-mark-x / -y nudge the
|
|
318
|
+
drawing onto the centre line (the line under it does not move), --splash-at moves the whole group (default the 40% line) */
|
|
319
|
+
.splash__mark > * { display: block; width: 100%; height: auto; }
|
|
320
|
+
.splash__text { margin: 0; font: var(--type-body); color: var(--splash-text-ink, var(--on-surface-muted)); }
|
|
321
|
+
@media (prefers-reduced-motion: reduce) { .splash { transition: none; } }
|
package/css/lists.css
CHANGED
|
@@ -67,6 +67,9 @@
|
|
|
67
67
|
/* rows: a list of rows separated by hairlines */
|
|
68
68
|
.rows { display: grid; --rows-gap: var(--sp-4); row-gap: var(--rows-gap); }
|
|
69
69
|
.rows:has(> .row--lg) { --rows-gap: var(--sp-6); }
|
|
70
|
+
/* the group's name: a label on the rows' start edge; two groups one after another read apart by it, not by space */
|
|
71
|
+
.rows__head { font: var(--type-label); color: var(--rows-head-ink, var(--card-muted)); }
|
|
72
|
+
.rows + .rows { margin-top: var(--rows-group-gap, var(--sp-6)); }
|
|
70
73
|
/* the hairline between rows sits in the middle of the gap — drawn, not spaced:
|
|
71
74
|
no padding, no spacer element */
|
|
72
75
|
.rows > .row + .row { position: relative; }
|
package/css/media.css
CHANGED
|
@@ -90,6 +90,8 @@
|
|
|
90
90
|
.tile--pick:has(:checked) { background: var(--accent); color: var(--on-accent); border-color: transparent; }
|
|
91
91
|
.tile--pick:has(:focus-visible) { outline: var(--focus); outline-offset: var(--sp-1); }
|
|
92
92
|
.tile--wide { grid-column: 1 / -1; }
|
|
93
|
+
/* stat: READ, not pressed — no frame of its own inside a card (a frame says "tap me"): icon · value · words on the card's ground */
|
|
94
|
+
.tile--stat { border-color: transparent; background: none; padding-inline: 0; cursor: default; }
|
|
93
95
|
.tile--stat { min-height: var(--tile-stat-min-h, var(--avatar-xl)); gap: var(--tile-stat-gap, var(--sp-3)); grid-template-columns: auto 1fr; align-items: center; }
|
|
94
96
|
/* upload: dashed, centred plus + label */
|
|
95
97
|
.tile--upload {
|
package/css/stack.css
CHANGED
|
@@ -271,7 +271,25 @@
|
|
|
271
271
|
|
|
272
272
|
/* ---- StackSheet's tabs (Lh 2026-09-23): the head's end is a row of small icon buttons, the sheet's pages — the chosen one
|
|
273
273
|
filled (.icon-btn--invert); a count is a small circle over the icon's top right corner ---- */
|
|
274
|
-
.stack-sheet__tabs { display: flex; gap: var(--stack-sheet-tabs-gap,
|
|
274
|
+
.stack-sheet__tabs { display: flex; gap: var(--stack-sheet-tabs-gap, var(--sp-2)); flex: none; } /* room between the tabs: with none, the chosen tab's disc touched its neighbour's (Lh 2026-09-24) */
|
|
275
|
+
/* the head is ONE line, always (Lh 2026-09-24): the tabs keep their size, the TITLE gives — it never wraps, it runs sideways
|
|
276
|
+
(StackSheet measures --_over, the width it overflows by, and sets .is-long), one way: it waits, runs to the end, waits, and starts again from the first letter — like a song title */
|
|
277
|
+
.stack-sheet > .card__head { flex-wrap: nowrap; }
|
|
278
|
+
.stack-sheet__title {
|
|
279
|
+
flex: 1; min-width: 0; overflow-x: clip; overflow-y: visible; white-space: nowrap; overflow-wrap: normal; /* clip SIDEWAYS only:
|
|
280
|
+
hidden clipped the Thai tone marks above a line-height-1 line (เพื่อน lost its ไม้เอก — Lh 2026-09-24) */
|
|
281
|
+
translate: 0 var(--stack-sheet-title-shift, -0.1em); /* FC Pride's line box carries more room above its letters than below: the
|
|
282
|
+
letters sat ~0.1em under the middle of the back disc and the tabs — lifted onto it (Lh 2026-09-24) */
|
|
283
|
+
}
|
|
284
|
+
.stack-sheet__run { display: inline-block; }
|
|
285
|
+
.stack-sheet__title.is-long .stack-sheet__run {
|
|
286
|
+
animation: stack-sheet-run var(--stack-sheet-run-time, var(--_run, 6s)) ease-in-out infinite; /* ONE way: start → end, then back to the start at once (Lh 2026-09-24) */ /* --_run: from the overflow, 30px a second + the pauses */
|
|
287
|
+
}
|
|
288
|
+
@keyframes stack-sheet-run { 0%, 20% { translate: 0; } 85%, 100% { translate: calc(-1 * var(--_over)); } }
|
|
289
|
+
@media (prefers-reduced-motion: reduce) {
|
|
290
|
+
.stack-sheet__title.is-long .stack-sheet__run { animation: none; display: inline; } /* inline: an ellipsis cannot cut an inline-block */
|
|
291
|
+
.stack-sheet__title.is-long { text-overflow: ellipsis; }
|
|
292
|
+
}
|
|
275
293
|
.stack-sheet__tab { position: relative; }
|
|
276
294
|
.stack-sheet__tab > .stack-sheet__count { /* outranks .badge (numbers.css loads after this file) */
|
|
277
295
|
position: absolute;
|
package/dist/actions/Chip.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { type ComponentProps, type ReactNode } from 'react';
|
|
|
2
2
|
export interface ChipProps extends ComponentProps<'span'> {
|
|
3
3
|
/** filled accent: the selected / live state */
|
|
4
4
|
on?: boolean;
|
|
5
|
+
/** quiet: an untouched default value — muted words, the thin edge, nothing to find the eye (README rule 13) */
|
|
6
|
+
muted?: boolean;
|
|
5
7
|
/** a leading signal dot: `a` (the accent signal) or `b` (the secondary) */
|
|
6
8
|
dot?: 'a' | 'b';
|
|
7
9
|
/** the element: span (default), a link, or a button */
|
|
@@ -10,7 +12,7 @@ export interface ChipProps extends ComponentProps<'span'> {
|
|
|
10
12
|
children?: ReactNode;
|
|
11
13
|
}
|
|
12
14
|
/** Chip — a status or attribute tag (with an optional signal Dot), or a small tap target; `on` when it is the live one. For a choice use ChipPick (`sign` for the +/× interest picker). */
|
|
13
|
-
export declare function Chip({ on, dot, as, href, className, children, ...rest }: ChipProps): import("react").DetailedReactHTMLElement<{
|
|
15
|
+
export declare function Chip({ on, muted, dot, as, href, className, children, ...rest }: ChipProps): import("react").DetailedReactHTMLElement<{
|
|
14
16
|
ref?: import("react").Ref<HTMLSpanElement> | undefined;
|
|
15
17
|
key?: import("react").Key | null | undefined;
|
|
16
18
|
defaultChecked?: boolean | undefined;
|
package/dist/actions/Chip.js
CHANGED
|
@@ -3,9 +3,9 @@ import { createElement } from 'react';
|
|
|
3
3
|
import { cx } from '../cx.js';
|
|
4
4
|
import { Dot } from './Dot.js';
|
|
5
5
|
/** Chip — a status or attribute tag (with an optional signal Dot), or a small tap target; `on` when it is the live one. For a choice use ChipPick (`sign` for the +/× interest picker). */
|
|
6
|
-
export function Chip({ on, dot, as, href, className, children, ...rest }) {
|
|
6
|
+
export function Chip({ on, muted, dot, as, href, className, children, ...rest }) {
|
|
7
7
|
const el = as ?? (href ? 'a' : 'span');
|
|
8
|
-
return createElement(el, { href, type: el === 'button' ? 'button' : undefined, className: cx('chip', on && 'chip--on', className), ...rest }, dot && _jsx(Dot, { b: dot === 'b' }), children);
|
|
8
|
+
return createElement(el, { href, type: el === 'button' ? 'button' : undefined, className: cx('chip', on && 'chip--on', muted && 'chip--muted', className), ...rest }, dot && _jsx(Dot, { b: dot === 'b' }), children);
|
|
9
9
|
}
|
|
10
10
|
/** ChipPick — a chip that is a choice: full-width in a ChipGrid (9:00 · 9:30 · …), or `sign` on a ChipRow (+ off, × on — the
|
|
11
11
|
* interest picker); the picked one fills with accent. A native radio/checkbox inside the label carries the state. */
|
|
@@ -13,6 +13,8 @@ export interface DropdownProps extends Omit<ComponentProps<'div'>, 'onChange' |
|
|
|
13
13
|
label?: ReactNode;
|
|
14
14
|
/** right-aligned trigger: the panel grows leftwards (in TopBar actions) */
|
|
15
15
|
end?: boolean;
|
|
16
|
+
/** quiet: the value is still the default — muted words; drop it once the member picks (README rule 13) */
|
|
17
|
+
muted?: boolean;
|
|
16
18
|
/** MenuItems, each with a `value` */
|
|
17
19
|
children?: ReactNode;
|
|
18
20
|
}
|
|
@@ -22,7 +24,7 @@ export interface DropdownProps extends Omit<ComponentProps<'div'>, 'onChange' |
|
|
|
22
24
|
* input: `value` + `onChange`, or `defaultValue`. Children are MenuItems with a `value`; the chosen one is marked and names the pill.
|
|
23
25
|
* In a FilterRow dropdowns share the width.
|
|
24
26
|
*/
|
|
25
|
-
export declare function Dropdown({ id, value, defaultValue, onChange, label, end, className, children, ...rest }: DropdownProps): import("react").JSX.Element;
|
|
27
|
+
export declare function Dropdown({ id, value, defaultValue, onChange, label, end, muted, className, children, ...rest }: DropdownProps): import("react").JSX.Element;
|
|
26
28
|
export interface MenuProps extends Omit<ComponentProps<'div'>, 'popover' | 'onChange' | 'defaultValue'> {
|
|
27
29
|
/** render as a popover panel: give it an `id`, and ANY button opens it with the platform's own `popoverTarget={id}` — the Menu does
|
|
28
30
|
* not know which. Omit for an inline menu card */
|
package/dist/actions/Dropdown.js
CHANGED
|
@@ -19,10 +19,10 @@ function useValue(value, defaultValue, onChange) {
|
|
|
19
19
|
* input: `value` + `onChange`, or `defaultValue`. Children are MenuItems with a `value`; the chosen one is marked and names the pill.
|
|
20
20
|
* In a FilterRow dropdowns share the width.
|
|
21
21
|
*/
|
|
22
|
-
export function Dropdown({ id, value, defaultValue, onChange, label, end, className, children, ...rest }) {
|
|
22
|
+
export function Dropdown({ id, value, defaultValue, onChange, label, end, muted, className, children, ...rest }) {
|
|
23
23
|
const [current, pick] = useValue(value, defaultValue, onChange);
|
|
24
24
|
const chosen = Children.toArray(children).find((c) => isValidElement(c) && c.props.value === current);
|
|
25
|
-
return (_jsxs("div", { className: cx('dropdown', end && 'dropdown--end', className), "data-value": current, ...rest, children: [_jsxs("button", { type: "button", className: "dropdown__btn", popoverTarget: id, children: [_jsx("span", { className: "dropdown__label", children: label ?? (isValidElement(chosen) ? chosen.props.children : null) }), _jsx(Icon, { name: "chevron-down", size: "xs" })] }), _jsx(Menu, { popover: true, id: id, value: current, onChange: pick, children: children })] }));
|
|
25
|
+
return (_jsxs("div", { className: cx('dropdown', end && 'dropdown--end', muted && 'dropdown--muted', className), "data-value": current, ...rest, children: [_jsxs("button", { type: "button", className: "dropdown__btn", popoverTarget: id, children: [_jsx("span", { className: "dropdown__label", children: label ?? (isValidElement(chosen) ? chosen.props.children : null) }), _jsx(Icon, { name: "chevron-down", size: "xs" })] }), _jsx(Menu, { popover: true, id: id, value: current, onChange: pick, children: children })] }));
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
28
|
* Menu — a card of MenuItems: a popover, or inline rows of icon + label. A COMPONENT on its own — what opens a popover Menu is any
|
package/dist/cardds.css
CHANGED
|
@@ -1114,7 +1114,25 @@ button { font: inherit; cursor: pointer; }
|
|
|
1114
1114
|
|
|
1115
1115
|
/* ---- StackSheet's tabs (Lh 2026-09-23): the head's end is a row of small icon buttons, the sheet's pages — the chosen one
|
|
1116
1116
|
filled (.icon-btn--invert); a count is a small circle over the icon's top right corner ---- */
|
|
1117
|
-
.stack-sheet__tabs { display: flex; gap: var(--stack-sheet-tabs-gap,
|
|
1117
|
+
.stack-sheet__tabs { display: flex; gap: var(--stack-sheet-tabs-gap, var(--sp-2)); flex: none; } /* room between the tabs: with none, the chosen tab's disc touched its neighbour's (Lh 2026-09-24) */
|
|
1118
|
+
/* the head is ONE line, always (Lh 2026-09-24): the tabs keep their size, the TITLE gives — it never wraps, it runs sideways
|
|
1119
|
+
(StackSheet measures --_over, the width it overflows by, and sets .is-long), one way: it waits, runs to the end, waits, and starts again from the first letter — like a song title */
|
|
1120
|
+
.stack-sheet > .card__head { flex-wrap: nowrap; }
|
|
1121
|
+
.stack-sheet__title {
|
|
1122
|
+
flex: 1; min-width: 0; overflow-x: clip; overflow-y: visible; white-space: nowrap; overflow-wrap: normal; /* clip SIDEWAYS only:
|
|
1123
|
+
hidden clipped the Thai tone marks above a line-height-1 line (เพื่อน lost its ไม้เอก — Lh 2026-09-24) */
|
|
1124
|
+
translate: 0 var(--stack-sheet-title-shift, -0.1em); /* FC Pride's line box carries more room above its letters than below: the
|
|
1125
|
+
letters sat ~0.1em under the middle of the back disc and the tabs — lifted onto it (Lh 2026-09-24) */
|
|
1126
|
+
}
|
|
1127
|
+
.stack-sheet__run { display: inline-block; }
|
|
1128
|
+
.stack-sheet__title.is-long .stack-sheet__run {
|
|
1129
|
+
animation: stack-sheet-run var(--stack-sheet-run-time, var(--_run, 6s)) ease-in-out infinite; /* ONE way: start → end, then back to the start at once (Lh 2026-09-24) */ /* --_run: from the overflow, 30px a second + the pauses */
|
|
1130
|
+
}
|
|
1131
|
+
@keyframes stack-sheet-run { 0%, 20% { translate: 0; } 85%, 100% { translate: calc(-1 * var(--_over)); } }
|
|
1132
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1133
|
+
.stack-sheet__title.is-long .stack-sheet__run { animation: none; display: inline; } /* inline: an ellipsis cannot cut an inline-block */
|
|
1134
|
+
.stack-sheet__title.is-long { text-overflow: ellipsis; }
|
|
1135
|
+
}
|
|
1118
1136
|
.stack-sheet__tab { position: relative; }
|
|
1119
1137
|
.stack-sheet__tab > .stack-sheet__count { /* outranks .badge (numbers.css loads after this file) */
|
|
1120
1138
|
position: absolute;
|
|
@@ -1984,6 +2002,8 @@ button { font: inherit; cursor: pointer; }
|
|
|
1984
2002
|
letters' own middle, in em so it follows the text size (Lh 2026-09-22: "ตัวหนังสือกับไฟเสมอกัน") */
|
|
1985
2003
|
.dot--b { background: var(--dot-b); }
|
|
1986
2004
|
.chip--on { background: var(--chip-on-bg, var(--accent)); color: var(--chip-on-ink, var(--on-accent)); border-color: transparent; }
|
|
2005
|
+
/* muted: an untouched default — the words wash out, the edge stays; emphasis is the difference from the neighbours (README 13) */
|
|
2006
|
+
.chip--muted { color: var(--chip-muted-ink, var(--card-muted)); }
|
|
1987
2007
|
/* dark-slot inversion lives in card.css (.card--3 :is(...)) */
|
|
1988
2008
|
|
|
1989
2009
|
.chip-row { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
|
|
@@ -2013,6 +2033,8 @@ button { font: inherit; cursor: pointer; }
|
|
|
2013
2033
|
text-align: left;
|
|
2014
2034
|
cursor: pointer;
|
|
2015
2035
|
}
|
|
2036
|
+
/* muted: the value is still the default (README 13) */
|
|
2037
|
+
.dropdown--muted .dropdown__btn { color: var(--dropdown-muted-ink, var(--card-muted)); }
|
|
2016
2038
|
.dropdown__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2017
2039
|
.dropdown__btn > .icon { transition: rotate 0.2s ease; }
|
|
2018
2040
|
.dropdown:has(.menu:popover-open) .dropdown__btn > .icon { rotate: 180deg; }
|
|
@@ -2900,6 +2922,9 @@ button { font: inherit; cursor: pointer; }
|
|
|
2900
2922
|
/* rows: a list of rows separated by hairlines */
|
|
2901
2923
|
.rows { display: grid; --rows-gap: var(--sp-4); row-gap: var(--rows-gap); }
|
|
2902
2924
|
.rows:has(> .row--lg) { --rows-gap: var(--sp-6); }
|
|
2925
|
+
/* the group's name: a label on the rows' start edge; two groups one after another read apart by it, not by space */
|
|
2926
|
+
.rows__head { font: var(--type-label); color: var(--rows-head-ink, var(--card-muted)); }
|
|
2927
|
+
.rows + .rows { margin-top: var(--rows-group-gap, var(--sp-6)); }
|
|
2903
2928
|
/* the hairline between rows sits in the middle of the gap — drawn, not spaced:
|
|
2904
2929
|
no padding, no spacer element */
|
|
2905
2930
|
.rows > .row + .row { position: relative; }
|
|
@@ -3131,6 +3156,8 @@ button { font: inherit; cursor: pointer; }
|
|
|
3131
3156
|
.tile--pick:has(:checked) { background: var(--accent); color: var(--on-accent); border-color: transparent; }
|
|
3132
3157
|
.tile--pick:has(:focus-visible) { outline: var(--focus); outline-offset: var(--sp-1); }
|
|
3133
3158
|
.tile--wide { grid-column: 1 / -1; }
|
|
3159
|
+
/* stat: READ, not pressed — no frame of its own inside a card (a frame says "tap me"): icon · value · words on the card's ground */
|
|
3160
|
+
.tile--stat { border-color: transparent; background: none; padding-inline: 0; cursor: default; }
|
|
3134
3161
|
.tile--stat { min-height: var(--tile-stat-min-h, var(--avatar-xl)); gap: var(--tile-stat-gap, var(--sp-3)); grid-template-columns: auto 1fr; align-items: center; }
|
|
3135
3162
|
/* upload: dashed, centred plus + label */
|
|
3136
3163
|
.tile--upload {
|
|
@@ -3359,12 +3386,11 @@ button { font: inherit; cursor: pointer; }
|
|
|
3359
3386
|
Reduced motion: the finished state, no beats; closing fires at once. */
|
|
3360
3387
|
.modal {
|
|
3361
3388
|
--drawer-h: 14rem;
|
|
3362
|
-
position: absolute; /* at the stage's centre, and kept there as it grows:
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
share the rest. NOT `height: fit-content` — WebKit/Safari sizes it wrong (Lh 2026-09-24, iOS 18.6) */
|
|
3389
|
+
position: absolute; /* at the stage's centre, and kept there as it grows: its middle is pinned at 50% and */
|
|
3390
|
+
top: 50%; /* translate -50% of its OWN height re-centres it on every height. NOT inset 0 + margin auto + */
|
|
3391
|
+
inset-inline: 0; /* align-self: center — Safari 18 ignores align-self on an absolute box in a block container, */
|
|
3392
|
+
margin-inline: auto; /* so the card stretched to the whole stage (iOS 18.6, Lh 2026-09-24); nor height: fit-content */
|
|
3393
|
+
translate: 0 -50%;
|
|
3368
3394
|
width: calc(100% - 2 * var(--screen-pad));
|
|
3369
3395
|
z-index: 2; /* over the dim stage's scrim (z 1) */
|
|
3370
3396
|
max-height: calc(100cqh - 2 * var(--sp-5)); /* cqh: the stage (a size container) */
|
|
@@ -3408,8 +3434,8 @@ button { font: inherit; cursor: pointer; }
|
|
|
3408
3434
|
.modal { animation: modal-drop calc(2 * var(--motion-sheet)) ease both; }
|
|
3409
3435
|
.modal__drawer { animation: modal-open calc(2 * var(--motion-sheet)) ease both; }
|
|
3410
3436
|
@keyframes modal-drop {
|
|
3411
|
-
0% { translate: 0 -100cqh; } /* above the stage */
|
|
3412
|
-
50%, 100% { translate: 0
|
|
3437
|
+
0% { translate: 0 calc(-50% - 100cqh); } /* above the stage */
|
|
3438
|
+
50%, 100% { translate: 0 -50%; } /* 1. at the centre — and it stays: beat 2 is the drawer's */
|
|
3413
3439
|
}
|
|
3414
3440
|
@keyframes modal-open {
|
|
3415
3441
|
0%, 50% { height: 0; padding-block: 0; } /* inside the card */
|
|
@@ -3494,16 +3520,29 @@ button { font: inherit; cursor: pointer; }
|
|
|
3494
3520
|
@starting-style { .menu:popover-open { opacity: 0; translate: 0 calc(-1 * var(--sp-2)); } }
|
|
3495
3521
|
}
|
|
3496
3522
|
|
|
3497
|
-
/* ---- banner:
|
|
3498
|
-
<details class="banner"><summary class="
|
|
3499
|
-
<div class="
|
|
3500
|
-
|
|
3501
|
-
.banner
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3523
|
+
/* ---- banner: ONE band — the line that opens it and the body share one ground (Lh 2026-09-24: "ปุ่มกับเนื้อหาใช้พื้นที่เดียวกัน") ----
|
|
3524
|
+
<details class="banner"><summary class="banner__summary">Save 2 h… <svg class="icon icon--xs"/></summary>
|
|
3525
|
+
<div class="banner__body">…</div></details>
|
|
3526
|
+
A light tint (not a card tone) with a slightly darker rule above and below, edge to edge of its card or screen (Lh 2026-09-24). */
|
|
3527
|
+
.banner {
|
|
3528
|
+
display: grid;
|
|
3529
|
+
background: var(--banner-bg, var(--ink-tint));
|
|
3530
|
+
color: var(--banner-ink, inherit);
|
|
3531
|
+
border-block: var(--border-w) solid var(--banner-border, var(--hairline));
|
|
3532
|
+
/* a band edge to edge of what holds it — the card's or the screen's inset; no radius, no side border: a band, not a card */
|
|
3533
|
+
margin-inline: calc(-1 * var(--card-pad, var(--screen-pad)));
|
|
3534
|
+
padding-inline: var(--card-pad, var(--screen-pad));
|
|
3535
|
+
}
|
|
3536
|
+
.banner__summary {
|
|
3537
|
+
list-style: none; cursor: pointer;
|
|
3538
|
+
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
|
|
3539
|
+
padding-block: var(--banner-summary-pad-y, calc(var(--sp-base) * 1.5)); font: var(--banner-summary-font, var(--type-caption)); font-weight: 400;
|
|
3540
|
+
}
|
|
3541
|
+
.banner__summary::-webkit-details-marker { display: none; }
|
|
3542
|
+
.banner__summary .icon { transition: rotate 0.2s ease; }
|
|
3543
|
+
.banner[open] .banner__summary .icon { rotate: 180deg; }
|
|
3544
|
+
.banner__body { display: grid; gap: var(--banner-body-gap, var(--sp-1)); padding-bottom: var(--sp-4); }
|
|
3545
|
+
.banner::details-content { width: 100%; }
|
|
3507
3546
|
@supports (interpolate-size: allow-keywords) {
|
|
3508
3547
|
@media (prefers-reduced-motion: no-preference) {
|
|
3509
3548
|
.banner { interpolate-size: allow-keywords; }
|
|
@@ -3618,3 +3657,21 @@ button { font: inherit; cursor: pointer; }
|
|
|
3618
3657
|
.deck__actions { display: flex; justify-content: center; gap: var(--deck-actions-gap, var(--sp-8)); }
|
|
3619
3658
|
.deck__actions > .icon-btn { width: var(--bar-h); height: var(--bar-h); }
|
|
3620
3659
|
|
|
3660
|
+
/* ---- splash: the app's mark at the human centre while it boots (Lh 2026-09-24) ----
|
|
3661
|
+
<section class="screen screen--fill splash"><div class="splash__group"><div class="splash__mark">{the app's lottie/img}</div>
|
|
3662
|
+
<p class="splash__text">…</p></div></section>
|
|
3663
|
+
The group's MIDDLE is on --screen-centre (40% down), the same line a centred card uses. The mark is the app's own content —
|
|
3664
|
+
a lottie box often carries empty room around its drawing — the app sets the knobs below, cardds does not guess it. */
|
|
3665
|
+
.splash { position: relative; background: var(--splash-bg, var(--surface)); transition: opacity var(--motion-sheet) ease; }
|
|
3666
|
+
.splash--leaving { opacity: 0; }
|
|
3667
|
+
.splash__group {
|
|
3668
|
+
position: absolute; inset-inline: var(--screen-pad); top: var(--splash-at, var(--screen-centre)); translate: 0 -50%;
|
|
3669
|
+
display: grid; justify-items: center; gap: var(--splash-gap, var(--sp-4)); text-align: center;
|
|
3670
|
+
}
|
|
3671
|
+
.splash__mark { width: var(--splash-mark-w, 60%); max-width: 100%; translate: var(--splash-mark-x, 0) var(--splash-mark-y, 0); }
|
|
3672
|
+
/* each app's lottie is its own size and carries its own empty room: --splash-mark-w sizes the box, --splash-mark-x / -y nudge the
|
|
3673
|
+
drawing onto the centre line (the line under it does not move), --splash-at moves the whole group (default the 40% line) */
|
|
3674
|
+
.splash__mark > * { display: block; width: 100%; height: auto; }
|
|
3675
|
+
.splash__text { margin: 0; font: var(--type-body); color: var(--splash-text-ink, var(--on-surface-muted)); }
|
|
3676
|
+
@media (prefers-reduced-motion: reduce) { .splash { transition: none; } }
|
|
3677
|
+
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/layover/Banner.d.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import type { ComponentProps, ReactNode } from 'react';
|
|
2
|
-
export interface BannerProps extends ComponentProps<'details'> {
|
|
3
|
-
/** the
|
|
2
|
+
export interface BannerProps extends Omit<ComponentProps<'details'>, 'summary'> {
|
|
3
|
+
/** the line that opens it — words (and an inline Icon); the chevron is the Banner's own */
|
|
4
4
|
summary: ReactNode;
|
|
5
|
-
/** the tone of the opened card */
|
|
6
|
-
tone?: 1 | 2 | 3;
|
|
7
5
|
}
|
|
8
|
-
/** Banner —
|
|
9
|
-
|
|
6
|
+
/** Banner — ONE band: the line that opens it and what it opens share the same ground, so they read as one piece (Lh 2026-09-24).
|
|
7
|
+
* A light tint with a darker rule above and below, edge to edge of its card or screen — a band, never a card in a card. A native details, no JS. */
|
|
8
|
+
export declare function Banner({ summary, className, children, ...rest }: BannerProps): import("react").JSX.Element;
|
package/dist/layover/Banner.js
CHANGED
|
@@ -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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import { Icon } from '../type/Icon.js';
|
|
4
|
+
/** Banner — ONE band: the line that opens it and what it opens share the same ground, so they read as one piece (Lh 2026-09-24).
|
|
5
|
+
* A light tint with a darker rule above and below, edge to edge of its card or screen — a band, never a card in a card. A native details, no JS. */
|
|
6
|
+
export function Banner({ summary, className, children, ...rest }) {
|
|
7
|
+
return (_jsxs("details", { className: cx('banner', className), ...rest, children: [_jsxs("summary", { className: "banner__summary", children: [summary, _jsx(Icon, { name: "chevron-down", size: "xs" })] }), _jsx("div", { className: "banner__body", children: children })] }));
|
|
6
8
|
}
|
package/dist/lists/Row.d.ts
CHANGED
|
@@ -41,6 +41,8 @@ export interface RowProps extends Omit<ComponentProps<'div'>, 'title'> {
|
|
|
41
41
|
*/
|
|
42
42
|
export declare function Row({ lead, leadIcon, title, meta, body, end, endValue, endCaption, lg, done, as, href, more, open, defaultOpen, onOpenChange, className, children, ...rest }: RowProps): import("react").JSX.Element;
|
|
43
43
|
export interface RowsProps extends ComponentProps<'div'> {
|
|
44
|
+
/** the group's name, above its rows (Today · Tomorrow · Later): a long list is GROUPED, not spaced out — several Rows one after another */
|
|
45
|
+
heading?: ReactNode;
|
|
44
46
|
}
|
|
45
|
-
/** Rows — a list of Rows separated by hairlines. */
|
|
46
|
-
export declare function Rows({ className, ...rest }: RowsProps): import("react").JSX.Element;
|
|
47
|
+
/** Rows — a list of Rows separated by hairlines; `heading` names the group (a `role="group"` labelled by it). */
|
|
48
|
+
export declare function Rows({ heading, className, children, ...rest }: RowsProps): import("react").JSX.Element;
|
package/dist/lists/Row.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { createElement } from 'react';
|
|
2
|
+
import { createElement, useId } from 'react';
|
|
3
3
|
import { cx } from '../cx.js';
|
|
4
4
|
import { Icon } from '../type/Icon.js';
|
|
5
5
|
/**
|
|
@@ -18,7 +18,9 @@ export function Row({ lead, leadIcon, title, meta, body, end, endValue, endCapti
|
|
|
18
18
|
e.currentTarget.open = open; if (is !== (open ?? !is))
|
|
19
19
|
onOpenChange?.(is); }, children: [row, _jsx("div", { className: "row-expand__more", children: more })] }));
|
|
20
20
|
}
|
|
21
|
-
/** Rows — a list of Rows separated by hairlines. */
|
|
22
|
-
export function Rows({ className, ...rest }) {
|
|
23
|
-
|
|
21
|
+
/** Rows — a list of Rows separated by hairlines; `heading` names the group (a `role="group"` labelled by it). */
|
|
22
|
+
export function Rows({ heading, className, children, ...rest }) {
|
|
23
|
+
const id = useId();
|
|
24
|
+
const named = heading != null;
|
|
25
|
+
return (_jsxs("div", { className: cx('rows', className), role: named ? 'group' : undefined, "aria-labelledby": named ? id : undefined, ...rest, children: [named && _jsx("span", { className: "rows__head", id: id, children: heading }), children] }));
|
|
24
26
|
}
|
package/dist/sheets/Drawer.d.ts
CHANGED
|
@@ -5,15 +5,19 @@ export interface ModalProps extends ComponentProps<'article'> {
|
|
|
5
5
|
open?: boolean;
|
|
6
6
|
/** the leaving has finished (at once, when motion is reduced) — unmount here */
|
|
7
7
|
onClosed?: () => void;
|
|
8
|
+
/** the member asked to leave WITHOUT answering: a tap on the scrim (anywhere outside the card) or Esc. The modal only asks — the app
|
|
9
|
+
* sets `open={false}`. Without it the scrim does nothing (a question that must be answered). */
|
|
10
|
+
onDismiss?: (reason: 'scrim' | 'escape') => void;
|
|
8
11
|
}
|
|
9
12
|
/**
|
|
10
13
|
* Modal — the ask: ONE card at the centre of a `SheetStage dim`, two parts — a `Lift` (the thing being asked about, light, scrolls
|
|
11
14
|
* 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
15
|
* out below while the lift moves up.
|
|
13
16
|
* 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.
|
|
17
|
+
* records the answer and sets `open={false}`; the modal's one behaviour is how it leaves. `onDismiss` asks to leave unanswered (a tap on
|
|
18
|
+
* the scrim, Esc).
|
|
15
19
|
*/
|
|
16
|
-
export declare function Modal({ open, onClosed, className, ...rest }: ModalProps): import("react").JSX.Element;
|
|
20
|
+
export declare function Modal({ open, onClosed, onDismiss, className, ...rest }: ModalProps): import("react").JSX.Element;
|
|
17
21
|
export interface LiftProps extends ComponentProps<'div'> {
|
|
18
22
|
}
|
|
19
23
|
/** 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. */
|
package/dist/sheets/Drawer.js
CHANGED
|
@@ -6,9 +6,10 @@ import { cx } from '../cx.js';
|
|
|
6
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
7
|
* out below while the lift moves up.
|
|
8
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.
|
|
9
|
+
* records the answer and sets `open={false}`; the modal's one behaviour is how it leaves. `onDismiss` asks to leave unanswered (a tap on
|
|
10
|
+
* the scrim, Esc).
|
|
10
11
|
*/
|
|
11
|
-
export function Modal({ open = true, onClosed, className, ...rest }) {
|
|
12
|
+
export function Modal({ open = true, onClosed, onDismiss, className, ...rest }) {
|
|
12
13
|
const ref = useRef(null);
|
|
13
14
|
const closed = useRef(onClosed);
|
|
14
15
|
closed.current = onClosed;
|
|
@@ -27,6 +28,21 @@ export function Modal({ open = true, onClosed, className, ...rest }) {
|
|
|
27
28
|
closed.current?.(); });
|
|
28
29
|
return () => { live = false; };
|
|
29
30
|
}, [open]);
|
|
31
|
+
const dismiss = useRef(onDismiss);
|
|
32
|
+
dismiss.current = onDismiss;
|
|
33
|
+
const has = onDismiss !== undefined;
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const el = ref.current, stage = el?.parentElement;
|
|
36
|
+
if (!el || !stage || !has || !open)
|
|
37
|
+
return;
|
|
38
|
+
const tap = (e) => { if (!el.contains(e.target))
|
|
39
|
+
dismiss.current?.('scrim'); }; // the scrim is the stage's own ::after
|
|
40
|
+
const key = (e) => { if (e.key === 'Escape' && !e.defaultPrevented)
|
|
41
|
+
dismiss.current?.('escape'); };
|
|
42
|
+
stage.addEventListener('click', tap);
|
|
43
|
+
document.addEventListener('keydown', key);
|
|
44
|
+
return () => { stage.removeEventListener('click', tap); document.removeEventListener('keydown', key); };
|
|
45
|
+
}, [has, open]);
|
|
30
46
|
return _jsx("article", { ref: ref, className: cx('card modal', className), ...rest });
|
|
31
47
|
}
|
|
32
48
|
/** 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. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState } from 'react';
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { cx } from '../cx.js';
|
|
4
4
|
import { Card } from '../cards/Card.js';
|
|
5
5
|
import { CardHead } from '../cards/CardHead.js';
|
|
@@ -20,5 +20,24 @@ export function StackSheet({ title, tone, icon, tabs, tab: tabProp, defaultTab,
|
|
|
20
20
|
const tab = tabProp ?? own;
|
|
21
21
|
const pick = (id) => { if (tabProp === undefined)
|
|
22
22
|
setOwn(id); onTabChange?.(id); };
|
|
23
|
-
|
|
23
|
+
// a title longer than its room does not wrap: it runs sideways, like a song title in a short slot — the room it overflows by
|
|
24
|
+
// is measured here (the tabs may grow with the text size) and handed to the CSS as --_over
|
|
25
|
+
const titleRef = useRef(null);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const h = titleRef.current, run = h?.firstElementChild;
|
|
28
|
+
if (!h || !run || typeof ResizeObserver === 'undefined')
|
|
29
|
+
return;
|
|
30
|
+
const measure = () => {
|
|
31
|
+
const over = Math.max(0, Math.ceil(run.scrollWidth - h.clientWidth));
|
|
32
|
+
h.style.setProperty('--_over', `${over}px`);
|
|
33
|
+
h.style.setProperty('--_run', `${(over / 30 + 3).toFixed(1)}s`);
|
|
34
|
+
h.classList.toggle('is-long', over > 0);
|
|
35
|
+
};
|
|
36
|
+
const ro = new ResizeObserver(measure);
|
|
37
|
+
ro.observe(h);
|
|
38
|
+
ro.observe(run);
|
|
39
|
+
measure();
|
|
40
|
+
return () => ro.disconnect();
|
|
41
|
+
}, [title]);
|
|
42
|
+
return (_jsxs(Card, { tone: tone, className: cx('stack-sheet', className), ...rest, children: [_jsxs(CardHead, { children: [_jsx(StackBack, { label: backLabel }), _jsx(Text, { level: "h2", ref: titleRef, className: "stack-sheet__title", children: _jsx("span", { className: "stack-sheet__run", children: title }) }), tabs?.length ? (_jsx("span", { className: "stack-sheet__tabs", role: "tablist", children: tabs.map((t) => (_jsxs("button", { type: "button", role: "tab", "aria-selected": t.id === tab, "aria-label": t.label, className: cx('icon-btn icon-btn--sm stack-sheet__tab', t.id === tab && 'icon-btn--invert'), onClick: () => pick(t.id), children: [_jsx(Icon, { name: t.icon }), t.count ? _jsx(Badge, { className: "stack-sheet__count", children: t.count > 99 ? '99+' : t.count }) : null] }, t.id))) })) : icon] }), _jsx(SheetBody, { role: tabs?.length ? 'tabpanel' : undefined, children: children }), foot != null && _jsx(CardFoot, { children: foot })] }));
|
|
24
43
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type ComponentProps, type ReactNode } from 'react';
|
|
2
|
+
import { Screen } from '../scaffold/Screen.js';
|
|
3
|
+
export interface SplashProps extends Omit<ComponentProps<typeof Screen>, 'children' | 'fill' | 'top' | 'bottom'> {
|
|
4
|
+
/** the app's mark — ITS OWN content: a Lottie player, an <img>, an SVG. cardds places it; it never plays anything itself */
|
|
5
|
+
mark: ReactNode;
|
|
6
|
+
/** one line under the mark (optional) */
|
|
7
|
+
text?: ReactNode;
|
|
8
|
+
/** the app is ready: the splash fades out (after `min`), then calls `onLeft` */
|
|
9
|
+
done?: boolean;
|
|
10
|
+
/** the least time it stays up, in ms, counted from when it appeared (default 0) — so a fast boot does not flash it */
|
|
11
|
+
min?: number;
|
|
12
|
+
/** it has faded out: the app unmounts it now */
|
|
13
|
+
onLeft?: () => void;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Splash — the screen an app shows while it boots: the app's mark (and a line) at the human centre, `--screen-centre` (40%) down,
|
|
17
|
+
* nothing else. A TEMPLATE: cardds does the place, the line, the timing and the fade; the mark is the app's (a Lottie it loads and
|
|
18
|
+
* plays, or a still) — cardds has no animation library. The app says when it is ready (`done`); `min` keeps a fast boot from
|
|
19
|
+
* flashing it. Each lottie is its own size: knobs `--splash-mark-w` (the box's width), `--splash-mark-x` / `--splash-mark-y` (nudge the
|
|
20
|
+
* drawing onto the line), `--splash-at` (the group's line, default --screen-centre) — in the app's theme.css or `style`. `onLeft` fires after the fade (at once under prefers-reduced-motion — the app should give its mark a still frame there too).
|
|
21
|
+
*/
|
|
22
|
+
export declare function Splash({ mark, text, done, min, onLeft, className, ...rest }: SplashProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Screen } from '../scaffold/Screen.js';
|
|
4
|
+
import { cx } from '../cx.js';
|
|
5
|
+
/**
|
|
6
|
+
* Splash — the screen an app shows while it boots: the app's mark (and a line) at the human centre, `--screen-centre` (40%) down,
|
|
7
|
+
* nothing else. A TEMPLATE: cardds does the place, the line, the timing and the fade; the mark is the app's (a Lottie it loads and
|
|
8
|
+
* plays, or a still) — cardds has no animation library. The app says when it is ready (`done`); `min` keeps a fast boot from
|
|
9
|
+
* flashing it. Each lottie is its own size: knobs `--splash-mark-w` (the box's width), `--splash-mark-x` / `--splash-mark-y` (nudge the
|
|
10
|
+
* drawing onto the line), `--splash-at` (the group's line, default --screen-centre) — in the app's theme.css or `style`. `onLeft` fires after the fade (at once under prefers-reduced-motion — the app should give its mark a still frame there too).
|
|
11
|
+
*/
|
|
12
|
+
export function Splash({ mark, text, done, min = 0, onLeft, className, ...rest }) {
|
|
13
|
+
const born = useRef(Date.now());
|
|
14
|
+
const [leaving, setLeaving] = useState(false);
|
|
15
|
+
const left = useRef(onLeft);
|
|
16
|
+
left.current = onLeft;
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!done)
|
|
19
|
+
return;
|
|
20
|
+
const t = setTimeout(() => setLeaving(true), Math.max(0, min - (Date.now() - born.current)));
|
|
21
|
+
return () => clearTimeout(t);
|
|
22
|
+
}, [done, min]);
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!leaving)
|
|
25
|
+
return;
|
|
26
|
+
const still = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
27
|
+
const ms = still ? 0 : parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--motion-sheet')) * 1000 || 350;
|
|
28
|
+
const t = setTimeout(() => left.current?.(), ms);
|
|
29
|
+
return () => clearTimeout(t);
|
|
30
|
+
}, [leaving]);
|
|
31
|
+
return (_jsx(Screen, { fill: true, className: cx('splash', leaving && 'splash--leaving', className), role: "status", "aria-busy": !leaving, ...rest, children: _jsxs("div", { className: "splash__group", children: [_jsx("div", { className: "splash__mark", children: mark }), text != null && _jsx("p", { className: "splash__text", children: text })] }) }));
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usableapp/cardds",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"license": "UNLICENSED",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "card-first mobile design system, React-first: the components in src/ are thin wrappers over the CSS contract (css/*.css stays the only truth); gallery/ shows every story live (npm run dev), tests/ measures the geometry.",
|