@usableapp/cardds 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +562 -0
- package/cardds.css +18 -0
- package/cardds.js +473 -0
- package/css/actions.css +292 -0
- package/css/base.css +128 -0
- package/css/card.css +283 -0
- package/css/choice.css +297 -0
- package/css/fonts.css +37 -0
- package/css/forms.css +64 -0
- package/css/journey.css +61 -0
- package/css/layover.css +243 -0
- package/css/lists.css +142 -0
- package/css/media.css +232 -0
- package/css/numbers.css +258 -0
- package/css/palette.css +112 -0
- package/css/people.css +79 -0
- package/css/sheet.css +295 -0
- package/css/stack.css +262 -0
- package/css/step.css +71 -0
- package/css/tokens.css +177 -0
- package/dist/actions/Btn.d.ts +311 -0
- package/dist/actions/Btn.js +14 -0
- package/dist/actions/BtnRow.d.ts +5 -0
- package/dist/actions/BtnRow.js +6 -0
- package/dist/actions/Chip.d.ts +328 -0
- package/dist/actions/Chip.js +25 -0
- package/dist/actions/Dot.d.ts +7 -0
- package/dist/actions/Dot.js +6 -0
- package/dist/actions/Dropdown.d.ts +34 -0
- package/dist/actions/Dropdown.js +19 -0
- package/dist/actions/Fab.d.ts +9 -0
- package/dist/actions/Fab.js +7 -0
- package/dist/actions/IconBtn.d.ts +308 -0
- package/dist/actions/IconBtn.js +8 -0
- package/dist/actions/Link.d.ts +301 -0
- package/dist/actions/Link.js +8 -0
- package/dist/actions/Segment.d.ts +11 -0
- package/dist/actions/Segment.js +10 -0
- package/dist/cardds.css +3068 -0
- package/dist/cards/Card.d.ts +310 -0
- package/dist/cards/Card.js +14 -0
- package/dist/cards/CardFoot.d.ts +5 -0
- package/dist/cards/CardFoot.js +6 -0
- package/dist/cards/CardHead.d.ts +21 -0
- package/dist/cards/CardHead.js +11 -0
- package/dist/cards/CardParts.d.ts +25 -0
- package/dist/cards/CardParts.js +26 -0
- package/dist/cards/Placeholder.d.ts +9 -0
- package/dist/cards/Placeholder.js +6 -0
- package/dist/cards/Stat.d.ts +17 -0
- package/dist/cards/Stat.js +14 -0
- package/dist/choice/Calendar.d.ts +23 -0
- package/dist/choice/Calendar.js +7 -0
- package/dist/choice/Check.d.ts +14 -0
- package/dist/choice/Check.js +10 -0
- package/dist/choice/DayStrip.d.ts +16 -0
- package/dist/choice/DayStrip.js +10 -0
- package/dist/choice/Mood.d.ts +11 -0
- package/dist/choice/Mood.js +6 -0
- package/dist/choice/Slider.d.ts +11 -0
- package/dist/choice/Slider.js +14 -0
- package/dist/cx.d.ts +2 -0
- package/dist/cx.js +2 -0
- package/dist/forms/AddRow.d.ts +5 -0
- package/dist/forms/AddRow.js +7 -0
- package/dist/forms/Composer.d.ts +13 -0
- package/dist/forms/Composer.js +6 -0
- package/dist/forms/Field.d.ts +17 -0
- package/dist/forms/Field.js +10 -0
- package/dist/forms/FileBtn.d.ts +18 -0
- package/dist/forms/FileBtn.js +10 -0
- package/dist/forms/Pin.d.ts +19 -0
- package/dist/forms/Pin.js +27 -0
- package/dist/index.d.ts +72 -0
- package/dist/index.js +75 -0
- package/dist/journey/NoteRow.d.ts +7 -0
- package/dist/journey/NoteRow.js +7 -0
- package/dist/journey/Route.d.ts +18 -0
- package/dist/journey/Route.js +8 -0
- package/dist/journey/TileBadge.d.ts +15 -0
- package/dist/journey/TileBadge.js +6 -0
- package/dist/layover/Banner.d.ts +9 -0
- package/dist/layover/Banner.js +6 -0
- package/dist/layover/Callout.d.ts +10 -0
- package/dist/layover/Callout.js +6 -0
- package/dist/layover/Deck.d.ts +9 -0
- package/dist/layover/Deck.js +10 -0
- package/dist/lists/Kv.d.ts +27 -0
- package/dist/lists/Kv.js +19 -0
- package/dist/lists/Legend.d.ts +11 -0
- package/dist/lists/Legend.js +11 -0
- package/dist/lists/Row.d.ts +319 -0
- package/dist/lists/Row.js +17 -0
- package/dist/lists/Timeline.d.ts +11 -0
- package/dist/lists/Timeline.js +10 -0
- package/dist/media/MapArea.d.ts +20 -0
- package/dist/media/MapArea.js +11 -0
- package/dist/media/Mosaic.d.ts +11 -0
- package/dist/media/Mosaic.js +10 -0
- package/dist/media/Postcard.d.ts +20 -0
- package/dist/media/Postcard.js +10 -0
- package/dist/media/Quote.d.ts +11 -0
- package/dist/media/Quote.js +6 -0
- package/dist/media/Tile.d.ts +320 -0
- package/dist/media/Tile.js +16 -0
- package/dist/numbers/Badge.d.ts +7 -0
- package/dist/numbers/Badge.js +6 -0
- package/dist/numbers/Band.d.ts +16 -0
- package/dist/numbers/Band.js +10 -0
- package/dist/numbers/Bars.d.ts +14 -0
- package/dist/numbers/Bars.js +10 -0
- package/dist/numbers/DotGrid.d.ts +12 -0
- package/dist/numbers/DotGrid.js +6 -0
- package/dist/numbers/Picker.d.ts +13 -0
- package/dist/numbers/Picker.js +18 -0
- package/dist/numbers/Ring.d.ts +15 -0
- package/dist/numbers/Ring.js +6 -0
- package/dist/numbers/Track.d.ts +12 -0
- package/dist/numbers/Track.js +10 -0
- package/dist/people/Avatar.d.ts +314 -0
- package/dist/people/Avatar.js +13 -0
- package/dist/people/AvatarPick.d.ts +18 -0
- package/dist/people/AvatarPick.js +10 -0
- package/dist/scaffold/ActionBar.d.ts +21 -0
- package/dist/scaffold/ActionBar.js +16 -0
- package/dist/scaffold/AppBar.d.ts +18 -0
- package/dist/scaffold/AppBar.js +10 -0
- package/dist/scaffold/Centre.d.ts +30 -0
- package/dist/scaffold/Centre.js +41 -0
- package/dist/scaffold/EmptyState.d.ts +14 -0
- package/dist/scaffold/EmptyState.js +8 -0
- package/dist/scaffold/FilterRow.d.ts +5 -0
- package/dist/scaffold/FilterRow.js +6 -0
- package/dist/scaffold/FloatBar.d.ts +310 -0
- package/dist/scaffold/FloatBar.js +15 -0
- package/dist/scaffold/PagerAt.d.ts +11 -0
- package/dist/scaffold/PagerAt.js +6 -0
- package/dist/scaffold/Screen.d.ts +299 -0
- package/dist/scaffold/Screen.js +11 -0
- package/dist/scaffold/Section.d.ts +12 -0
- package/dist/scaffold/Section.js +13 -0
- package/dist/scaffold/TopBar.d.ts +15 -0
- package/dist/scaffold/TopBar.js +9 -0
- package/dist/sheets/CardBack.d.ts +7 -0
- package/dist/sheets/CardBack.js +7 -0
- package/dist/sheets/Drawer.d.ts +17 -0
- package/dist/sheets/Drawer.js +16 -0
- package/dist/sheets/Sheet.d.ts +21 -0
- package/dist/sheets/Sheet.js +13 -0
- package/dist/sheets/SheetBody.d.ts +5 -0
- package/dist/sheets/SheetBody.js +6 -0
- package/dist/sheets/SheetHead.d.ts +13 -0
- package/dist/sheets/SheetHead.js +6 -0
- package/dist/sheets/SheetStack.d.ts +21 -0
- package/dist/sheets/SheetStack.js +14 -0
- package/dist/sheets/SheetStage.d.ts +20 -0
- package/dist/sheets/SheetStage.js +11 -0
- package/dist/sheets/Step.d.ts +15 -0
- package/dist/sheets/Step.js +15 -0
- package/dist/type/Icon.d.ts +16 -0
- package/dist/type/Icon.js +12 -0
- package/dist/type/Text.d.ts +302 -0
- package/dist/type/Text.js +16 -0
- package/dist/type/icons.d.ts +6 -0
- package/dist/type/icons.js +2 -0
- package/fonts/LICENSES.md +44 -0
- package/fonts/fc-pride-medium.otf +0 -0
- package/fonts/pk-nonthaburi-demo.ttf +0 -0
- package/icons.svg +61 -0
- package/package.json +62 -0
package/cardds.js
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/* cardds.js — optional behaviour helpers (the CSS works without it).
|
|
2
|
+
Dropdown: clicking a .menu__item marks it current, copies its label into the
|
|
3
|
+
.dropdown__label of the enclosing .dropdown, closes the popover and fires
|
|
4
|
+
"change" (detail.value) on the .dropdown. */
|
|
5
|
+
document.addEventListener('click', e => {
|
|
6
|
+
const item = e.target.closest('.menu__item');
|
|
7
|
+
if (!item) return;
|
|
8
|
+
const menu = item.closest('.menu');
|
|
9
|
+
const dd = menu && menu.closest('.dropdown');
|
|
10
|
+
menu.querySelectorAll('.menu__item').forEach(i => i.removeAttribute('aria-current'));
|
|
11
|
+
item.setAttribute('aria-current', 'true');
|
|
12
|
+
if (dd) {
|
|
13
|
+
const label = dd.querySelector('.dropdown__label');
|
|
14
|
+
const value = item.dataset.value ?? item.textContent.trim();
|
|
15
|
+
if (label) label.textContent = item.dataset.label ?? item.textContent.trim();
|
|
16
|
+
dd.dataset.value = value;
|
|
17
|
+
dd.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value } }));
|
|
18
|
+
}
|
|
19
|
+
if (menu.matches(':popover-open')) menu.hidePopover();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/* Pin (.pin, choice.css): one digit per cell. Typing a digit moves focus to
|
|
23
|
+
the next cell; Backspace in an empty cell moves it back (and clears that
|
|
24
|
+
one); a paste of the whole code fills the cells from the one it landed in.
|
|
25
|
+
Only digits stay. Delegated, so a Pin rendered by any host — the React
|
|
26
|
+
adapter, plain HTML — gets it; the cells stay ordinary inputs. */
|
|
27
|
+
/* Postcard (.postcard, media.css): a tap on either face turns the card over.
|
|
28
|
+
Delegated; the faces are buttons, so Enter and Space arrive as clicks. */
|
|
29
|
+
document.addEventListener('click', e => {
|
|
30
|
+
const face = e.target instanceof Element && e.target.closest('.postcard__face');
|
|
31
|
+
if (!face) return;
|
|
32
|
+
const card = face.closest('.postcard');
|
|
33
|
+
if (!card) return;
|
|
34
|
+
const flipped = card.classList.toggle('is-flipped');
|
|
35
|
+
card.dispatchEvent(new CustomEvent('cardds:flip', { bubbles: true, detail: { flipped } }));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/* Slider (.slider, choice.css): the knob's place along the rail, published on
|
|
39
|
+
the .slider as --slider-at (0..1) so the .slider__glyph can ride the knob.
|
|
40
|
+
Delegated, and run once for every slider already on the page. */
|
|
41
|
+
function carddsSliderAt(range) {
|
|
42
|
+
const min = Number(range.min) || 0, max = Number(range.max) || 100;
|
|
43
|
+
const at = max > min ? (Number(range.value) - min) / (max - min) : 0;
|
|
44
|
+
range.closest('.slider')?.style.setProperty('--slider-at', String(Math.min(1, Math.max(0, at))));
|
|
45
|
+
}
|
|
46
|
+
document.addEventListener('input', e => {
|
|
47
|
+
if (e.target instanceof HTMLInputElement && e.target.classList.contains('slider__range')) carddsSliderAt(e.target);
|
|
48
|
+
});
|
|
49
|
+
document.addEventListener('input', e => {
|
|
50
|
+
const cell = e.target;
|
|
51
|
+
if (!(cell instanceof HTMLInputElement) || !cell.classList.contains('pin__cell')) return;
|
|
52
|
+
const cells = [...cell.closest('.pin').querySelectorAll('.pin__cell')];
|
|
53
|
+
const i = cells.indexOf(cell);
|
|
54
|
+
const digits = cell.value.replace(/\D/g, '');
|
|
55
|
+
// a paste (or a typed run) spills over the cells that follow
|
|
56
|
+
digits.split('').forEach((d, k) => { if (cells[i + k]) cells[i + k].value = d; });
|
|
57
|
+
if (!digits) cell.value = '';
|
|
58
|
+
else {
|
|
59
|
+
const next = cells[Math.min(i + digits.length, cells.length - 1)];
|
|
60
|
+
if (next !== cell) next.focus();
|
|
61
|
+
else if (cell.value !== digits.slice(-1)) cell.value = digits.slice(-1);
|
|
62
|
+
}
|
|
63
|
+
carddsPinPublish(cells);
|
|
64
|
+
});
|
|
65
|
+
/* "cardds:pin" (bubbles, detail.code = the digits so far, detail.complete)
|
|
66
|
+
after every change — the one thing a host listens to; the React adapter's
|
|
67
|
+
Pin turns it into onChange */
|
|
68
|
+
function carddsPinPublish(cells) {
|
|
69
|
+
const code = cells.map(c => c.value).join('');
|
|
70
|
+
cells[0].closest('.pin').dispatchEvent(new CustomEvent('cardds:pin', { bubbles: true, detail: { code, complete: code.length === cells.length } }));
|
|
71
|
+
}
|
|
72
|
+
/* a cell holds one digit; typing into a full one replaces it — select the
|
|
73
|
+
digit before the insert lands (a paste of a whole code still spills over
|
|
74
|
+
the cells that follow, above). No maxlength on the cells: the browser
|
|
75
|
+
would truncate a pasted code before this ever saw it. */
|
|
76
|
+
document.addEventListener('beforeinput', e => {
|
|
77
|
+
const cell = e.target;
|
|
78
|
+
if (!(cell instanceof HTMLInputElement) || !cell.classList.contains('pin__cell')) return;
|
|
79
|
+
if (e.inputType === 'insertText' && cell.value && cell.selectionStart === cell.selectionEnd) cell.select();
|
|
80
|
+
});
|
|
81
|
+
document.addEventListener('focusin', e => {
|
|
82
|
+
const cell = e.target;
|
|
83
|
+
if (cell instanceof HTMLInputElement && cell.classList.contains('pin__cell') && cell.value) cell.select();
|
|
84
|
+
});
|
|
85
|
+
document.addEventListener('keydown', e => {
|
|
86
|
+
const cell = e.target;
|
|
87
|
+
if (!(cell instanceof HTMLInputElement) || !cell.classList.contains('pin__cell')) return;
|
|
88
|
+
const cells = [...cell.closest('.pin').querySelectorAll('.pin__cell')];
|
|
89
|
+
const i = cells.indexOf(cell);
|
|
90
|
+
if (e.key === 'Backspace' && !cell.value && i > 0) {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
cells[i - 1].value = '';
|
|
93
|
+
cells[i - 1].focus();
|
|
94
|
+
carddsPinPublish(cells);
|
|
95
|
+
} else if (e.key === 'ArrowLeft' && i > 0) { e.preventDefault(); cells[i - 1].focus(); }
|
|
96
|
+
else if (e.key === 'ArrowRight' && i < cells.length - 1) { e.preventDefault(); cells[i + 1].focus(); }
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/* Sheet stack (.sheet-stack--tap, stack.css): tap a peeked sheet to open it, its back
|
|
100
|
+
button returns it. Sheets are rigid material — they move in, move out, or
|
|
101
|
+
get covered, never morph — so the only animated property is translate;
|
|
102
|
+
see the --tap block in stack.css for the whole model.
|
|
103
|
+
|
|
104
|
+
This only ever toggles classes. Every card's resting slot, offset and
|
|
105
|
+
paint order is declared in CSS, so a stack still renders correctly with
|
|
106
|
+
this file absent — it just won't open. Works for any number of stacks on
|
|
107
|
+
a page, with no ids or per-page wiring. */
|
|
108
|
+
const CARDDS_STACK_PHASE = 380; /* ms before the before-pack un-tucks on
|
|
109
|
+
close, so the after-pack is clear first */
|
|
110
|
+
|
|
111
|
+
/* the stack's sheets, in slot order. Normally the stack's direct children;
|
|
112
|
+
a host that wraps each child in one box of its own (a design tool's
|
|
113
|
+
per-component frame, a router's element) is tolerated: the first .card
|
|
114
|
+
inside each child then counts. Never deeper — a card inside a sheet's
|
|
115
|
+
content is content, not a sheet. */
|
|
116
|
+
function carddsStackCards(stack) {
|
|
117
|
+
return [...stack.children]
|
|
118
|
+
.filter(c => !c.classList.contains('sheet-stack__base')) // the base is what shows behind the sheets, never a sheet (a Card in it is content)
|
|
119
|
+
.map(c => c.classList.contains('card') ? c : c.querySelector(':scope > .card')).filter(Boolean);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
document.addEventListener('click', e => {
|
|
123
|
+
const stack = e.target.closest('.sheet-stack--tap');
|
|
124
|
+
if (!stack) return;
|
|
125
|
+
const cards = carddsStackCards(stack);
|
|
126
|
+
// the sheet this tap landed in: the member the target sits in, not the
|
|
127
|
+
// nearest .card (a content card inside an open sheet must not count)
|
|
128
|
+
const card = cards.find(c => c.contains(e.target));
|
|
129
|
+
if (e.target.closest('.card__back')) { carddsCloseStack(stack, card); return; }
|
|
130
|
+
if (card && !cards.some(c => c.classList.contains('is-open'))) {
|
|
131
|
+
carddsOpenStack(stack, card);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
function carddsOpenStack(stack, card) {
|
|
136
|
+
const cards = carddsStackCards(stack);
|
|
137
|
+
const idx = cards.indexOf(card);
|
|
138
|
+
cards.forEach((c, i) => {
|
|
139
|
+
c.classList.remove('is-before', 'is-after');
|
|
140
|
+
c.style.setProperty('--stagger', 0); // only is-before overrides this
|
|
141
|
+
if (i < idx) {
|
|
142
|
+
// already behind the tapped card — tuck under the next card's strip,
|
|
143
|
+
// nearest one first, and let the opening card cover them
|
|
144
|
+
c.style.setProperty('--stagger', idx - 1 - i);
|
|
145
|
+
c.classList.add('is-before');
|
|
146
|
+
} else if (i > idx) {
|
|
147
|
+
c.classList.add('is-after'); // in front of it, so these must leave
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
card.classList.add('is-open');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* close: the sheet whose back button was tapped, read from the tap itself
|
|
154
|
+
(a page with several stacks, or a re-render mid-animation, can leave a
|
|
155
|
+
stale .is-open elsewhere); without one, the stack's open sheet */
|
|
156
|
+
function carddsCloseStack(stack, card) {
|
|
157
|
+
const cards = carddsStackCards(stack);
|
|
158
|
+
card = card && cards.includes(card) ? card : cards.find(c => c.classList.contains('is-open'));
|
|
159
|
+
if (!card) return;
|
|
160
|
+
const idx = cards.indexOf(card);
|
|
161
|
+
// phase 1: the after-pack moves back in from below while the opened card
|
|
162
|
+
// drops into its own slot, passing down over the still-covered before-pack
|
|
163
|
+
cards.filter((c, i) => i > idx).forEach(c => c.classList.remove('is-after'));
|
|
164
|
+
card.classList.remove('is-open'); // back button starts its own crop-out now
|
|
165
|
+
// phase 2: then the before-pack slides back up out from behind it, same
|
|
166
|
+
// order it tucked away — nearest to the card first
|
|
167
|
+
const before = cards.filter((c, i) => i < idx);
|
|
168
|
+
setTimeout(() => before.forEach(c => c.classList.remove('is-before')), CARDDS_STACK_PHASE);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* ---- card heading squeeze ----
|
|
172
|
+
A skeleton head's heading has a budget of two lines (card.css, "Skeleton
|
|
173
|
+
head"). When the words need more, step --squeeze down — the CSS multiplies
|
|
174
|
+
the level's own size by it — until it fits two lines, or stop at the floor
|
|
175
|
+
and let the CSS clamp hold it to three.
|
|
176
|
+
|
|
177
|
+
Stack heads are skipped: a peek is one line at its own size. stack.css opts
|
|
178
|
+
those same cards out of the clamp, so the two files agree on what a skeleton
|
|
179
|
+
head is — change one, change the other.
|
|
180
|
+
|
|
181
|
+
What causes a measure:
|
|
182
|
+
- a ResizeObserver on each heading. A heading only needs a different squeeze
|
|
183
|
+
when its WIDTH changes, so that is what is watched: window resize, a
|
|
184
|
+
container that moved, display:none -> shown, all arrive here. Our own
|
|
185
|
+
font-size change is not a width change, so it does not come back round.
|
|
186
|
+
- a MutationObserver that only REGISTERS headings added later. It reads
|
|
187
|
+
added nodes, never the layout, so a host app that re-renders constantly
|
|
188
|
+
pays a node scan instead of a measuring pass over the page.
|
|
189
|
+
- document.fonts.ready, once: a font swap changes glyph metrics at an
|
|
190
|
+
unchanged width, which is the one case the ResizeObserver cannot see.
|
|
191
|
+
- a "cardds:layout" event, listened for in the CAPTURE phase so it arrives
|
|
192
|
+
whether or not the app sets bubbles: true. */
|
|
193
|
+
const CARDDS_SQUEEZE_STEPS = [0.94, 0.88, 0.82];
|
|
194
|
+
const CARDDS_HEAD_SEL = '.card__head :is(h1, h2, h3)';
|
|
195
|
+
|
|
196
|
+
/* a heading in a card's own head — not one in a sheet-stack peek (stack.css).
|
|
197
|
+
A sheet is read the way the stack reads it (carddsStackCards): a direct
|
|
198
|
+
child, or one wrapper down — a host that wraps each sheet must not turn its
|
|
199
|
+
one-line peek into a squeezed skeleton head. */
|
|
200
|
+
function carddsIsSkeletonHead(h) {
|
|
201
|
+
const card = h.closest('.card__head')?.parentElement;
|
|
202
|
+
if (!card) return false;
|
|
203
|
+
const stack = card.parentElement?.closest('.sheet-stack');
|
|
204
|
+
return !stack || !carddsStackCards(stack).includes(card);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function carddsSqueezeOne(h) {
|
|
208
|
+
h.style.removeProperty('--squeeze');
|
|
209
|
+
if (h.getClientRects().length === 0) return; // not rendered — nothing to measure
|
|
210
|
+
/* line-height is read again at every step: --squeeze scales it too, so a
|
|
211
|
+
count taken against the unsqueezed value is out by exactly the step */
|
|
212
|
+
const lines = () => {
|
|
213
|
+
const lh = parseFloat(getComputedStyle(h).lineHeight);
|
|
214
|
+
return lh ? Math.round(h.scrollHeight / lh) : 0;
|
|
215
|
+
};
|
|
216
|
+
if (lines() <= 2) return;
|
|
217
|
+
for (const step of CARDDS_SQUEEZE_STEPS) {
|
|
218
|
+
h.style.setProperty('--squeeze', step);
|
|
219
|
+
if (lines() <= 2) break;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function carddsSqueeze(root = document) {
|
|
224
|
+
root.querySelectorAll(CARDDS_HEAD_SEL).forEach(h => {
|
|
225
|
+
if (carddsIsSkeletonHead(h)) carddsSqueezeOne(h);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* measuring is batched into one frame, and the width we settle at is recorded
|
|
230
|
+
so the resize our own font-size change causes is recognised, not re-run */
|
|
231
|
+
const carddsHeadWidth = new WeakMap();
|
|
232
|
+
const carddsPending = new Set();
|
|
233
|
+
let carddsRaf = 0;
|
|
234
|
+
|
|
235
|
+
function carddsFlush() {
|
|
236
|
+
carddsRaf = 0;
|
|
237
|
+
const batch = [...carddsPending];
|
|
238
|
+
carddsPending.clear();
|
|
239
|
+
batch.forEach(carddsSqueezeOne);
|
|
240
|
+
batch.forEach(h => carddsHeadWidth.set(h, h.getBoundingClientRect().width));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const carddsHeadResize = new ResizeObserver(entries => {
|
|
244
|
+
for (const e of entries) {
|
|
245
|
+
const w = e.target.getBoundingClientRect().width;
|
|
246
|
+
if (Math.abs((carddsHeadWidth.get(e.target) ?? -1) - w) < 0.5) continue;
|
|
247
|
+
carddsHeadWidth.set(e.target, w);
|
|
248
|
+
carddsPending.add(e.target);
|
|
249
|
+
}
|
|
250
|
+
if (carddsPending.size && !carddsRaf) carddsRaf = requestAnimationFrame(carddsFlush);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
function carddsWatch(root = document) {
|
|
254
|
+
root.querySelectorAll(CARDDS_HEAD_SEL).forEach(h => {
|
|
255
|
+
if (carddsIsSkeletonHead(h)) carddsHeadResize.observe(h);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
carddsWatch();
|
|
260
|
+
(document.fonts ? document.fonts.ready : Promise.resolve()).then(() => carddsSqueeze());
|
|
261
|
+
|
|
262
|
+
new MutationObserver(records => {
|
|
263
|
+
for (const r of records) {
|
|
264
|
+
for (const n of r.addedNodes) {
|
|
265
|
+
if (n.nodeType !== 1) continue;
|
|
266
|
+
if (n.matches(CARDDS_HEAD_SEL) && carddsIsSkeletonHead(n)) carddsHeadResize.observe(n);
|
|
267
|
+
else carddsWatch(n);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}).observe(document.documentElement, { childList: true, subtree: true });
|
|
271
|
+
|
|
272
|
+
document.addEventListener('cardds:layout', e => {
|
|
273
|
+
const root = e.target instanceof Element ? e.target : document;
|
|
274
|
+
carddsWatch(root);
|
|
275
|
+
carddsSqueeze(root);
|
|
276
|
+
}, true);
|
|
277
|
+
|
|
278
|
+
/* ---- sheet (.sheet, sheet.css): peek / half / full ----
|
|
279
|
+
A sheet is rigid material, like a sheet in the stack (stack.css) — it never changes size, a
|
|
280
|
+
state is only how far it slid — so this, like the stack code, only ever
|
|
281
|
+
toggles classes and two custom properties. The CSS renders every state
|
|
282
|
+
without it; this adds the handle's click and drag, the focus raise, the
|
|
283
|
+
keyboard room, and one event. Delegated: any sheet on the page, no ids.
|
|
284
|
+
|
|
285
|
+
- click / Enter / Space on .sheet__handle cycles the states named in
|
|
286
|
+
data-sheet-states (default "peek half full"), wrapping round
|
|
287
|
+
- drag on the handle writes the offset to --_drag (the CSS adds it to the
|
|
288
|
+
state's translate) and on release snaps to the nearest state; a move
|
|
289
|
+
shorter than --sp-2 is a tap. The snap is the one place the sheet's
|
|
290
|
+
geometry is read, once per release: its height, and the two state
|
|
291
|
+
tokens — registered in tokens.css so they read back resolved
|
|
292
|
+
- focus inside a peeked sheet raises it to half (or full, if the sheet has
|
|
293
|
+
no half) and reveals the control — the sheet's own scroll-padding
|
|
294
|
+
already knows how much of it slid under the box's edge
|
|
295
|
+
- the visual viewport's height is published on :root as --kb, the height
|
|
296
|
+
the on-screen keyboard covers, so the bar (actions.css) and the step
|
|
297
|
+
(step.css) can stay above it on iOS
|
|
298
|
+
- "cardds:sheet" (bubbles, detail.state) after every state change
|
|
299
|
+
|
|
300
|
+
carddsSheetSet(sheet, state) is the one entry point; an app that sets
|
|
301
|
+
the state itself calls it rather than swapping classes, so listeners hear. */
|
|
302
|
+
const CARDDS_SHEET_STATES = ['peek', 'half', 'full'];
|
|
303
|
+
const CARDDS_SHEET_TAP_REM = 0.5; /* = --sp-2: a move shorter than this is a tap, not a drag */
|
|
304
|
+
|
|
305
|
+
function carddsSheetStates(sheet) {
|
|
306
|
+
const list = (sheet.dataset.sheetStates || '').split(/\s+/).filter(s => CARDDS_SHEET_STATES.includes(s));
|
|
307
|
+
return list.length ? list : CARDDS_SHEET_STATES;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function carddsSheetState(sheet) {
|
|
311
|
+
return CARDDS_SHEET_STATES.find(s => sheet.classList.contains('sheet--' + s)) ?? 'half'; // the CSS default
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function carddsSheetSet(sheet, state) {
|
|
315
|
+
if (!CARDDS_SHEET_STATES.includes(state)) return;
|
|
316
|
+
const was = carddsSheetState(sheet);
|
|
317
|
+
CARDDS_SHEET_STATES.forEach(s => sheet.classList.toggle('sheet--' + s, s === state));
|
|
318
|
+
if (was !== state) sheet.dispatchEvent(new CustomEvent('cardds:sheet', { bubbles: true, detail: { state } }));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/* the click that follows a drag on the same handle is not a tap — the drag
|
|
322
|
+
already chose the state. Cleared on the next frame, so a drag that ends
|
|
323
|
+
without a click (pointercancel) cannot eat a later, real one. */
|
|
324
|
+
let carddsSheetSkipClick = null;
|
|
325
|
+
|
|
326
|
+
document.addEventListener('click', e => {
|
|
327
|
+
const handle = e.target.closest('.sheet__handle');
|
|
328
|
+
if (!handle) return;
|
|
329
|
+
if (handle === carddsSheetSkipClick) { carddsSheetSkipClick = null; return; }
|
|
330
|
+
const sheet = handle.closest('.sheet');
|
|
331
|
+
if (!sheet) return;
|
|
332
|
+
const states = carddsSheetStates(sheet);
|
|
333
|
+
carddsSheetSet(sheet, states[(states.indexOf(carddsSheetState(sheet)) + 1) % states.length]);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
/* how far the sheet has slid, in px: its rendered top against its resting top
|
|
337
|
+
at full (the box's padding edge + its own offset). Read from geometry, not
|
|
338
|
+
from the computed translate — that keeps percentages and calcs as text. */
|
|
339
|
+
function carddsSheetY(sheet) {
|
|
340
|
+
const box = sheet.offsetParent ?? sheet.parentElement;
|
|
341
|
+
return sheet.getBoundingClientRect().top - (box.getBoundingClientRect().top + box.clientTop + sheet.offsetTop);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/* drag: where each state rests, in px from the sheet's top at full */
|
|
345
|
+
function carddsSheetStops(sheet) {
|
|
346
|
+
const cs = getComputedStyle(sheet);
|
|
347
|
+
const height = sheet.getBoundingClientRect().height;
|
|
348
|
+
return {
|
|
349
|
+
full: 0,
|
|
350
|
+
half: height * (parseFloat(cs.getPropertyValue('--sheet-half')) || 0), // a share of the height
|
|
351
|
+
peek: height - (parseFloat(cs.getPropertyValue('--sheet-peek')) || 0), // a length, resolved
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
let carddsSheetDrag = null;
|
|
356
|
+
|
|
357
|
+
document.addEventListener('pointerdown', e => {
|
|
358
|
+
const handle = e.target.closest('.sheet__handle');
|
|
359
|
+
if (!handle || !e.isPrimary || e.button !== 0) return;
|
|
360
|
+
const sheet = handle.closest('.sheet');
|
|
361
|
+
if (!sheet) return;
|
|
362
|
+
const stops = carddsSheetStops(sheet);
|
|
363
|
+
const states = carddsSheetStates(sheet);
|
|
364
|
+
const y = carddsSheetY(sheet); // where it is now — mid-slide too, so a grab never jumps
|
|
365
|
+
carddsSheetDrag = {
|
|
366
|
+
sheet, handle, states, stops,
|
|
367
|
+
startY: e.clientY,
|
|
368
|
+
y0: y,
|
|
369
|
+
y,
|
|
370
|
+
min: Math.min(...states.map(s => stops[s])),
|
|
371
|
+
max: Math.max(...states.map(s => stops[s])),
|
|
372
|
+
tap: CARDDS_SHEET_TAP_REM * parseFloat(getComputedStyle(document.documentElement).fontSize),
|
|
373
|
+
moved: false,
|
|
374
|
+
};
|
|
375
|
+
handle.setPointerCapture(e.pointerId);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
document.addEventListener('pointermove', e => {
|
|
379
|
+
const d = carddsSheetDrag;
|
|
380
|
+
if (!d || !e.isPrimary) return;
|
|
381
|
+
const dy = e.clientY - d.startY;
|
|
382
|
+
if (!d.moved) {
|
|
383
|
+
if (Math.abs(dy) < d.tap) return;
|
|
384
|
+
d.moved = true;
|
|
385
|
+
d.sheet.classList.add('is-dragging');
|
|
386
|
+
}
|
|
387
|
+
d.y = Math.min(Math.max(d.y0 + dy, d.min), d.max); // never above full, never below the lowest state
|
|
388
|
+
d.sheet.style.setProperty('--_drag', (d.y - d.y0) + 'px');
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
function carddsSheetRelease() {
|
|
392
|
+
const d = carddsSheetDrag;
|
|
393
|
+
if (!d) return;
|
|
394
|
+
carddsSheetDrag = null;
|
|
395
|
+
d.sheet.classList.remove('is-dragging');
|
|
396
|
+
if (!d.moved) return; // a tap: the click that follows cycles the state
|
|
397
|
+
d.sheet.style.removeProperty('--_drag');
|
|
398
|
+
const nearest = d.states.reduce((a, b) => Math.abs(d.stops[b] - d.y) < Math.abs(d.stops[a] - d.y) ? b : a);
|
|
399
|
+
carddsSheetSet(d.sheet, nearest); // same state: --_drag is gone, so it eases back on its own
|
|
400
|
+
carddsSheetSkipClick = d.handle;
|
|
401
|
+
requestAnimationFrame(() => { carddsSheetSkipClick = null; });
|
|
402
|
+
}
|
|
403
|
+
document.addEventListener('pointerup', carddsSheetRelease);
|
|
404
|
+
document.addEventListener('pointercancel', carddsSheetRelease);
|
|
405
|
+
|
|
406
|
+
/* focus in a peeked sheet: a control under the cut cannot be used, so raise
|
|
407
|
+
the sheet first — to the state after peek at least, further if that one
|
|
408
|
+
has no room for the control under the pinned head (a small phone at a
|
|
409
|
+
large text size). The browser's own reveal scrolled the body against the
|
|
410
|
+
peek strip before this fired; start over from the top and let the reveal,
|
|
411
|
+
with the raised state's scroll-padding, do the least. */
|
|
412
|
+
document.addEventListener('focusin', e => {
|
|
413
|
+
const sheet = e.target.closest?.('.sheet');
|
|
414
|
+
if (!sheet || !sheet.classList.contains('sheet--peek') || e.target.closest('.sheet__handle')) return;
|
|
415
|
+
const states = carddsSheetStates(sheet);
|
|
416
|
+
const body = sheet.querySelector(':scope > .sheet__body');
|
|
417
|
+
if (body) body.scrollTop = 0;
|
|
418
|
+
carddsSheetReveal(e.target, states[states.indexOf('peek') + 1] ?? states[states.length - 1]);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
/* --kb: the height of the viewport an on-screen keyboard covers. iOS keeps
|
|
422
|
+
the layout viewport as it was and shrinks only the visual one, so a fixed
|
|
423
|
+
bar and the sheet's control disappear behind the keyboard unless they
|
|
424
|
+
read this; Android with interactive-widget=resizes-content (set it in the
|
|
425
|
+
viewport meta) shrinks the layout viewport, and this stays 0. A pinch zoom
|
|
426
|
+
also shrinks the visual viewport, and is not a keyboard — ignored. */
|
|
427
|
+
if (window.visualViewport) {
|
|
428
|
+
const vv = window.visualViewport;
|
|
429
|
+
const publish = () => {
|
|
430
|
+
const covered = vv.scale > 1.01 ? 0 : Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop));
|
|
431
|
+
document.documentElement.style.setProperty('--kb', covered + 'px');
|
|
432
|
+
/* the keyboard just moved the cut: a sheet's box shrank with the step (or
|
|
433
|
+
the viewport did), and the control being typed into may now sit under
|
|
434
|
+
it. Half was enough before the keyboard; it may not be now — raise the
|
|
435
|
+
sheet as far as its states go and reveal the control. This is the one
|
|
436
|
+
listener that reads layout, and it reads it after the change it made. */
|
|
437
|
+
carddsSheetReveal(document.activeElement);
|
|
438
|
+
};
|
|
439
|
+
vv.addEventListener('resize', publish);
|
|
440
|
+
vv.addEventListener('scroll', publish);
|
|
441
|
+
publish();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/* bring a control in a sheet into the visible part — the part of its body
|
|
445
|
+
between the pinned head and the cut (the box's bottom edge, or the keyboard
|
|
446
|
+
above it). The sheet goes to the lowest of its states, from the current one
|
|
447
|
+
(or `atLeast`) upward, whose visible body has room for the control; if none
|
|
448
|
+
has, to its highest; then the body scrolls the control into view. An app
|
|
449
|
+
that moves the cut itself (its own keyboard handling, a bar it shows late)
|
|
450
|
+
calls this with the control; the keyboard listener and the focus raise call
|
|
451
|
+
it. It reads layout — after the change that moved the cut — and reasons
|
|
452
|
+
from the resting offsets, so it is right mid-slide too. */
|
|
453
|
+
function carddsSheetReveal(el, atLeast) {
|
|
454
|
+
const sheet = el?.closest?.('.sheet');
|
|
455
|
+
if (!sheet || el.closest('.sheet__handle')) return;
|
|
456
|
+
el = el.closest('.field') ?? el; // the control's box, not the bare input inside a .field
|
|
457
|
+
const states = carddsSheetStates(sheet);
|
|
458
|
+
const from = Math.max(states.indexOf(carddsSheetState(sheet)), states.indexOf(atLeast), 0);
|
|
459
|
+
const stops = carddsSheetStops(sheet);
|
|
460
|
+
const sheetRect = sheet.getBoundingClientRect();
|
|
461
|
+
const restTop = sheetRect.top - carddsSheetY(sheet); // where the sheet's top sits at full
|
|
462
|
+
const bodyTop = (sheet.querySelector(':scope > .sheet__body') ?? sheet).getBoundingClientRect().top - sheetRect.top; // the scrollport, from the sheet's top
|
|
463
|
+
const box = sheet.parentElement.getBoundingClientRect(); // the box that clips it: .step or .sheet-stage
|
|
464
|
+
const vv = window.visualViewport;
|
|
465
|
+
const cut = vv ? Math.min(box.bottom, vv.offsetTop + vv.height) : box.bottom;
|
|
466
|
+
const need = el.getBoundingClientRect().height;
|
|
467
|
+
let to = states[states.length - 1];
|
|
468
|
+
for (let i = from; i < states.length; i++) {
|
|
469
|
+
if (cut - (restTop + stops[states[i]] + bodyTop) >= need) { to = states[i]; break; }
|
|
470
|
+
}
|
|
471
|
+
carddsSheetSet(sheet, to);
|
|
472
|
+
el.scrollIntoView({ block: 'nearest' });
|
|
473
|
+
}
|