@usableapp/cardds 0.7.22 → 0.7.24
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 +17 -0
- package/README.md +13 -0
- package/bin/cardds.mjs +72 -0
- package/css/actions.css +18 -0
- package/dist/actions/IconBtnGroup.d.ts +19 -0
- package/dist/actions/IconBtnGroup.js +34 -0
- package/dist/cardds.css +18 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/sheets/Sheet.d.ts +8 -1
- package/dist/sheets/Sheet.js +53 -4
- package/lint/eslint.mjs +28 -0
- package/lint/rules.mjs +111 -0
- package/package.json +17 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
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.24 — 2026-09-29
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- `Sheet` ARRIVES and LEAVES by sliding, always (Lh 2026-09-29). A sheet that mounts already up starts `away` for one frame and slides in — so every sheet on a screen now slides in when the screen opens, and `{open && <Sheet state="full">}` slides in (before: it appeared at once).
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **One way in** (Lh 2026-09-29): `npx cardds init` (writes `theme.css` from the template, adds `npm run cardds:check`) and `npx cardds check [dir]` — the rules of how a project uses cardds: a cardds class written by hand = error (the look without the behaviour — import the component); `<Sheet state>` without `onStateChange`, or a `Sheet` on a condition without `onSettled` = warning; a bar after `</Screen>` / pinned by hand = error. The same rules for ESLint 9: `import cardds from '@usableapp/cardds/eslint'`, add `cardds.configs.recommended` (`cardds/check` errors · `cardds/advice` warnings). Skip one line on purpose: `// cardds-ignore-next-line`.
|
|
12
|
+
- `Sheet onSettled(state)`: the sheet finished sliding. To take a sheet away: set `state="away"`, unmount it in `onSettled` when the state is `away` — it slides out. Unmounted while still up, it can only vanish: a dev warning says so. Gallery: `Sheet · MountedOnDemand` (the right way), `Sheet · UnmountedWhileUp` (the warning).
|
|
13
|
+
|
|
14
|
+
## 0.7.23 — 2026-09-29
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- `IconBtnGroup` (Lh 2026-09-29): up to FOUR icon-only `IconBtn`s in ONE frame. They share its width, with a rule between them in the frame's colour. At home in a `CardFoot` (card or sheet), where it takes the row. More than 4 → a dev warning.
|
|
18
|
+
- Plain: commands, each on its own `onClick`. Pressing dims the button; it never shrinks.
|
|
19
|
+
- `select="one"`: one on at a time. `select="many"`: each on or off by itself. A pressed button STAYS filled (`aria-pressed`). Each child names itself with `value`. The group takes `value` + `onChange`, or `defaultValue`.
|
|
20
|
+
- Knobs: `--icon-btn-group-h` · `-border` · `-radius` · `-bg` · `-ink` · `-rule` · `-on-bg` · `-on-ink`. Gallery: `/IconBtnGroup`.
|
|
21
|
+
|
|
5
22
|
## 0.7.22 — 2026-09-29
|
|
6
23
|
|
|
7
24
|
### Changed
|
package/README.md
CHANGED
|
@@ -92,8 +92,18 @@ npm i @usableapp/cardds
|
|
|
92
92
|
```tsx
|
|
93
93
|
import { Card, CardHead, Btn } from '@usableapp/cardds';
|
|
94
94
|
import '@usableapp/cardds/dist/cardds.css'; // the CSS, fonts resolve from the package
|
|
95
|
+
import './theme.css'; // the project's look, after cardds.css
|
|
95
96
|
```
|
|
96
97
|
|
|
98
|
+
**One way in, one step** (2026-09-29): `npx cardds init` — writes the project's `theme.css` (a copy of the
|
|
99
|
+
template), adds `npm run cardds:check`, and says what to import. Then a screen is a template (`Walkthrough` · `Home` ·
|
|
100
|
+
`CallSheet` · `Splash`) or components composed — IMPORTED, never their markup copied: the behaviour lives in the
|
|
101
|
+
component. `npx cardds check [dir]` runs the rules (`lint/rules.mjs`): a hand-written cardds class is an error; a
|
|
102
|
+
`Sheet` that cannot move (`state` without `onStateChange`) or leaves by vanishing (on a condition without
|
|
103
|
+
`onSettled`) is a warning; a bar after `</Screen>` or pinned by hand is an error. The same rules in ESLint (flat
|
|
104
|
+
config): `import cardds from '@usableapp/cardds/eslint'` → `cardds.configs.recommended`. A deliberate exception:
|
|
105
|
+
`// cardds-ignore-next-line`. Adjust from outside only: `theme.css` → knobs → props / slots.
|
|
106
|
+
|
|
97
107
|
|
|
98
108
|
**เบราว์เซอร์** (เช็ก 2026-09-24): iPhone ตั้งแต่ **iOS 16.4** (มี.ค. 2023) ขึ้นไป
|
|
99
109
|
|
|
@@ -289,6 +299,9 @@ whose markup has rules, because the sheet's box is the step itself:
|
|
|
289
299
|
onStateChange={setS}>` (controlled) or `<Sheet defaultState="half">` (it keeps its own). The sheet's own behaviour —
|
|
290
300
|
the handle's tap and drag, the focus raise, the climb when the keyboard moves the cut — lives in the component and
|
|
291
301
|
only ever ASKS through `onStateChange(state, reason)`; nothing outside the sheet is touched.
|
|
302
|
+
A sheet always ARRIVES and LEAVES by sliding: mounted already up, it starts `away` for one frame and slides in. To
|
|
303
|
+
take it off the screen set `state="away"` and unmount it in `onSettled` (`s === 'away'`) — unmounting it while it is up
|
|
304
|
+
only makes it vanish (dev warning). Story: `Sheet · MountedOnDemand`.
|
|
292
305
|
|
|
293
306
|
## Centred card markup
|
|
294
307
|
|
package/bin/cardds.mjs
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `npx cardds init` — set a project up the ONE way (Lh 2026-09-29): the project's theme.css, the check script, the ESLint rules.
|
|
3
|
+
// `npx cardds check [dir…]` — the rules of lint/rules.mjs over the project's source; exit 1 on an error.
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, mkdirSync, copyFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, join, relative } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { checkSource } from '../lint/rules.mjs';
|
|
8
|
+
|
|
9
|
+
const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
+
const [cmd, ...args] = process.argv.slice(2);
|
|
11
|
+
const cwd = process.cwd();
|
|
12
|
+
const SKIP = new Set(['node_modules', 'dist', 'build', '.git', '.next', 'coverage']);
|
|
13
|
+
|
|
14
|
+
function* sources(dir) {
|
|
15
|
+
for (const name of readdirSync(dir)) {
|
|
16
|
+
if (SKIP.has(name) || name.startsWith('.')) continue;
|
|
17
|
+
const p = join(dir, name);
|
|
18
|
+
if (statSync(p).isDirectory()) yield* sources(p);
|
|
19
|
+
else if (/\.(jsx|tsx|js|ts)$/.test(name) && !/\.d\.ts$/.test(name)) yield p;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function check(dirs) {
|
|
24
|
+
const roots = (dirs.length ? dirs : ['src']).map((d) => join(cwd, d)).filter((d) => existsSync(d));
|
|
25
|
+
if (!roots.length) { console.error('cardds check: no such folder — `npx cardds check <dir>`'); return 2; }
|
|
26
|
+
let errors = 0, warns = 0;
|
|
27
|
+
for (const root of roots) for (const file of sources(root)) {
|
|
28
|
+
for (const f of checkSource(readFileSync(file, 'utf8'), relative(cwd, file))) {
|
|
29
|
+
f.level === 'error' ? errors++ : warns++;
|
|
30
|
+
console.log(`${f.file}:${f.line} ${f.level === 'error' ? 'error' : 'warn '} [${f.rule}] ${f.message}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
console.log(errors + warns ? `\ncardds check: ${errors} error(s), ${warns} warning(s)` : 'cardds check: clean');
|
|
34
|
+
return errors ? 1 : 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function init() {
|
|
38
|
+
const say = (done, what) => console.log(`${done ? '✓' : '·'} ${what}`);
|
|
39
|
+
const src = existsSync(join(cwd, 'src')) ? 'src' : '.';
|
|
40
|
+
|
|
41
|
+
// 1 · the project's look: its own theme.css, a copy of the template (the defaults — nothing moves until a base is set)
|
|
42
|
+
const theme = join(cwd, src, 'theme.css');
|
|
43
|
+
if (existsSync(theme)) say(false, `${relative(cwd, theme)} is there — kept`);
|
|
44
|
+
else { mkdirSync(dirname(theme), { recursive: true }); copyFileSync(join(PKG, 'css', 'theme.template.css'), theme); say(true, `${relative(cwd, theme)} — the project's look: set the bases there, nothing else`); }
|
|
45
|
+
|
|
46
|
+
// 2 · the check, as a script the project (and its AI) runs before a screen is called done
|
|
47
|
+
const pkgFile = join(cwd, 'package.json');
|
|
48
|
+
if (existsSync(pkgFile)) {
|
|
49
|
+
const pkg = JSON.parse(readFileSync(pkgFile, 'utf8'));
|
|
50
|
+
pkg.scripts ??= {};
|
|
51
|
+
if (pkg.scripts['cardds:check']) say(false, 'package.json "cardds:check" is there — kept');
|
|
52
|
+
else { pkg.scripts['cardds:check'] = `cardds check ${src === '.' ? '' : src}`.trim(); writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + '\n'); say(true, 'package.json "cardds:check" — run it before a screen is done'); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 3 · the same rules in the editor, where the project has ESLint (flat config)
|
|
56
|
+
const eslint = ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.ts'].map((f) => join(cwd, f)).find(existsSync);
|
|
57
|
+
if (eslint && /@usableapp\/cardds\/eslint/.test(readFileSync(eslint, 'utf8'))) say(false, `${relative(cwd, eslint)} has the cardds rules — kept`);
|
|
58
|
+
else if (eslint) say(false, `${relative(cwd, eslint)}: add import cardds from '@usableapp/cardds/eslint' and cardds.configs.recommended to its array`);
|
|
59
|
+
else say(false, 'no ESLint here — `npm run cardds:check` carries the same rules');
|
|
60
|
+
|
|
61
|
+
console.log(`
|
|
62
|
+
In the app's entry, once:
|
|
63
|
+
import '@usableapp/cardds/dist/cardds.css';
|
|
64
|
+
import './theme.css'; // after cardds.css
|
|
65
|
+
|
|
66
|
+
Start a screen from a template (Walkthrough · Home · CallSheet · Splash) or compose the components —
|
|
67
|
+
import them, never copy their markup: the behaviour lives in the component. Gallery: see README.`);
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const code = cmd === 'check' ? check(args) : cmd === 'init' ? init() : (console.log('usage: cardds init | cardds check [dir…]'), cmd ? 2 : 0);
|
|
72
|
+
process.exit(code);
|
package/css/actions.css
CHANGED
|
@@ -93,6 +93,24 @@
|
|
|
93
93
|
}
|
|
94
94
|
.icon-btn--invert { background: var(--icon-btn-invert-bg, var(--accent)); color: var(--icon-btn-invert-ink, var(--on-accent)); border-color: transparent; }
|
|
95
95
|
.icon-btn--sm { width: var(--icon-btn-sm-size, var(--tap-sm)); height: var(--icon-btn-sm-size, var(--tap-sm)); font-size: var(--fs-title); }
|
|
96
|
+
/* ---- icon button group: up to four icon-only commands in ONE frame (Lh 2026-09-29) ----
|
|
97
|
+
<div class="icon-btn-group" role="group"><button class="icon-btn" aria-label="…">…</button>…</div>
|
|
98
|
+
One pill as tall as a button, the buttons share its width, a hairline between them; in a foot it takes the row. */
|
|
99
|
+
.icon-btn-group {
|
|
100
|
+
display: flex; min-width: 0;
|
|
101
|
+
min-height: var(--icon-btn-group-h, var(--btn-h));
|
|
102
|
+
border: var(--border-w) solid var(--icon-btn-group-border, var(--outline));
|
|
103
|
+
border-radius: var(--icon-btn-group-radius, var(--r-chip));
|
|
104
|
+
background: var(--icon-btn-group-bg, var(--action-bg));
|
|
105
|
+
color: var(--icon-btn-group-ink, var(--action-ink));
|
|
106
|
+
overflow: clip;
|
|
107
|
+
}
|
|
108
|
+
.icon-btn-group > .icon-btn { flex: 1 1 0; min-width: 0; width: auto; height: auto; border: 0; border-radius: 0; background: transparent; color: inherit; box-shadow: none; }
|
|
109
|
+
.icon-btn-group > .icon-btn + .icon-btn { border-inline-start: var(--border-w) solid var(--icon-btn-group-rule, var(--icon-btn-group-border, var(--outline))); }
|
|
110
|
+
.card__foot > .icon-btn-group { flex: 1 1 0; }
|
|
111
|
+
.icon-btn-group > .icon-btn[aria-pressed="true"] { background: var(--icon-btn-group-on-bg, var(--accent)); color: var(--icon-btn-group-on-ink, var(--on-accent)); } /* `select`: a pressed button STAYS filled (Lh 2026-09-29) */
|
|
112
|
+
.icon-btn-group > .icon-btn:active:not(:disabled) { scale: 1; opacity: var(--press-dim); } /* a part of one frame DIMS, it never shrinks inside it */
|
|
113
|
+
|
|
96
114
|
/* fab: the square floating action, bottom-right of a stage or screen */
|
|
97
115
|
.fab { width: var(--fab); height: var(--fab); border-radius: var(--r-card); position: absolute; right: var(--sp-4); bottom: calc(var(--sp-5) + var(--bar-h) + var(--sp-3) + var(--safe-bottom)); box-shadow: var(--shadow-float); z-index: var(--z-fab); }
|
|
98
116
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ComponentProps } from 'react';
|
|
2
|
+
type Picked = string | null | string[];
|
|
3
|
+
export interface IconBtnGroupProps extends Omit<ComponentProps<'div'>, 'onChange' | 'defaultValue'> {
|
|
4
|
+
/** the group's accessible name (what these commands act on) */
|
|
5
|
+
label?: string;
|
|
6
|
+
/** buttons that STAY ON when pressed (Lh 2026-09-29): `one` — one on at a time, pressing another moves it (value: the on
|
|
7
|
+
* button's `value`, or null) · `many` — each on or off by itself (value: the on ones' values). None = plain commands. */
|
|
8
|
+
select?: 'one' | 'many';
|
|
9
|
+
/** which are on — each child IconBtn names itself with its own `value` */
|
|
10
|
+
value?: Picked;
|
|
11
|
+
defaultValue?: Picked;
|
|
12
|
+
/** asks the app: `one` → the pressed value · `many` → the new list */
|
|
13
|
+
onChange?: (value: Picked) => void;
|
|
14
|
+
}
|
|
15
|
+
/** IconBtnGroup — a panel of up to FOUR icon-only buttons in ONE frame, sharing its width, a rule between them in the frame's
|
|
16
|
+
* colour (Lh 2026-09-29). Each is an `IconBtn` (its `label` is its spoken name). Plain: each does its own thing on its onClick.
|
|
17
|
+
* With `select`, a pressed button stays filled (aria-pressed): `one` or `many`. At home in a card's or a sheet's foot. */
|
|
18
|
+
export declare function IconBtnGroup({ label, select, value, defaultValue, onChange, className, children, ...rest }: IconBtnGroupProps): import("react").JSX.Element;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Children, cloneElement, isValidElement } from 'react';
|
|
3
|
+
import { cx } from '../cx.js';
|
|
4
|
+
import { useControlled, warnOnce } from '../state.js';
|
|
5
|
+
/** IconBtnGroup — a panel of up to FOUR icon-only buttons in ONE frame, sharing its width, a rule between them in the frame's
|
|
6
|
+
* colour (Lh 2026-09-29). Each is an `IconBtn` (its `label` is its spoken name). Plain: each does its own thing on its onClick.
|
|
7
|
+
* With `select`, a pressed button stays filled (aria-pressed): `one` or `many`. At home in a card's or a sheet's foot. */
|
|
8
|
+
export function IconBtnGroup({ label, select, value, defaultValue, onChange, className, children, ...rest }) {
|
|
9
|
+
if (Children.count(children) > 4)
|
|
10
|
+
warnOnce('icon-btn-group', 'cardds IconBtnGroup: more than 4 buttons — keep 4, put the rest in a Menu behind one of them');
|
|
11
|
+
const [shown, request] = useControlled(value, defaultValue ?? (select === 'many' ? [] : null), onChange);
|
|
12
|
+
const on = (v) => (Array.isArray(shown) ? shown.includes(v) : shown === v);
|
|
13
|
+
const items = !select ? children : Children.map(children, (c) => {
|
|
14
|
+
if (!isValidElement(c))
|
|
15
|
+
return c;
|
|
16
|
+
const el = c;
|
|
17
|
+
const v = el.props.value;
|
|
18
|
+
if (v == null)
|
|
19
|
+
return c;
|
|
20
|
+
return cloneElement(el, {
|
|
21
|
+
'aria-pressed': on(v),
|
|
22
|
+
onClick: (e) => {
|
|
23
|
+
el.props.onClick?.(e);
|
|
24
|
+
if (select === 'one')
|
|
25
|
+
request(v);
|
|
26
|
+
else {
|
|
27
|
+
const list = Array.isArray(shown) ? shown : [];
|
|
28
|
+
request(on(v) ? list.filter((x) => x !== v) : [...list, v]);
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
return _jsx("div", { role: "group", "aria-label": label, className: cx('icon-btn-group', className), ...rest, children: items });
|
|
34
|
+
}
|
package/dist/cardds.css
CHANGED
|
@@ -2117,6 +2117,24 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
|
|
|
2117
2117
|
}
|
|
2118
2118
|
.icon-btn--invert { background: var(--icon-btn-invert-bg, var(--accent)); color: var(--icon-btn-invert-ink, var(--on-accent)); border-color: transparent; }
|
|
2119
2119
|
.icon-btn--sm { width: var(--icon-btn-sm-size, var(--tap-sm)); height: var(--icon-btn-sm-size, var(--tap-sm)); font-size: var(--fs-title); }
|
|
2120
|
+
/* ---- icon button group: up to four icon-only commands in ONE frame (Lh 2026-09-29) ----
|
|
2121
|
+
<div class="icon-btn-group" role="group"><button class="icon-btn" aria-label="…">…</button>…</div>
|
|
2122
|
+
One pill as tall as a button, the buttons share its width, a hairline between them; in a foot it takes the row. */
|
|
2123
|
+
.icon-btn-group {
|
|
2124
|
+
display: flex; min-width: 0;
|
|
2125
|
+
min-height: var(--icon-btn-group-h, var(--btn-h));
|
|
2126
|
+
border: var(--border-w) solid var(--icon-btn-group-border, var(--outline));
|
|
2127
|
+
border-radius: var(--icon-btn-group-radius, var(--r-chip));
|
|
2128
|
+
background: var(--icon-btn-group-bg, var(--action-bg));
|
|
2129
|
+
color: var(--icon-btn-group-ink, var(--action-ink));
|
|
2130
|
+
overflow: clip;
|
|
2131
|
+
}
|
|
2132
|
+
.icon-btn-group > .icon-btn { flex: 1 1 0; min-width: 0; width: auto; height: auto; border: 0; border-radius: 0; background: transparent; color: inherit; box-shadow: none; }
|
|
2133
|
+
.icon-btn-group > .icon-btn + .icon-btn { border-inline-start: var(--border-w) solid var(--icon-btn-group-rule, var(--icon-btn-group-border, var(--outline))); }
|
|
2134
|
+
.card__foot > .icon-btn-group { flex: 1 1 0; }
|
|
2135
|
+
.icon-btn-group > .icon-btn[aria-pressed="true"] { background: var(--icon-btn-group-on-bg, var(--accent)); color: var(--icon-btn-group-on-ink, var(--on-accent)); } /* `select`: a pressed button STAYS filled (Lh 2026-09-29) */
|
|
2136
|
+
.icon-btn-group > .icon-btn:active:not(:disabled) { scale: 1; opacity: var(--press-dim); } /* a part of one frame DIMS, it never shrinks inside it */
|
|
2137
|
+
|
|
2120
2138
|
/* fab: the square floating action, bottom-right of a stage or screen */
|
|
2121
2139
|
.fab { width: var(--fab); height: var(--fab); border-radius: var(--r-card); position: absolute; right: var(--sp-4); bottom: calc(var(--sp-5) + var(--bar-h) + var(--sp-3) + var(--safe-bottom)); box-shadow: var(--shadow-float); z-index: var(--z-fab); }
|
|
2122
2140
|
|
package/dist/index.d.ts
CHANGED
|
@@ -33,6 +33,7 @@ export * from './sheets/SheetSteps.js';
|
|
|
33
33
|
export * from './sheets/Drawer.js';
|
|
34
34
|
export * from './actions/Btn.js';
|
|
35
35
|
export * from './actions/IconBtn.js';
|
|
36
|
+
export * from './actions/IconBtnGroup.js';
|
|
36
37
|
export * from './actions/Chip.js';
|
|
37
38
|
export * from './actions/Dot.js';
|
|
38
39
|
export * from './actions/Dropdown.js';
|
package/dist/index.js
CHANGED
|
@@ -37,6 +37,7 @@ export * from './sheets/SheetSteps.js';
|
|
|
37
37
|
export * from './sheets/Drawer.js';
|
|
38
38
|
export * from './actions/Btn.js';
|
|
39
39
|
export * from './actions/IconBtn.js';
|
|
40
|
+
export * from './actions/IconBtnGroup.js';
|
|
40
41
|
export * from './actions/Chip.js';
|
|
41
42
|
export * from './actions/Dot.js';
|
|
42
43
|
export * from './actions/Dropdown.js';
|
package/dist/sheets/Sheet.d.ts
CHANGED
|
@@ -14,6 +14,9 @@ export interface SheetProps extends Omit<ComponentProps<'article'>, 'onChange'>
|
|
|
14
14
|
/** the sheet ASKS to move — the handle was tapped or dragged, a field in a peeked sheet took focus, the keyboard moved the cut.
|
|
15
15
|
* Controlled: set your state to it (or don't — the sheet stays). This is where an app ties a sheet to its router / back button. */
|
|
16
16
|
onStateChange?: (state: SheetState, reason: SheetChangeReason) => void;
|
|
17
|
+
/** the sheet has FINISHED sliding to `state` (its transition ended; at once where motion is off). To leave the screen: set `away`, and take the
|
|
18
|
+
* sheet out of the tree here — `onSettled={(s) => s === 'away' && setMounted(false)}` — so it slides out instead of vanishing */
|
|
19
|
+
onSettled?: (state: SheetState) => void;
|
|
17
20
|
/** the states a drag snaps to and a reveal may climb through — "peek half full", or an array (default all four) */
|
|
18
21
|
states?: string | readonly Resting[];
|
|
19
22
|
/** the grab handle; `false` for a sheet with no handle, a string sets its accessible name */
|
|
@@ -30,6 +33,10 @@ export interface SheetProps extends Omit<ComponentProps<'article'>, 'onChange'>
|
|
|
30
33
|
* Its own behaviour stays inside it and only ever ASKS (`onStateChange`): a tap on the handle toggles its own state ↔ peek,
|
|
31
34
|
* a drag snaps to the nearest of `states`, focus in a peeked sheet raises it, and when the cut moves (the keyboard) it
|
|
32
35
|
* climbs until the focused control has room above the foot.
|
|
36
|
+
*
|
|
37
|
+
* It ARRIVES and LEAVES by sliding, always: a sheet that mounts already up starts `away` for one frame and slides to its state, so
|
|
38
|
+
* `{open && <Sheet state="full">}` slides in. Leaving is the app's beat: set `away`, and unmount on `onSettled('away')` — unmount it
|
|
39
|
+
* while it is still up and it can only vanish (a dev warning says so).
|
|
33
40
|
*/
|
|
34
|
-
export declare function Sheet({ state, defaultState, onStateChange, states, handle, className, children, onFocus, ...rest }: SheetProps): import("react").JSX.Element;
|
|
41
|
+
export declare function Sheet({ state, defaultState, onStateChange, onSettled, states, handle, className, children, onFocus, ...rest }: SheetProps): import("react").JSX.Element;
|
|
35
42
|
export {};
|
package/dist/sheets/Sheet.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useCallback, useEffect, useRef } from 'react';
|
|
3
|
-
import { useControlled, watchSize } from '../state.js';
|
|
2
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useControlled, useLatest, warnOnce, watchSize } from '../state.js';
|
|
4
4
|
import { cx } from '../cx.js';
|
|
5
5
|
const ORDER = ['peek', 'half', '3q', 'full']; // low → high; `away` is below them all and outside them
|
|
6
6
|
const TAP_REM = 0.5; // = --sp-2: a move shorter than this is a tap, not a drag
|
|
7
|
+
const ms = (t) => Math.max(0, ...t.split(',').map((v) => parseFloat(v) * (v.trim().endsWith('ms') ? 1 : 1000) || 0)); // a computed `0.35s, 0s` list → its longest, in ms
|
|
7
8
|
/**
|
|
8
9
|
* Sheet — the SINGLE sheet: one card as a bottom sheet holding what the member ACTS on. Always the full height of its
|
|
9
10
|
* box — a Step or a SheetStage — a state is only how far it slid (translate; the material never changes size).
|
|
@@ -15,19 +16,67 @@ const TAP_REM = 0.5; // = --sp-2: a move shorter than this is a tap, not a drag
|
|
|
15
16
|
* Its own behaviour stays inside it and only ever ASKS (`onStateChange`): a tap on the handle toggles its own state ↔ peek,
|
|
16
17
|
* a drag snaps to the nearest of `states`, focus in a peeked sheet raises it, and when the cut moves (the keyboard) it
|
|
17
18
|
* climbs until the focused control has room above the foot.
|
|
19
|
+
*
|
|
20
|
+
* It ARRIVES and LEAVES by sliding, always: a sheet that mounts already up starts `away` for one frame and slides to its state, so
|
|
21
|
+
* `{open && <Sheet state="full">}` slides in. Leaving is the app's beat: set `away`, and unmount on `onSettled('away')` — unmount it
|
|
22
|
+
* while it is still up and it can only vanish (a dev warning says so).
|
|
18
23
|
*/
|
|
19
|
-
export function Sheet({ state, defaultState = 'half', onStateChange, states, handle = true, className, children, onFocus, ...rest }) {
|
|
24
|
+
export function Sheet({ state, defaultState = 'half', onStateChange, onSettled, states, handle = true, className, children, onFocus, ...rest }) {
|
|
20
25
|
const [current, request] = useControlled(state, defaultState, onStateChange);
|
|
21
26
|
const ref = useRef(null);
|
|
22
27
|
const handleRef = useRef(null);
|
|
23
28
|
const home = useRef(current !== 'peek' && current !== 'away' ? current : 'half'); // where a tap from peek returns to
|
|
24
29
|
const was = useRef(current);
|
|
25
30
|
const skipClick = useRef(false);
|
|
31
|
+
const [entered, setEntered] = useState(false); // false for the first frame: the class says `away`, so a sheet mounted up has somewhere to slide from
|
|
32
|
+
const shown = entered ? current : 'away';
|
|
33
|
+
const latest = useLatest({ current, onSettled });
|
|
26
34
|
const drag = useRef(null);
|
|
27
35
|
const allowed = useCallback(() => {
|
|
28
36
|
const list = (typeof states === 'string' ? states.split(/\s+/) : [...(states ?? [])]).filter((s) => ORDER.includes(s));
|
|
29
37
|
return list.length ? ORDER.filter((s) => list.includes(s)) : [...ORDER];
|
|
30
38
|
}, [states]);
|
|
39
|
+
useLayoutEffect(() => {
|
|
40
|
+
void ref.current?.offsetHeight; // the browser styles the `away` frame first, or there is no transition to run
|
|
41
|
+
setEntered(true);
|
|
42
|
+
}, []);
|
|
43
|
+
/* it has finished moving: the transition ended (or at once where there is none) — say so once per move; a move cut short by another says nothing */
|
|
44
|
+
const last = useRef(shown);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const el = ref.current;
|
|
47
|
+
if (!el || last.current === shown)
|
|
48
|
+
return;
|
|
49
|
+
last.current = shown;
|
|
50
|
+
const cs = getComputedStyle(el);
|
|
51
|
+
let done = false;
|
|
52
|
+
const end = () => {
|
|
53
|
+
if (done)
|
|
54
|
+
return;
|
|
55
|
+
done = true;
|
|
56
|
+
clearTimeout(t);
|
|
57
|
+
el.removeEventListener('transitionend', onEnd);
|
|
58
|
+
latest.current.onSettled?.(latest.current.current);
|
|
59
|
+
};
|
|
60
|
+
const onEnd = (e) => { if (e.target === el && e.propertyName === 'translate')
|
|
61
|
+
end(); };
|
|
62
|
+
el.addEventListener('transitionend', onEnd);
|
|
63
|
+
const t = setTimeout(end, ms(cs.transitionDuration) + ms(cs.transitionDelay) + 100); // no transition (motion off) or none came: settled anyway
|
|
64
|
+
return () => { done = true; clearTimeout(t); el.removeEventListener('transitionend', onEnd); };
|
|
65
|
+
}, [shown, latest]);
|
|
66
|
+
/* taken out of the tree while it is still up — the box stays, the sheet goes: nothing to slide, so it vanishes. Said once in dev.
|
|
67
|
+
(A whole screen leaving takes the box too; StrictMode's trial unmount is over by the time the timer looks.) */
|
|
68
|
+
const alive = useRef(false);
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
alive.current = true;
|
|
71
|
+
const box = ref.current?.parentElement;
|
|
72
|
+
return () => {
|
|
73
|
+
alive.current = false;
|
|
74
|
+
setTimeout(() => {
|
|
75
|
+
if (!alive.current && box?.isConnected && latest.current.current !== 'away')
|
|
76
|
+
warnOnce('sheet-unmount', 'cardds Sheet: unmounted while on the screen, so it vanished instead of sliding out. Set state="away" and unmount it in onSettled (state === "away").');
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
}, [latest]);
|
|
31
80
|
useEffect(() => {
|
|
32
81
|
if (current !== 'peek' && current !== 'away')
|
|
33
82
|
home.current = current;
|
|
@@ -182,5 +231,5 @@ export function Sheet({ state, defaultState = 'half', onStateChange, states, han
|
|
|
182
231
|
skipClick.current = true;
|
|
183
232
|
requestAnimationFrame(() => { skipClick.current = false; });
|
|
184
233
|
};
|
|
185
|
-
return (_jsxs("article", { ref: ref, className: cx('card sheet', `sheet--${
|
|
234
|
+
return (_jsxs("article", { ref: ref, className: cx('card sheet', `sheet--${shown}`, className), inert: current === 'away' || undefined, onFocus: focus, ...rest, children: [handle !== false && (_jsx("button", { ref: handleRef, className: "sheet__handle", type: "button", "aria-label": typeof handle === 'string' ? handle : 'Resize', onClick: tap, onPointerDown: down, onPointerMove: move, onPointerUp: up, onPointerCancel: up })), children] }));
|
|
186
235
|
}
|
package/lint/eslint.mjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// `@usableapp/cardds/eslint` — the ESLint front door of lint/rules.mjs (the same rules `npx cardds check` runs).
|
|
2
|
+
// // eslint.config.js
|
|
3
|
+
// import cardds from '@usableapp/cardds/eslint';
|
|
4
|
+
// export default [ …, cardds.configs.recommended ];
|
|
5
|
+
// It reads the file's TEXT, so it works with whatever parser the project already has for .tsx.
|
|
6
|
+
import { checkSource } from './rules.mjs';
|
|
7
|
+
|
|
8
|
+
// two rules = the two levels of rules.mjs: `check` reports its errors, `advice` its warnings
|
|
9
|
+
const rule = (level, description) => ({
|
|
10
|
+
meta: { type: level === 'error' ? 'problem' : 'suggestion', docs: { description }, schema: [] },
|
|
11
|
+
create(context) {
|
|
12
|
+
const source = context.sourceCode ?? context.getSourceCode();
|
|
13
|
+
return {
|
|
14
|
+
'Program:exit'() {
|
|
15
|
+
for (const f of checkSource(source.text, context.filename ?? context.getFilename()))
|
|
16
|
+
if (f.level === level) context.report({ loc: { line: f.line, column: 0 }, message: `[${f.rule}] ${f.message}` });
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
const check = rule('error', 'cardds used through its components: no hand-written cardds class; the bar is the Screen\'s bottom');
|
|
22
|
+
const advice = rule('warn', 'cardds behaviour kept: a Sheet that can move and slides out; no bar in front of a sheet');
|
|
23
|
+
|
|
24
|
+
const plugin = { meta: { name: '@usableapp/cardds/eslint' }, rules: { check, advice } };
|
|
25
|
+
plugin.configs = {
|
|
26
|
+
recommended: { files: ['**/*.{js,jsx,ts,tsx}'], plugins: { cardds: plugin }, rules: { 'cardds/check': 'error', 'cardds/advice': 'warn' } },
|
|
27
|
+
};
|
|
28
|
+
export default plugin;
|
package/lint/rules.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// lint/rules.mjs — how a PROJECT uses cardds, as code. ONE rule set, two front doors (Lh 2026-09-29, "one standard"):
|
|
2
|
+
// • `npx cardds check [dir]` (bin/cardds.mjs) — no dependency, runs anywhere
|
|
3
|
+
// • the ESLint plugin (lint/eslint.mjs, `@usableapp/cardds/eslint`) — the same findings in the editor and CI
|
|
4
|
+
// cardds's own tests run it on the gallery's stories (tests/lint.spec.js) and on the screens (tests/composition.spec.js).
|
|
5
|
+
// Plain text in, findings out: { rule, level: 'error' | 'warn', file, line, message }. No parser — any .tsx/.jsx/.ts/.js.
|
|
6
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const CSS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'css');
|
|
11
|
+
|
|
12
|
+
const lineOf = (text, idx) => text.slice(0, idx).split('\n').length;
|
|
13
|
+
|
|
14
|
+
/** every class cardds's CSS styles — read from the package's own css/, so the list is never copied by hand */
|
|
15
|
+
let classes;
|
|
16
|
+
export function carddsClasses(dir = CSS_DIR) {
|
|
17
|
+
if (classes && dir === CSS_DIR) return classes;
|
|
18
|
+
const set = new Set();
|
|
19
|
+
for (const f of readdirSync(dir)) {
|
|
20
|
+
if (!f.endsWith('.css') || f.startsWith('theme.')) continue;
|
|
21
|
+
const css = readFileSync(join(dir, f), 'utf8')
|
|
22
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
23
|
+
.replace(/url\([^)]*\)/g, '')
|
|
24
|
+
.replace(/"[^"]*"|'[^']*'/g, '');
|
|
25
|
+
for (const m of css.matchAll(/(?<![\w\\-])\.(-?[a-zA-Z_][\w-]*)/g)) if (!/^(is|has)-/.test(m[1])) set.add(m[1]); // a bare state word (`is-open`) is every project's too — it only matters beside a cardds class, which is caught
|
|
26
|
+
}
|
|
27
|
+
if (dir === CSS_DIR) classes = set;
|
|
28
|
+
return set;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** the whole of a JSX opening tag from `<Name` — attributes may hold `=>` and nested braces, so a `[^>]*` regex is not enough */
|
|
32
|
+
function tagAt(text, start) {
|
|
33
|
+
let depth = 0, quote = null;
|
|
34
|
+
for (let i = start + 1; i < text.length; i++) {
|
|
35
|
+
const c = text[i];
|
|
36
|
+
if (quote) { if (c === quote && text[i - 1] !== '\\') quote = null; continue; }
|
|
37
|
+
if (c === '"' || c === "'" || c === '`') quote = c;
|
|
38
|
+
else if (c === '{') depth++;
|
|
39
|
+
else if (c === '}') depth--;
|
|
40
|
+
else if (c === '>' && depth === 0) return text.slice(start, i + 1);
|
|
41
|
+
}
|
|
42
|
+
return text.slice(start);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** the expression of a `{…}` attribute value from its opening brace */
|
|
46
|
+
function braceAt(text, start) {
|
|
47
|
+
let depth = 0, quote = null;
|
|
48
|
+
for (let i = start; i < text.length; i++) {
|
|
49
|
+
const c = text[i];
|
|
50
|
+
if (quote) { if (c === quote && text[i - 1] !== '\\') quote = null; continue; }
|
|
51
|
+
if (c === '"' || c === "'" || c === '`') quote = c;
|
|
52
|
+
else if (c === '{') depth++;
|
|
53
|
+
else if (c === '}' && --depth === 0) return text.slice(start, i + 1);
|
|
54
|
+
}
|
|
55
|
+
return text.slice(start);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** the static words a className holds: its string, or every string / template text inside its `{…}` */
|
|
59
|
+
function classWords(text, at) {
|
|
60
|
+
const rest = text.slice(at);
|
|
61
|
+
const lit = rest.match(/^\s*=\s*(?:"([^"]*)"|'([^']*)')/);
|
|
62
|
+
if (lit) return (lit[1] ?? lit[2]).split(/\s+/);
|
|
63
|
+
const open = rest.match(/^\s*=\s*\{/);
|
|
64
|
+
if (!open) return [];
|
|
65
|
+
const expr = braceAt(text, at + open[0].length - 1);
|
|
66
|
+
const words = [];
|
|
67
|
+
for (const m of expr.matchAll(/"([^"]*)"|'([^']*)'|`([^`]*)`/g)) words.push(...(m[1] ?? m[2] ?? m[3].replace(/\$\{[^}]*\}/g, ' ')).split(/\s+/));
|
|
68
|
+
return words;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {string} text a source file
|
|
73
|
+
* @param {string} file its name, for the findings
|
|
74
|
+
* @param {{ classes?: Set<string> }} [opts]
|
|
75
|
+
*/
|
|
76
|
+
export function checkSource(source, file, { classes = carddsClasses() } = {}) {
|
|
77
|
+
const findings = [];
|
|
78
|
+
const lines = source.split('\n');
|
|
79
|
+
const text = source.replace(/\/\*[\s\S]*?\*\/|(?<![:'"`\\])\/\/[^\n]*/g, (c) => c.replace(/[^\n]/g, ' ')); // comments out, lines kept
|
|
80
|
+
const add = (rule, level, idx, message) => {
|
|
81
|
+
const line = lineOf(text, idx);
|
|
82
|
+
if (!/cardds-ignore-next-line/.test(lines[line - 2] ?? '')) findings.push({ rule, level, file, line, message }); // `// cardds-ignore-next-line` — a deliberate case (a story that shows the wrong way)
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// 1 · a cardds class written by hand: the look comes without the behaviour — import the component
|
|
86
|
+
for (const m of text.matchAll(/\bclass(?:Name)?(?=\s*=)/g)) {
|
|
87
|
+
const hit = [...new Set(classWords(text, m.index + m[0].length).filter((w) => classes.has(w)))];
|
|
88
|
+
if (hit.length) add('raw-class', 'error', m.index, `cardds class written by hand (${hit.map((w) => '.' + w).join(' ')}): the look without the behaviour — use the cardds component (its props pick the classes), or a knob / theme.css for the look.`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 2 · Sheet: controlled with no way to move · put in the tree on a condition with no slide out
|
|
92
|
+
for (const m of text.matchAll(/<Sheet\b/g)) {
|
|
93
|
+
const tag = tagAt(text, m.index);
|
|
94
|
+
if (/\sstate\s*=/.test(tag) && !/\sonStateChange\s*=/.test(tag))
|
|
95
|
+
add('sheet-frozen', 'warn', m.index, '<Sheet state> without onStateChange: the handle, the drag and the keyboard climb only ASK — nothing answers, the sheet cannot move. Add onStateChange, or use defaultState.');
|
|
96
|
+
const before = text.slice(Math.max(0, m.index - 40), m.index);
|
|
97
|
+
if (/(?:&&|\?|:)\s*\(?\s*$/.test(before) && !/\sonSettled\s*=/.test(tag))
|
|
98
|
+
add('sheet-unmounted', 'warn', m.index, '<Sheet> rendered on a condition without onSettled: taken out of the tree it vanishes instead of sliding out. Set state="away" and unmount it in onSettled (state === "away") — or keep it mounted and switch its state.');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 3 · the screen: the bar is the Screen's bottom, never after it, never pinned; a sheet finishes on its own foot
|
|
102
|
+
for (const m of text.matchAll(/<\/Screen>\s*(?:\{[^}]*\}\s*)*<(ActionBar|FloatBar)\b/g)) add('screen.bar-outside', 'error', m.index, `${m[1]} after </Screen>: pass it as <Screen bottom={…}> — the screen's column lays it.`);
|
|
103
|
+
for (const m of text.matchAll(/<(ActionBar|FloatBar)\b[^>]*style=\{\{[^}]*\bposition\b/g)) add('screen.bar-pinned', 'error', m.index, `${m[1]} with a hand-set position: the screen's flex column places the bar.`);
|
|
104
|
+
const fns = [...text.matchAll(/export (?:default )?function (\w+)/g)];
|
|
105
|
+
fns.forEach((m, i) => {
|
|
106
|
+
const body = text.slice(m.index, fns[i + 1]?.index ?? text.length);
|
|
107
|
+
if (/<Sheet\b/.test(body) && /<ActionBar\b/.test(body)) add('screen.bar-with-sheet', 'warn', m.index, `${m[1]}: a Sheet and an ActionBar — a screen with a sheet finishes on the sheet's CardFoot, no bar.`);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return findings.sort((a, b) => a.line - b.line);
|
|
111
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usableapp/cardds",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.24",
|
|
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.",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"knobs": "node scripts/knobs.mjs",
|
|
18
18
|
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p gallery/tsconfig.json --noEmit",
|
|
19
19
|
"snapshot:write": "CARDDS_SNAPSHOT=write playwright test tests/style-snapshot.spec.js",
|
|
20
|
-
"snapshot:check": "CARDDS_SNAPSHOT=check playwright test tests/style-snapshot.spec.js"
|
|
20
|
+
"snapshot:check": "CARDDS_SNAPSHOT=check playwright test tests/style-snapshot.spec.js",
|
|
21
|
+
"check": "node bin/cardds.mjs check .design-sync/previews gallery/src"
|
|
21
22
|
},
|
|
22
23
|
"devDependencies": {
|
|
23
24
|
"@playwright/test": "1.58.2",
|
|
@@ -33,6 +34,9 @@
|
|
|
33
34
|
"main": "dist/index.js",
|
|
34
35
|
"module": "dist/index.js",
|
|
35
36
|
"types": "dist/index.d.ts",
|
|
37
|
+
"bin": {
|
|
38
|
+
"cardds": "bin/cardds.mjs"
|
|
39
|
+
},
|
|
36
40
|
"exports": {
|
|
37
41
|
".": {
|
|
38
42
|
"types": "./dist/index.d.ts",
|
|
@@ -42,10 +46,13 @@
|
|
|
42
46
|
"./dist/cardds.css": "./dist/cardds.css",
|
|
43
47
|
"./css/*": "./css/*",
|
|
44
48
|
"./cardds.js": "./cardds.js",
|
|
45
|
-
"./icons.svg": "./icons.svg"
|
|
49
|
+
"./icons.svg": "./icons.svg",
|
|
50
|
+
"./eslint": "./lint/eslint.mjs"
|
|
46
51
|
},
|
|
47
52
|
"files": [
|
|
48
53
|
"dist",
|
|
54
|
+
"lint",
|
|
55
|
+
"bin",
|
|
49
56
|
"css",
|
|
50
57
|
"fonts",
|
|
51
58
|
"cardds.css",
|
|
@@ -56,12 +63,18 @@
|
|
|
56
63
|
],
|
|
57
64
|
"peerDependencies": {
|
|
58
65
|
"react": ">=19",
|
|
59
|
-
"react-dom": ">=19"
|
|
66
|
+
"react-dom": ">=19",
|
|
67
|
+
"eslint": ">=9"
|
|
60
68
|
},
|
|
61
69
|
"publishConfig": {
|
|
62
70
|
"access": "public"
|
|
63
71
|
},
|
|
64
72
|
"dependencies": {
|
|
65
73
|
"@oddbird/popover-polyfill": "^0.7.2"
|
|
74
|
+
},
|
|
75
|
+
"peerDependenciesMeta": {
|
|
76
|
+
"eslint": {
|
|
77
|
+
"optional": true
|
|
78
|
+
}
|
|
66
79
|
}
|
|
67
80
|
}
|