@echodial/deck 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.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1327 -0
  3. package/bin/deck.mjs +219 -0
  4. package/build.mjs +338 -0
  5. package/dist/api-buckets.json +10486 -0
  6. package/dist/api.json +57896 -0
  7. package/dist/brand/deck-apple-touch-icon.png +0 -0
  8. package/dist/brand/deck-logo-dark.svg +1 -0
  9. package/dist/brand/deck-logo-light.svg +1 -0
  10. package/dist/brand/deck-logo-stacked.svg +1 -0
  11. package/dist/brand/deck-logo.svg +1 -0
  12. package/dist/brand/deck-mark.svg +7 -0
  13. package/dist/brand/deck-og.png +0 -0
  14. package/dist/collisions.json +223 -0
  15. package/dist/deck-adapters.js +482 -0
  16. package/dist/deck-adapters.min.js +23 -0
  17. package/dist/deck-extras.js +823 -0
  18. package/dist/deck-extras.min.js +5 -0
  19. package/dist/deck-icons.svg +184 -0
  20. package/dist/deck.bundle.js +2638 -0
  21. package/dist/deck.bundle.min.js +86 -0
  22. package/dist/deck.css +7904 -0
  23. package/dist/deck.esm.js +2644 -0
  24. package/dist/deck.js +1331 -0
  25. package/dist/deck.min.css +32 -0
  26. package/dist/deck.min.js +60 -0
  27. package/dist/layers/anchor.css +192 -0
  28. package/dist/layers/buttons.css +210 -0
  29. package/dist/layers/charts.css +337 -0
  30. package/dist/layers/combobox.css +248 -0
  31. package/dist/layers/components.css +868 -0
  32. package/dist/layers/container.css +199 -0
  33. package/dist/layers/datagrid.css +335 -0
  34. package/dist/layers/datepicker.css +305 -0
  35. package/dist/layers/forms.css +401 -0
  36. package/dist/layers/gradients.css +302 -0
  37. package/dist/layers/inputs.css +522 -0
  38. package/dist/layers/layers.css +87 -0
  39. package/dist/layers/layout.css +235 -0
  40. package/dist/layers/logical.css +226 -0
  41. package/dist/layers/media.css +400 -0
  42. package/dist/layers/mobile.css +245 -0
  43. package/dist/layers/motion.css +379 -0
  44. package/dist/layers/nav.css +771 -0
  45. package/dist/layers/perf.css +234 -0
  46. package/dist/layers/print.css +198 -0
  47. package/dist/layers/reset.css +103 -0
  48. package/dist/layers/space3d.css +280 -0
  49. package/dist/layers/toasts.css +201 -0
  50. package/dist/layers/tokens.css +229 -0
  51. package/dist/layers/type.css +114 -0
  52. package/dist/layers/utilities.css +241 -0
  53. package/dist/sizes.json +52 -0
  54. package/dist/usage.json +6340 -0
  55. package/package.json +94 -0
  56. package/src/00-layers.css +87 -0
  57. package/src/01-tokens.css +229 -0
  58. package/src/02-reset.css +103 -0
  59. package/src/03-type.css +114 -0
  60. package/src/04-layout.css +235 -0
  61. package/src/05-buttons.css +210 -0
  62. package/src/06-forms.css +401 -0
  63. package/src/07-components.css +868 -0
  64. package/src/08-mobile.css +245 -0
  65. package/src/09-utilities.css +241 -0
  66. package/src/10-datepicker.css +305 -0
  67. package/src/11-combobox.css +248 -0
  68. package/src/12-datagrid.css +335 -0
  69. package/src/13-toasts.css +201 -0
  70. package/src/14-charts.css +337 -0
  71. package/src/16-motion.css +379 -0
  72. package/src/18-container.css +199 -0
  73. package/src/19-logical.css +226 -0
  74. package/src/20-gradients.css +302 -0
  75. package/src/21-space3d.css +280 -0
  76. package/src/22-nav.css +771 -0
  77. package/src/23-inputs.css +522 -0
  78. package/src/24-media.css +400 -0
  79. package/src/25-anchor.css +192 -0
  80. package/src/26-perf.css +234 -0
  81. package/src/99-print.css +198 -0
  82. package/src/brand/deck-apple-touch-icon.png +0 -0
  83. package/src/brand/deck-logo-dark.svg +1 -0
  84. package/src/brand/deck-logo-light.svg +1 -0
  85. package/src/brand/deck-logo-stacked.svg +1 -0
  86. package/src/brand/deck-logo.svg +1 -0
  87. package/src/brand/deck-mark.svg +7 -0
  88. package/src/brand/deck-og.png +0 -0
  89. package/src/brand/sources.json +7 -0
  90. package/src/deck-icons.svg +184 -0
  91. package/src/js/deck-adapters.js +482 -0
  92. package/src/js/deck-extras.js +823 -0
  93. package/src/js/deck.js +1331 -0
package/dist/deck.js ADDED
@@ -0,0 +1,1331 @@
1
+ /*! ==========================================================================
2
+ * deck.js v0.1 — behaviour for Deck
3
+ * No dependencies. No build step. Drop it in and it wires itself up.
4
+ *
5
+ * <script src="/assets/deck/deck.js" defer></script>
6
+ *
7
+ * deck-icons.svg is expected to sit beside this file.
8
+ *
9
+ * Anything with a data-deck-* attribute is initialised on DOMContentLoaded
10
+ * and again whenever you call Deck.init(container) after injecting HTML.
11
+ * ======================================================================== */
12
+
13
+ const Deck = (() => {
14
+ 'use strict';
15
+
16
+ /* deck-icons.svg ships next to deck.js, so resolve it against this script's
17
+ own URL and the sprite follows the bundle wherever it is installed. Read
18
+ it here, at script-execution time, while document.currentScript is still
19
+ set. Override per page with data-deck-icons on the script tag; setting
20
+ Deck.iconSprite later only affects icons rendered after that point. */
21
+ const spriteURL = (() => {
22
+ const tag = typeof document !== 'undefined' ? document.currentScript : null;
23
+ if (tag && tag.dataset.deckIcons) return tag.dataset.deckIcons;
24
+ if (tag && tag.src) {
25
+ try { return new URL('deck-icons.svg', tag.src).href; } catch (e) { }
26
+ }
27
+ return '/assets/deck-icons.svg';
28
+ })();
29
+
30
+ const $ = (sel, root = document) => root.querySelector(sel);
31
+ const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
32
+ const el = (tag, cls, html) => {
33
+ const n = document.createElement(tag);
34
+ if (cls) n.className = cls;
35
+ if (html != null) n.innerHTML = html;
36
+ return n;
37
+ };
38
+ const icon = (name, cls = 'icon') =>
39
+ `<svg class="${cls}" aria-hidden="true"><use href="${Deck.iconSprite}#${name}"></use></svg>`;
40
+
41
+ /* ======================================================================
42
+ Date picker
43
+ <div class="datefield" data-deck-datepicker
44
+ data-mode="single|range" data-format="mdy|dmy|iso"
45
+ data-min="2024-01-01" data-max="2026-12-31" data-presets>
46
+ <input class="input" name="ship_date" readonly>
47
+ </div>
48
+ ====================================================================== */
49
+
50
+ const DAY = 86400000;
51
+ const iso = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
52
+ const parseISO = s => {
53
+ if (!s) return null;
54
+ const [y, m, d] = s.split('-').map(Number);
55
+ return (y && m && d) ? new Date(y, m - 1, d) : null;
56
+ };
57
+ const sameDay = (a, b) => a && b && a.toDateString() === b.toDateString();
58
+ const startOfDay = d => new Date(d.getFullYear(), d.getMonth(), d.getDate());
59
+ const addMonths = (d, n) => new Date(d.getFullYear(), d.getMonth() + n, 1);
60
+
61
+ /* Month and weekday names come from Intl rather than a hardcoded English
62
+ array or a date library. It is built into every browser, it is correct in
63
+ every locale, and it costs nothing to ship. Set data-locale on the field
64
+ to override the document language. */
65
+ const nameCache = new Map();
66
+ function names(locale) {
67
+ const key = locale || 'auto';
68
+ if (nameCache.has(key)) return nameCache.get(key);
69
+ const loc = locale || document.documentElement.lang || navigator.language || 'en';
70
+ const month = new Intl.DateTimeFormat(loc, { month: 'long' });
71
+ const monthShort = new Intl.DateTimeFormat(loc, { month: 'short' });
72
+ const day = new Intl.DateTimeFormat(loc, { weekday: 'short' });
73
+ const set = {
74
+ locale: loc,
75
+ months: Array.from({ length: 12 }, (_, i) => month.format(new Date(2021, i, 1))),
76
+ monthsShort: Array.from({ length: 12 }, (_, i) => monthShort.format(new Date(2021, i, 1))),
77
+ // 2021-08-01 was a Sunday, so this walks Sunday through Saturday
78
+ days: Array.from({ length: 7 }, (_, i) => day.format(new Date(2021, 7, 1 + i))),
79
+ // First day of the week per locale: Sunday in the US, Monday in the EU
80
+ weekStart: (() => {
81
+ try {
82
+ const info = new Intl.Locale(loc).getWeekInfo?.() ||
83
+ new Intl.Locale(loc).weekInfo;
84
+ return info ? (info.firstDay === 7 ? 0 : info.firstDay) : 0;
85
+ } catch (e) { return 0; }
86
+ })(),
87
+ long: new Intl.DateTimeFormat(loc, { dateStyle: 'medium' }),
88
+ full: new Intl.DateTimeFormat(loc, { dateStyle: 'full' })
89
+ };
90
+ nameCache.set(key, set);
91
+ return set;
92
+ }
93
+
94
+ function formatDate(d, fmt, locale) {
95
+ if (!d) return '';
96
+ if (fmt === 'iso') return iso(d);
97
+ if (fmt === 'long') return names(locale).long.format(d);
98
+ const y = d.getFullYear(), m = d.getMonth() + 1, day = d.getDate();
99
+ const p = n => String(n).padStart(2, '0');
100
+ if (fmt === 'dmy') return `${p(day)}/${p(m)}/${y}`;
101
+ if (fmt === 'ymd') return `${y}-${p(m)}-${p(day)}`;
102
+ return `${p(m)}/${p(day)}/${y}`;
103
+ }
104
+
105
+ class DatePicker {
106
+ constructor(root) {
107
+ this.root = root;
108
+ this.input = $('input', root);
109
+ this.mode = root.dataset.mode || 'single';
110
+ this.format = root.dataset.format || 'mdy';
111
+ this.min = parseISO(root.dataset.min);
112
+ this.max = parseISO(root.dataset.max);
113
+ this.showPresets = root.hasAttribute('data-presets');
114
+ this.locale = root.dataset.locale || null;
115
+ this.i18n = names(this.locale);
116
+ this.weekStart = root.dataset.weekStart != null
117
+ ? Number(root.dataset.weekStart)
118
+ : this.i18n.weekStart;
119
+ this.months = Number(root.dataset.months || (this.mode === 'range' ? 2 : 1));
120
+
121
+ this.start = parseISO(this.input.value) || null;
122
+ this.end = null;
123
+ this.view = startOfDay(this.start || new Date());
124
+ this.view.setDate(1);
125
+ this.jump = null;
126
+
127
+ this.build();
128
+ this.bind();
129
+ this.render();
130
+ }
131
+
132
+ build() {
133
+ const r = this.root;
134
+ if (!$('.icon', r)) r.insertAdjacentHTML('afterbegin', icon('calendar'));
135
+ if (!$('.datefield-clear', r)) {
136
+ r.insertAdjacentHTML('beforeend',
137
+ `<button type="button" class="datefield-clear" aria-label="Clear date">${icon('x', 'icon icon-sm')}</button>`);
138
+ }
139
+ this.input.readOnly = true;
140
+ this.input.setAttribute('aria-haspopup', 'dialog');
141
+
142
+ this.id = 'dp-' + Math.random().toString(36).slice(2, 8);
143
+ this.panel = el('div', 'datepicker');
144
+ this.panel.id = this.id;
145
+ this.panel.setAttribute('popover', '');
146
+ document.body.append(this.panel);
147
+ this.input.setAttribute('popovertarget', this.id);
148
+ this.input.style.cursor = 'pointer';
149
+ }
150
+
151
+ bind() {
152
+ this.input.addEventListener('click', () => this.panel.showPopover());
153
+ this.input.addEventListener('keydown', e => {
154
+ if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
155
+ e.preventDefault();
156
+ this.panel.showPopover();
157
+ }
158
+ });
159
+ $('.datefield-clear', this.root).addEventListener('click', e => {
160
+ e.stopPropagation();
161
+ this.start = this.end = null;
162
+ this.commit();
163
+ this.render();
164
+ });
165
+ this.panel.addEventListener('toggle', e => {
166
+ if (e.newState === 'open') this.position();
167
+ });
168
+ this.panel.addEventListener('click', e => this.onClick(e));
169
+ this.panel.addEventListener('mouseover', e => {
170
+ const day = e.target.closest('.datepicker-day');
171
+ if (this.mode !== 'range' || !this.start || this.end || !day) return;
172
+ this.hover = parseISO(day.dataset.date);
173
+ this.paintRange();
174
+ });
175
+ window.addEventListener('resize', () => {
176
+ if (this.panel.matches(':popover-open')) this.position();
177
+ });
178
+ }
179
+
180
+ position() {
181
+ /* Where the browser has anchor positioning, 25-anchor.css places the
182
+ panel and these inline insets would be measured from the anchored
183
+ region rather than from the viewport — offsetting it a second time by
184
+ its own distance down the page. Same shape as Grid.shadows(), which
185
+ only runs where scroll-state queries are missing. */
186
+ if (CSS.supports('anchor-name: --a')) return;
187
+ if (window.innerWidth <= 480) return; // becomes a sheet in CSS
188
+ const r = this.root.getBoundingClientRect();
189
+ const p = this.panel;
190
+ p.style.insetInlineStart = Math.max(8, Math.min(r.left, window.innerWidth - p.offsetWidth - 8)) + 'px';
191
+ const below = window.innerHeight - r.bottom;
192
+ p.style.insetBlockStart = (below > p.offsetHeight + 12 || below > r.top)
193
+ ? (r.bottom + 6) + 'px'
194
+ : Math.max(8, r.top - p.offsetHeight - 6) + 'px';
195
+ }
196
+
197
+ blocked(d) {
198
+ return (this.min && d < this.min) || (this.max && d > this.max);
199
+ }
200
+
201
+ onClick(e) {
202
+ const day = e.target.closest('.datepicker-day');
203
+ if (day) return this.pick(parseISO(day.dataset.date));
204
+
205
+ const nav = e.target.closest('[data-nav]');
206
+ if (nav) {
207
+ this.view = addMonths(this.view, Number(nav.dataset.nav));
208
+ return this.render();
209
+ }
210
+ const title = e.target.closest('.datepicker-title');
211
+ if (title) { this.jump = this.jump ? null : 'month'; return this.render(); }
212
+
213
+ const jm = e.target.closest('[data-month]');
214
+ if (jm) {
215
+ this.view = new Date(this.view.getFullYear(), Number(jm.dataset.month), 1);
216
+ this.jump = null;
217
+ return this.render();
218
+ }
219
+ const preset = e.target.closest('[data-preset]');
220
+ if (preset) {
221
+ const n = Number(preset.dataset.preset);
222
+ const today = startOfDay(new Date());
223
+ if (this.mode === 'range') {
224
+ this.end = today;
225
+ this.start = n === 0 ? today : new Date(today - (n - 1) * DAY);
226
+ } else {
227
+ this.start = new Date(today - n * DAY);
228
+ this.end = null;
229
+ }
230
+ this.commit();
231
+ this.render();
232
+ if (this.mode !== 'range') this.panel.hidePopover();
233
+ return;
234
+ }
235
+ if (e.target.closest('[data-apply]')) { this.commit(); this.panel.hidePopover(); }
236
+ if (e.target.closest('[data-cancel]')) this.panel.hidePopover();
237
+ }
238
+
239
+ pick(d) {
240
+ if (!d || this.blocked(d)) return;
241
+ if (this.mode === 'range') {
242
+ if (!this.start || this.end) { this.start = d; this.end = null; }
243
+ else if (d < this.start) { this.end = this.start; this.start = d; }
244
+ else this.end = d;
245
+ } else {
246
+ this.start = d;
247
+ this.end = null;
248
+ }
249
+ this.commit();
250
+ this.render();
251
+ if (this.mode !== 'range' || this.end) {
252
+ setTimeout(() => this.panel.hidePopover(), 120);
253
+ }
254
+ }
255
+
256
+ commit() {
257
+ const f = d => formatDate(d, this.format, this.locale);
258
+ this.input.value = this.mode === 'range'
259
+ ? (this.start ? f(this.start) + (this.end ? ' – ' + f(this.end) : '') : '')
260
+ : f(this.start);
261
+ this.root.classList.toggle('has-value', !!this.input.value);
262
+ this.input.dispatchEvent(new CustomEvent('deck:change', {
263
+ bubbles: true,
264
+ detail: { start: this.start ? iso(this.start) : null, end: this.end ? iso(this.end) : null }
265
+ }));
266
+ }
267
+
268
+ monthHTML(base) {
269
+ const first = new Date(base.getFullYear(), base.getMonth(), 1);
270
+ let lead = (first.getDay() - this.weekStart + 7) % 7;
271
+ const cells = [];
272
+ const cursor = new Date(first);
273
+ cursor.setDate(1 - lead);
274
+ for (let i = 0; i < 42; i++) {
275
+ cells.push(new Date(cursor));
276
+ cursor.setDate(cursor.getDate() + 1);
277
+ if (i >= 34 && cursor.getMonth() !== base.getMonth() && cursor.getDay() === this.weekStart) break;
278
+ }
279
+ const all = this.i18n.days;
280
+ const dow = all.slice(this.weekStart).concat(all.slice(0, this.weekStart));
281
+ const today = startOfDay(new Date());
282
+
283
+ return `<div class="datepicker-month">
284
+ <div class="datepicker-grid">${dow.map(d => `<div class="datepicker-dow">${d}</div>`).join('')}</div>
285
+ <div class="datepicker-grid">${cells.map(d => {
286
+ const cls = ['datepicker-day'];
287
+ if (d.getMonth() !== base.getMonth()) cls.push('is-outside');
288
+ if (sameDay(d, today)) cls.push('is-today');
289
+ if (this.blocked(d)) cls.push('is-blocked');
290
+ return `<button type="button" class="${cls.join(' ')}" data-date="${iso(d)}"
291
+ ${this.blocked(d) ? 'disabled' : ''}
292
+ aria-label="${this.i18n.full.format(d)}">${d.getDate()}</button>`;
293
+ }).join('')}</div>
294
+ </div>`;
295
+ }
296
+
297
+ render() {
298
+ const presets = this.showPresets ? `
299
+ <div class="datepicker-presets">
300
+ ${(this.mode === 'range'
301
+ ? [['Today', 0], ['Last 7 days', 7], ['Last 30 days', 30], ['Last 90 days', 90]]
302
+ : [['Today', 0], ['Yesterday', 1], ['A week ago', 7], ['A month ago', 30]]
303
+ ).map(([label, n]) =>
304
+ `<button type="button" class="datepicker-preset" data-preset="${n}">${label}</button>`).join('')}
305
+ </div>` : '';
306
+
307
+ const body = this.jump
308
+ ? `<div class="datepicker-jump">${this.i18n.monthsShort.map((m, i) =>
309
+ `<button type="button" data-month="${i}" aria-selected="${i === this.view.getMonth()}">${m}</button>`).join('')}</div>`
310
+ : `<div class="datepicker-months">${Array.from({ length: this.months },
311
+ (_, i) => this.monthHTML(addMonths(this.view, i))).join('')}</div>`;
312
+
313
+ const label = this.months > 1 && !this.jump
314
+ ? `${this.i18n.monthsShort[this.view.getMonth()]} – ${this.i18n.monthsShort[addMonths(this.view, this.months - 1).getMonth()]} ${this.view.getFullYear()}`
315
+ : `${this.i18n.months[this.view.getMonth()]} ${this.view.getFullYear()}`;
316
+
317
+ const f = d => formatDate(d, this.format, this.locale);
318
+ const readout = this.mode === 'range'
319
+ ? (this.start ? `${f(this.start)} – ${this.end ? f(this.end) : '…'}` : 'Pick a start date')
320
+ : (this.start ? f(this.start) : 'No date selected');
321
+
322
+ this.panel.innerHTML = `
323
+ <div class="datepicker-layout">
324
+ ${presets}
325
+ <div class="datepicker-main">
326
+ <div class="datepicker-head">
327
+ <button type="button" class="datepicker-nav" data-nav="-1" aria-label="Previous month">${icon('chevron-left', 'icon icon-sm')}</button>
328
+ <button type="button" class="datepicker-title">${label} ${icon('chevron-down', 'icon icon-sm')}</button>
329
+ <button type="button" class="datepicker-nav" data-nav="1" aria-label="Next month">${icon('chevron-right', 'icon icon-sm')}</button>
330
+ </div>
331
+ ${body}
332
+ </div>
333
+ </div>
334
+ <div class="datepicker-foot">
335
+ <span class="datepicker-readout">${readout}</span>
336
+ <button type="button" class="btn btn-sm" data-cancel>Cancel</button>
337
+ <button type="button" class="btn btn-sm btn-primary" data-apply>Apply</button>
338
+ </div>`;
339
+
340
+ this.paintRange();
341
+ this.root.classList.toggle('has-value', !!this.input.value);
342
+ }
343
+
344
+ paintRange() {
345
+ $$('.datepicker-day', this.panel).forEach(node => {
346
+ const d = parseISO(node.dataset.date);
347
+ const end = this.end || (this.mode === 'range' && this.start && this.hover > this.start ? this.hover : null);
348
+ node.classList.toggle('is-selected', sameDay(d, this.start) || sameDay(d, this.end));
349
+ node.classList.toggle('is-range-start', !!end && sameDay(d, this.start));
350
+ node.classList.toggle('is-range-end', !!end && sameDay(d, end));
351
+ node.classList.toggle('is-in-range', !!(end && this.start && d > this.start && d < end));
352
+ });
353
+ }
354
+ }
355
+
356
+ /* ======================================================================
357
+ Combobox / autocomplete
358
+ <div class="combo" data-deck-combo data-multi data-create
359
+ data-placeholder="Search repositories">
360
+ <select hidden multiple>… your real options …</select>
361
+ </div>
362
+ The <select> stays in the DOM and stays in sync, so normal form posts
363
+ work with no extra server-side handling.
364
+ ====================================================================== */
365
+
366
+ class Combo {
367
+ constructor(root) {
368
+ this.root = root;
369
+ this.select = $('select', root);
370
+ this.multi = root.hasAttribute('data-multi') || (this.select && this.select.multiple);
371
+ this.allowCreate = root.hasAttribute('data-create');
372
+ this.placeholder = root.dataset.placeholder || 'Search';
373
+ this.remote = root.dataset.url || null;
374
+ this.minChars = Number(root.dataset.minChars || 0);
375
+ this.open = false;
376
+ this.activeIndex = -1;
377
+ this.query = '';
378
+
379
+ this.options = this.select
380
+ ? $$('option', this.select).map(o => ({
381
+ value: o.value,
382
+ label: o.textContent.trim(),
383
+ sub: o.dataset.sub || '',
384
+ group: o.dataset.group || (o.parentElement.tagName === 'OPTGROUP' ? o.parentElement.label : ''),
385
+ disabled: o.disabled,
386
+ selected: o.selected
387
+ }))
388
+ : [];
389
+
390
+ if (this.select) this.select.hidden = true;
391
+ this.build();
392
+ this.bind();
393
+ this.renderTokens();
394
+ }
395
+
396
+ get selected() { return this.options.filter(o => o.selected); }
397
+
398
+ build() {
399
+ this.control = el('div', 'combo-control');
400
+ this.control.innerHTML = `
401
+ <span class="combo-tokens"></span>
402
+ <input class="combo-input" type="text" role="combobox" autocomplete="off"
403
+ aria-expanded="false" aria-autocomplete="list" placeholder="${this.placeholder}">
404
+ <button type="button" class="combo-toggle" tabindex="-1" aria-label="Toggle list">${icon('chevron-down', 'icon icon-sm')}</button>`;
405
+ this.list = el('div', 'combo-list');
406
+ this.list.setAttribute('role', 'listbox');
407
+ this.root.append(this.control, this.list);
408
+ this.input = $('.combo-input', this.control);
409
+ this.tokens = $('.combo-tokens', this.control);
410
+ }
411
+
412
+ bind() {
413
+ this.control.addEventListener('mousedown', e => {
414
+ if (e.target.closest('.combo-token > button')) return;
415
+ if (e.target !== this.input) { e.preventDefault(); this.input.focus(); }
416
+ if (e.target.closest('.combo-toggle')) this.toggle();
417
+ else if (!this.open) this.show();
418
+ });
419
+
420
+ this.input.addEventListener('input', () => {
421
+ this.query = this.input.value;
422
+ this.activeIndex = 0;
423
+ if (this.remote) this.fetch();
424
+ else this.render();
425
+ this.show();
426
+ });
427
+
428
+ this.input.addEventListener('keydown', e => this.onKey(e));
429
+ this.input.addEventListener('blur', () => setTimeout(() => this.hide(), 130));
430
+
431
+ this.list.addEventListener('mousedown', e => {
432
+ const opt = e.target.closest('.combo-option');
433
+ if (!opt) return;
434
+ e.preventDefault();
435
+ if (opt.dataset.create != null) this.create(this.query);
436
+ else this.choose(opt.dataset.value);
437
+ });
438
+ this.list.addEventListener('mousemove', e => {
439
+ const opt = e.target.closest('.combo-option');
440
+ if (!opt) return;
441
+ this.activeIndex = Number(opt.dataset.index);
442
+ this.paintActive();
443
+ });
444
+
445
+ this.tokens.addEventListener('click', e => {
446
+ const btn = e.target.closest('button');
447
+ if (!btn) return;
448
+ this.choose(btn.dataset.value, true);
449
+ });
450
+ }
451
+
452
+ matches() {
453
+ const q = this.query.trim().toLowerCase();
454
+ if (q.length < this.minChars) return [];
455
+ if (!q) return this.options;
456
+ return this.options.filter(o =>
457
+ o.label.toLowerCase().includes(q) || o.sub.toLowerCase().includes(q) || o.value.toLowerCase().includes(q));
458
+ }
459
+
460
+ render() {
461
+ const list = this.matches();
462
+ this.visible = list;
463
+
464
+ if (!list.length) {
465
+ this.list.innerHTML = this.allowCreate && this.query.trim()
466
+ ? `<button type="button" class="combo-option" data-create data-index="0">${icon('plus', 'icon icon-sm')}
467
+ <span class="combo-option-main"><span>Add “${escapeHTML(this.query.trim())}”</span></span></button>`
468
+ : `<div class="combo-empty">${icon('search', 'icon icon-sm')} No matches for “${escapeHTML(this.query)}”</div>`;
469
+ return;
470
+ }
471
+
472
+ let html = '';
473
+ let group = null;
474
+ list.forEach((o, i) => {
475
+ if (o.group && o.group !== group) {
476
+ group = o.group;
477
+ html += `<div class="combo-group">${escapeHTML(group)}</div>`;
478
+ }
479
+ html += `<button type="button" class="combo-option" role="option" data-index="${i}"
480
+ data-value="${escapeHTML(o.value)}"
481
+ aria-selected="${o.selected}" ${o.disabled ? 'aria-disabled="true"' : ''}>
482
+ <span class="combo-option-main">
483
+ <span>${highlight(o.label, this.query)}</span>
484
+ ${o.sub ? `<span class="combo-option-sub">${highlight(o.sub, this.query)}</span>` : ''}
485
+ </span>
486
+ ${this.multi ? `<span class="combo-check">${icon('check', 'icon icon-sm')}</span>` : ''}
487
+ </button>`;
488
+ });
489
+
490
+ if (this.allowCreate && this.query.trim() && !list.some(o => o.label.toLowerCase() === this.query.trim().toLowerCase())) {
491
+ html += `<div class="combo-create"><button type="button" class="combo-option" data-create data-index="${list.length}">
492
+ ${icon('plus', 'icon icon-sm')}<span class="combo-option-main"><span>Add “${escapeHTML(this.query.trim())}”</span></span></button></div>`;
493
+ }
494
+
495
+ html += `<div class="combo-hint"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>enter</kbd> select</span><span><kbd>esc</kbd> close</span></div>`;
496
+ this.list.innerHTML = html;
497
+ this.paintActive();
498
+ }
499
+
500
+ paintActive() {
501
+ const nodes = $$('.combo-option', this.list);
502
+ nodes.forEach(n => n.classList.remove('is-active'));
503
+ const node = nodes[this.activeIndex];
504
+ if (node) {
505
+ node.classList.add('is-active');
506
+ node.scrollIntoView({ block: 'nearest' });
507
+ }
508
+ }
509
+
510
+ onKey(e) {
511
+ const nodes = $$('.combo-option', this.list);
512
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
513
+ e.preventDefault();
514
+ if (!this.open) this.show();
515
+ const dir = e.key === 'ArrowDown' ? 1 : -1;
516
+ this.activeIndex = (this.activeIndex + dir + nodes.length) % (nodes.length || 1);
517
+ this.paintActive();
518
+ } else if (e.key === 'Enter') {
519
+ if (!this.open) return;
520
+ e.preventDefault();
521
+ const node = nodes[this.activeIndex];
522
+ if (!node) return;
523
+ if (node.dataset.create != null) this.create(this.query);
524
+ else this.choose(node.dataset.value);
525
+ } else if (e.key === 'Escape') {
526
+ if (this.open) { e.stopPropagation(); this.hide(); }
527
+ } else if (e.key === 'Backspace' && !this.input.value && this.multi) {
528
+ const last = this.selected.at(-1);
529
+ if (last) this.choose(last.value, true);
530
+ }
531
+ }
532
+
533
+ choose(value, forceOff) {
534
+ const opt = this.options.find(o => o.value === value);
535
+ if (!opt || opt.disabled) return;
536
+ if (this.multi) {
537
+ opt.selected = forceOff ? false : !opt.selected;
538
+ } else {
539
+ this.options.forEach(o => { o.selected = false; });
540
+ opt.selected = true;
541
+ this.input.value = opt.label;
542
+ this.query = '';
543
+ this.hide();
544
+ }
545
+ if (this.multi) { this.input.value = ''; this.query = ''; }
546
+ this.sync();
547
+ this.renderTokens();
548
+ this.render();
549
+ }
550
+
551
+ create(text) {
552
+ const label = text.trim();
553
+ if (!label) return;
554
+ const o = { value: label, label, sub: '', group: '', disabled: false, selected: false };
555
+ this.options.push(o);
556
+ if (this.select) this.select.append(new Option(label, label));
557
+ this.choose(label);
558
+ this.root.dispatchEvent(new CustomEvent('deck:create', { bubbles: true, detail: { value: label } }));
559
+ }
560
+
561
+ renderTokens() {
562
+ if (!this.multi) {
563
+ const one = this.selected[0];
564
+ if (one && document.activeElement !== this.input) this.input.value = one.label;
565
+ this.tokens.innerHTML = '';
566
+ return;
567
+ }
568
+ const max = Number(this.root.dataset.maxTokens || 4);
569
+ const sel = this.selected;
570
+ this.tokens.innerHTML = sel.slice(0, max).map(o =>
571
+ `<span class="combo-token"><span>${escapeHTML(o.label)}</span>
572
+ <button type="button" data-value="${escapeHTML(o.value)}" aria-label="Remove ${escapeHTML(o.label)}">${icon('x', 'icon icon-sm')}</button>
573
+ </span>`).join('')
574
+ + (sel.length > max ? `<span class="combo-count">+${sel.length - max}</span>` : '');
575
+ this.input.placeholder = sel.length ? '' : this.placeholder;
576
+ }
577
+
578
+ sync() {
579
+ if (!this.select) return;
580
+ $$('option', this.select).forEach(o => {
581
+ const match = this.options.find(x => x.value === o.value);
582
+ o.selected = !!(match && match.selected);
583
+ });
584
+ this.select.dispatchEvent(new Event('change', { bubbles: true }));
585
+ this.root.dispatchEvent(new CustomEvent('deck:change', {
586
+ bubbles: true,
587
+ detail: { values: this.selected.map(o => o.value) }
588
+ }));
589
+ }
590
+
591
+ async fetch() {
592
+ const q = this.query.trim();
593
+ if (q.length < this.minChars) return;
594
+ this.list.innerHTML = `<div class="combo-loading"><span class="spinner"></span> Searching</div>`;
595
+ clearTimeout(this._t);
596
+ this._t = setTimeout(async () => {
597
+ try {
598
+ const res = await window.fetch(this.remote + encodeURIComponent(q), { headers: { 'Accept': 'application/json' } });
599
+ const rows = await res.json();
600
+ const keep = this.selected;
601
+ this.options = rows.map(r => ({
602
+ value: String(r.value ?? r.id),
603
+ label: String(r.label ?? r.name),
604
+ sub: r.sub || '',
605
+ group: r.group || '',
606
+ disabled: !!r.disabled,
607
+ selected: keep.some(k => k.value === String(r.value ?? r.id))
608
+ }));
609
+ this.render();
610
+ } catch (err) {
611
+ this.list.innerHTML = `<div class="combo-empty">Could not load results. Try again.</div>`;
612
+ }
613
+ }, Number(this.root.dataset.debounce || 220));
614
+ }
615
+
616
+ show() {
617
+ if (this.open) return;
618
+ this.open = true;
619
+ this.root.classList.add('is-open');
620
+ this.input.setAttribute('aria-expanded', 'true');
621
+ this.render();
622
+ const r = this.control.getBoundingClientRect();
623
+ this.list.classList.toggle('is-above',
624
+ window.innerWidth > 480 && window.innerHeight - r.bottom < 240 && r.top > 240);
625
+ }
626
+
627
+ toggle() { this.open ? this.hide() : this.show(); }
628
+
629
+ hide() {
630
+ if (!this.open) return;
631
+ this.open = false;
632
+ this.root.classList.remove('is-open');
633
+ this.input.setAttribute('aria-expanded', 'false');
634
+ if (!this.multi) {
635
+ const one = this.selected[0];
636
+ this.input.value = one ? one.label : '';
637
+ } else {
638
+ this.input.value = '';
639
+ }
640
+ this.query = '';
641
+ }
642
+ }
643
+
644
+ function escapeHTML(s) {
645
+ return String(s).replace(/[&<>"']/g, c =>
646
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
647
+ }
648
+ function highlight(text, query) {
649
+ const q = query.trim();
650
+ const safe = escapeHTML(text);
651
+ if (!q) return safe;
652
+ const i = text.toLowerCase().indexOf(q.toLowerCase());
653
+ if (i < 0) return safe;
654
+ return escapeHTML(text.slice(0, i)) + '<mark>' + escapeHTML(text.slice(i, i + q.length)) + '</mark>' + escapeHTML(text.slice(i + q.length));
655
+ }
656
+
657
+ /* ======================================================================
658
+ Data grid: pin shadows, sort, select-all, column resize
659
+ <div class="dg-wrap" data-deck-grid> <table class="dg"> … </table> </div>
660
+ Sortable header: <th data-sort="num|text|date">
661
+ ====================================================================== */
662
+
663
+ class Grid {
664
+ constructor(wrap) {
665
+ this.wrap = wrap;
666
+ this.table = $('.dg', wrap);
667
+ if (!this.table) return;
668
+ this.measurePins();
669
+ this.wireSort();
670
+ this.wireSelect();
671
+ this.wireResize();
672
+ /* @container scroll-state() paints the pin shadows on its own; this
673
+ handler is only here for browsers that do not have it yet. */
674
+ if (!CSS.supports('container-type: scroll-state')) {
675
+ this.shadows();
676
+ wrap.addEventListener('scroll', () => this.shadows(), { passive: true });
677
+ }
678
+ new ResizeObserver(() => this.measurePins()).observe(this.table);
679
+ }
680
+
681
+ measurePins() {
682
+ const first = $('thead .dg-pin-start', this.table);
683
+ if (first) this.wrap.style.setProperty('--dg-pin-start', first.offsetWidth + 'px');
684
+ }
685
+
686
+ shadows() {
687
+ const w = this.wrap;
688
+ const x = Math.abs(w.scrollLeft); // an RTL scroller counts down from 0
689
+ w.classList.toggle('is-scrolled-x', x > 0);
690
+ w.classList.toggle('is-scrolled-end', x + w.clientWidth < w.scrollWidth - 1);
691
+ }
692
+
693
+ wireSort() {
694
+ $$('th[data-sort]', this.table).forEach((th, col) => {
695
+ if ($('.dg-sort', th)) return;
696
+ const label = th.textContent.trim();
697
+ th.innerHTML = `<button type="button" class="dg-sort">${escapeHTML(label)}
698
+ <svg class="icon dg-sort-icon" aria-hidden="true"><use href="${Deck.iconSprite}#chevron-up"></use></svg></button>`;
699
+ $('.dg-sort', th).addEventListener('click', () => this.sort(th));
700
+ });
701
+ }
702
+
703
+ sort(th) {
704
+ const head = $$('th', this.table.tHead.rows[0]);
705
+ const index = head.indexOf(th);
706
+ const kind = th.dataset.sort;
707
+ const dir = th.getAttribute('aria-sort') === 'ascending' ? 'descending' : 'ascending';
708
+ head.forEach(h => h.removeAttribute('aria-sort'));
709
+ th.setAttribute('aria-sort', dir);
710
+
711
+ const body = this.table.tBodies[0];
712
+ const rows = Array.from(body.rows);
713
+ const key = row => {
714
+ const cell = row.cells[index];
715
+ const raw = cell?.dataset.value ?? cell?.textContent.trim() ?? '';
716
+ if (kind === 'num') return parseFloat(raw.replace(/[^0-9.\-]/g, '')) || 0;
717
+ if (kind === 'date') return new Date(raw).getTime() || 0;
718
+ return raw.toLowerCase();
719
+ };
720
+ rows.sort((a, b) => {
721
+ const x = key(a), y = key(b);
722
+ return (x < y ? -1 : x > y ? 1 : 0) * (dir === 'ascending' ? 1 : -1);
723
+ });
724
+ rows.forEach(r => body.append(r));
725
+ this.wrap.dispatchEvent(new CustomEvent('deck:sort', { bubbles: true, detail: { index, dir, kind } }));
726
+ }
727
+
728
+ wireSelect() {
729
+ const all = $('thead .dg-check input', this.table);
730
+ const boxes = () => $$('tbody .dg-check input', this.table);
731
+ const update = () => {
732
+ const list = boxes();
733
+ const on = list.filter(b => b.checked);
734
+ list.forEach(b => b.closest('tr').setAttribute('aria-selected', b.checked));
735
+ if (all) {
736
+ all.checked = on.length > 0 && on.length === list.length;
737
+ all.indeterminate = on.length > 0 && on.length < list.length;
738
+ }
739
+ const bar = $('[data-dg-selection]', this.wrap.parentElement || document);
740
+ if (bar) {
741
+ bar.hidden = on.length === 0;
742
+ const count = $('[data-dg-count]', bar);
743
+ if (count) count.textContent = on.length;
744
+ }
745
+ this.wrap.dispatchEvent(new CustomEvent('deck:select', { bubbles: true, detail: { count: on.length } }));
746
+ };
747
+ if (all) all.addEventListener('change', () => {
748
+ boxes().forEach(b => { b.checked = all.checked; });
749
+ update();
750
+ });
751
+ this.table.addEventListener('change', e => {
752
+ if (e.target.closest('.dg-check')) update();
753
+ });
754
+ update();
755
+ }
756
+
757
+ wireResize() {
758
+ $$('th[data-resize]', this.table).forEach(th => {
759
+ if ($('.dg-resize', th)) return;
760
+ th.style.position = 'sticky';
761
+ const grip = el('div', 'dg-resize');
762
+ th.append(grip);
763
+ let x0 = 0, w0 = 0;
764
+ const move = e => {
765
+ const x = (e.touches ? e.touches[0].clientX : e.clientX);
766
+ th.style.width = Math.max(64, w0 + (x - x0)) + 'px';
767
+ this.measurePins();
768
+ };
769
+ const stop = () => {
770
+ this.wrap.classList.remove('is-resizing');
771
+ document.removeEventListener('pointermove', move);
772
+ document.removeEventListener('pointerup', stop);
773
+ };
774
+ grip.addEventListener('pointerdown', e => {
775
+ e.preventDefault();
776
+ x0 = e.clientX;
777
+ w0 = th.offsetWidth;
778
+ this.wrap.classList.add('is-resizing');
779
+ document.addEventListener('pointermove', move);
780
+ document.addEventListener('pointerup', stop);
781
+ });
782
+ });
783
+ }
784
+ }
785
+
786
+ /* ======================================================================
787
+ Toast queue
788
+ Deck.toast('Order #1042 shipped')
789
+ Deck.toast({ title: 'Project archived', text: 'You can undo this.',
790
+ kind: 'warn', duration: 8000,
791
+ actions: [{ label: 'Undo', onClick: fn }] })
792
+ const t = Deck.toast({ kind: 'loading', title: 'Submitting…', duration: 0 });
793
+ t.update({ kind: 'good', title: 'Submitted', duration: 4000 });
794
+ ====================================================================== */
795
+
796
+ const Toaster = {
797
+ region: null,
798
+ items: [],
799
+ max: 3,
800
+ stacked: true,
801
+
802
+ mount() {
803
+ if (this.region) return this.region;
804
+ this.region = $('.toast-region') || el('div', 'toast-region');
805
+ if (this.stacked) this.region.classList.add('toast-region-stacked');
806
+ this.region.setAttribute('role', 'region');
807
+ this.region.setAttribute('aria-label', 'Notifications');
808
+ if (!this.region.isConnected) document.body.append(this.region);
809
+ return this.region;
810
+ },
811
+
812
+ icons: { good: 'check-circle', bad: 'x-circle', warn: 'alert-triangle', info: 'info', loading: null },
813
+
814
+ push(opts) {
815
+ if (typeof opts === 'string') opts = { title: opts };
816
+ const o = Object.assign({ kind: '', duration: 5000, actions: [], dismissible: true }, opts);
817
+ const region = this.mount();
818
+
819
+ const node = el('div', 'toast' + (o.kind ? ' toast-' + o.kind : ''));
820
+ node.setAttribute('role', o.kind === 'bad' ? 'alert' : 'status');
821
+ node.style.setProperty('--toast-duration', o.duration + 'ms');
822
+
823
+ const item = {
824
+ node,
825
+ opts: o,
826
+ dismiss: () => this.dismiss(item),
827
+ update: patch => this.update(item, patch)
828
+ };
829
+
830
+ this.paintBody(item);
831
+ this.wireSwipe(item);
832
+
833
+ region.prepend(node);
834
+ this.items.unshift(item);
835
+ requestAnimationFrame(() => {
836
+ node.style.setProperty('--toast-h', node.offsetHeight + 'px');
837
+ this.reflow();
838
+ });
839
+
840
+ if (o.duration > 0) this.arm(item);
841
+ while (this.items.length > 8) this.dismiss(this.items.at(-1));
842
+ return item;
843
+ },
844
+
845
+ paintBody(item) {
846
+ const o = item.opts;
847
+ const glyph = o.kind === 'loading'
848
+ ? '<span class="spinner toast-icon"></span>'
849
+ : (this.icons[o.kind] ? icon(this.icons[o.kind], 'icon toast-icon') : '');
850
+ item.node.innerHTML = `
851
+ ${glyph}
852
+ <div class="toast-main">
853
+ ${o.title ? `<div class="toast-title">${escapeHTML(o.title)}</div>` : ''}
854
+ ${o.text ? `<div class="toast-text">${escapeHTML(o.text)}</div>` : ''}
855
+ ${o.actions.length ? `<div class="toast-actions">${o.actions.map((a, i) =>
856
+ `<button type="button" class="toast-action" data-action="${i}">${escapeHTML(a.label)}</button>`).join('')}</div>` : ''}
857
+ </div>
858
+ ${o.dismissible ? `<button type="button" class="toast-close" aria-label="Dismiss">${icon('x', 'icon icon-sm')}</button>` : ''}
859
+ ${o.duration > 0 ? '<span class="toast-timer"></span>' : ''}`;
860
+
861
+ $('.toast-close', item.node)?.addEventListener('click', () => this.dismiss(item));
862
+ $$('.toast-action', item.node).forEach(btn => {
863
+ btn.addEventListener('click', () => {
864
+ const a = o.actions[Number(btn.dataset.action)];
865
+ a?.onClick?.(item);
866
+ if (a?.close !== false) this.dismiss(item);
867
+ });
868
+ });
869
+ },
870
+
871
+ update(item, patch) {
872
+ Object.assign(item.opts, patch);
873
+ item.node.className = 'toast' + (item.opts.kind ? ' toast-' + item.opts.kind : '');
874
+ item.node.style.setProperty('--toast-duration', item.opts.duration + 'ms');
875
+ this.paintBody(item);
876
+ this.wireSwipe(item);
877
+ clearTimeout(item.timer);
878
+ if (item.opts.duration > 0) this.arm(item);
879
+ return item;
880
+ },
881
+
882
+ arm(item) {
883
+ clearTimeout(item.timer);
884
+ item.timer = setTimeout(() => this.dismiss(item), item.opts.duration);
885
+ const region = this.region;
886
+ const pause = () => clearTimeout(item.timer);
887
+ const resume = () => { if (item.opts.duration > 0) this.arm(item); };
888
+ region.addEventListener('mouseenter', pause, { once: true });
889
+ region.addEventListener('mouseleave', resume, { once: true });
890
+ },
891
+
892
+ dismiss(item) {
893
+ if (!item || item.leaving) return;
894
+ item.leaving = true;
895
+ clearTimeout(item.timer);
896
+ item.node.classList.add('is-leaving');
897
+ item.node.addEventListener('transitionend', () => item.node.remove(), { once: true });
898
+ setTimeout(() => item.node.remove(), 500);
899
+ this.items = this.items.filter(i => i !== item);
900
+ this.reflow();
901
+ item.opts.onDismiss?.(item);
902
+ },
903
+
904
+ clear() { [...this.items].forEach(i => this.dismiss(i)); },
905
+
906
+ reflow() {
907
+ this.items.forEach((item, i) =>
908
+ item.node.setAttribute('aria-hidden', i >= this.max ? 'true' : 'false'));
909
+ },
910
+
911
+ wireSwipe(item) {
912
+ const node = item.node;
913
+ let x0 = null, dx = 0;
914
+ node.addEventListener('pointerdown', e => {
915
+ if (e.target.closest('button')) return;
916
+ x0 = e.clientX;
917
+ node.classList.add('is-dragging');
918
+ node.setPointerCapture(e.pointerId);
919
+ });
920
+ node.addEventListener('pointermove', e => {
921
+ if (x0 === null) return;
922
+ dx = e.clientX - x0;
923
+ node.style.translate = `${dx}px 0`;
924
+ node.style.opacity = String(Math.max(0, 1 - Math.abs(dx) / 220));
925
+ });
926
+ const end = () => {
927
+ if (x0 === null) return;
928
+ node.classList.remove('is-dragging');
929
+ if (Math.abs(dx) > 90) {
930
+ node.style.setProperty('--toast-exit-x', (dx > 0 ? 400 : -400) + 'px');
931
+ this.dismiss(item);
932
+ } else {
933
+ node.style.translate = '';
934
+ node.style.opacity = '';
935
+ }
936
+ x0 = null; dx = 0;
937
+ };
938
+ node.addEventListener('pointerup', end);
939
+ node.addEventListener('pointercancel', end);
940
+ }
941
+ };
942
+
943
+ /* ======================================================================
944
+ Motion
945
+ Ripples, a reveal fallback for browsers without scroll-driven animation,
946
+ stagger indexes past the twelfth child, and view-transition helpers.
947
+ ====================================================================== */
948
+
949
+ const reduced = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
950
+
951
+ function wireRipple(root) {
952
+ $$('.ripple', root).forEach(node => {
953
+ if (node.dataset.deckWired) return;
954
+ node.dataset.deckWired = '1';
955
+ node.addEventListener('pointerdown', e => {
956
+ if (reduced()) return;
957
+ const r = node.getBoundingClientRect();
958
+ const ink = el('span', 'ripple-ink');
959
+ ink.style.setProperty('--rx', (e.clientX - r.left) + 'px');
960
+ ink.style.setProperty('--ry', (e.clientY - r.top) + 'px');
961
+ node.append(ink);
962
+ ink.addEventListener('animationend', () => ink.remove(), { once: true });
963
+ setTimeout(() => ink.remove(), 900);
964
+ });
965
+ });
966
+ }
967
+
968
+ function wireReveal(root) {
969
+ if (CSS.supports('animation-timeline: view()')) return; // CSS handles it
970
+ const targets = $$('.reveal, .reveal-fade, .reveal-pop', root)
971
+ .filter(n => !n.dataset.deckWired);
972
+ if (!targets.length) return;
973
+ if (reduced()) return targets.forEach(n => { n.style.opacity = '1'; });
974
+
975
+ const io = new IntersectionObserver(entries => {
976
+ entries.forEach(entry => {
977
+ if (!entry.isIntersecting) return;
978
+ entry.target.classList.add('is-revealed');
979
+ io.unobserve(entry.target);
980
+ });
981
+ }, { rootMargin: '0px 0px -8% 0px', threshold: 0.08 });
982
+
983
+ targets.forEach(n => { n.dataset.deckWired = '1'; io.observe(n); });
984
+ }
985
+
986
+ function wireValidationShake(root) {
987
+ $$('.field', root).forEach(field => {
988
+ if (field.dataset.deckWired) return;
989
+ field.dataset.deckWired = '1';
990
+ field.addEventListener('animationend', () => field.classList.add('was-shaken'));
991
+ $$('input, textarea, select', field).forEach(input => {
992
+ input.addEventListener('input', () => {
993
+ if (input.checkValidity()) {
994
+ field.classList.remove('is-invalid', 'was-shaken');
995
+ }
996
+ });
997
+ });
998
+ });
999
+ }
1000
+
1001
+ function wireViewTransitions() {
1002
+ if (!('startViewTransition' in document)) return;
1003
+ // Give the browser a direction hint so back navigations slide the other way
1004
+ window.addEventListener('popstate', () => {
1005
+ document.documentElement.dataset.vt = 'back';
1006
+ });
1007
+ window.addEventListener('pageswap', () => {
1008
+ if (!performance.getEntriesByType('navigation')[0]) return;
1009
+ delete document.documentElement.dataset.vt;
1010
+ });
1011
+ }
1012
+
1013
+ function wireTick(root) {
1014
+ $$('[data-deck-tick]', root).forEach(node => {
1015
+ if (node.dataset.deckWired) return;
1016
+ node.dataset.deckWired = '1';
1017
+ node.classList.add('tick');
1018
+ let last = parseFloat(node.textContent.replace(/[^0-9.\-]/g, '')) || 0;
1019
+ new MutationObserver(() => {
1020
+ const now = parseFloat(node.textContent.replace(/[^0-9.\-]/g, '')) || 0;
1021
+ if (now === last) return;
1022
+ node.classList.remove('is-up', 'is-down');
1023
+ void node.offsetWidth; // restart the animation
1024
+ node.classList.add(now > last ? 'is-up' : 'is-down');
1025
+ last = now;
1026
+ }).observe(node, { childList: true, characterData: true, subtree: true });
1027
+ });
1028
+ }
1029
+
1030
+ /* ======================================================================
1031
+ Space: tilt, flip, coverflow
1032
+ ====================================================================== */
1033
+
1034
+ function wireTilt(root) {
1035
+ if (!window.matchMedia('(hover: hover)').matches) return;
1036
+ $$('.tilt', root).forEach(node => {
1037
+ if (node.dataset.deckWired) return;
1038
+ node.dataset.deckWired = '1';
1039
+ const max = Number(node.dataset.tilt || 9);
1040
+
1041
+ const move = e => {
1042
+ if (reduced()) return;
1043
+ const r = node.getBoundingClientRect();
1044
+ const px = (e.clientX - r.left) / r.width - 0.5;
1045
+ const py = (e.clientY - r.top) / r.height - 0.5;
1046
+ // one rotation about a computed axis, so it composes as a single value
1047
+ const amount = Math.hypot(px, py) * max * 2;
1048
+ node.style.setProperty('--tilt-axis', `${py.toFixed(3)} ${(-px).toFixed(3)} 0`);
1049
+ node.style.setProperty('--tilt-amount', amount.toFixed(2) + 'deg');
1050
+ node.style.setProperty('--gx', ((px + 0.5) * 100).toFixed(1) + '%');
1051
+ node.style.setProperty('--gy', ((py + 0.5) * 100).toFixed(1) + '%');
1052
+ };
1053
+ const rest = () => {
1054
+ node.classList.remove('is-tilting');
1055
+ node.style.setProperty('--tilt-amount', '0deg');
1056
+ };
1057
+ node.addEventListener('pointerenter', () => node.classList.add('is-tilting'));
1058
+ node.addEventListener('pointermove', move);
1059
+ node.addEventListener('pointerleave', rest);
1060
+ node.addEventListener('blur', rest);
1061
+ });
1062
+ }
1063
+
1064
+ function wireFlip(root) {
1065
+ $$('[data-deck-flip]', root).forEach(trigger => {
1066
+ if (trigger.dataset.deckWired) return;
1067
+ trigger.dataset.deckWired = '1';
1068
+ const target = trigger.dataset.deckFlip
1069
+ ? $(trigger.dataset.deckFlip)
1070
+ : trigger.closest('.flip, .flip-x');
1071
+ if (!target) return;
1072
+ trigger.addEventListener('click', () => {
1073
+ const on = target.classList.toggle('is-flipped');
1074
+ trigger.setAttribute('aria-pressed', String(on));
1075
+ // the hidden face should not be reachable by keyboard
1076
+ $$('.flip-front, .flip-back', target).forEach((face, i) => {
1077
+ face.inert = on ? i === 0 : i === 1;
1078
+ });
1079
+ });
1080
+ });
1081
+ }
1082
+
1083
+ function wireCoverflow(root) {
1084
+ $$('.coverflow', root).forEach(strip => {
1085
+ if (strip.dataset.deckWired) return;
1086
+ strip.dataset.deckWired = '1';
1087
+ const items = Array.from(strip.children);
1088
+ const paint = () => {
1089
+ const mid = strip.scrollLeft + strip.clientWidth / 2;
1090
+ const rtl = getComputedStyle(strip).direction === 'rtl';
1091
+ items.forEach(item => {
1092
+ const center = item.offsetLeft + item.offsetWidth / 2;
1093
+ const d = center - mid;
1094
+ item.classList.toggle('is-front', Math.abs(d) < item.offsetWidth / 2);
1095
+ item.classList.toggle('is-behind-start', d < -item.offsetWidth / 2 !== rtl && Math.abs(d) >= item.offsetWidth / 2);
1096
+ item.classList.toggle('is-behind-end', d > item.offsetWidth / 2 !== rtl && Math.abs(d) >= item.offsetWidth / 2);
1097
+ });
1098
+ };
1099
+ strip.addEventListener('scroll', () => requestAnimationFrame(paint), { passive: true });
1100
+ new ResizeObserver(paint).observe(strip);
1101
+ paint();
1102
+ });
1103
+ }
1104
+
1105
+ function wireStack(root) {
1106
+ $$('.pile', root).forEach(stack => {
1107
+ if (stack.dataset.deckWired) return;
1108
+ stack.dataset.deckWired = '1';
1109
+ stack.addEventListener('deck:advance', () => {
1110
+ const top = stack.querySelector(':scope > *:not(.is-dismissed)');
1111
+ if (!top) return;
1112
+ top.classList.add('is-dismissed');
1113
+ /* Once it has flown off, move it to the end of the pile. It stays in
1114
+ the DOM, but out of the way of everyone else's sibling-index(). */
1115
+ setTimeout(() => stack.append(top), 400);
1116
+ });
1117
+ });
1118
+ }
1119
+
1120
+ /* ======================================================================
1121
+ Small conveniences that pair with the CSS
1122
+ ====================================================================== */
1123
+
1124
+ function wireGroups(root) {
1125
+ $$('.segmented, .tabs', root).forEach(group => {
1126
+ if (group.dataset.deckWired) return;
1127
+ group.dataset.deckWired = '1';
1128
+ group.addEventListener('click', e => {
1129
+ const item = e.target.closest('[aria-selected]');
1130
+ if (!item) return;
1131
+ $$('[aria-selected]', group).forEach(b => b.setAttribute('aria-selected', 'false'));
1132
+ item.setAttribute('aria-selected', 'true');
1133
+ group.dispatchEvent(new CustomEvent('deck:change', { bubbles: true, detail: { value: item.dataset.value ?? item.textContent.trim() } }));
1134
+ });
1135
+ });
1136
+ }
1137
+
1138
+ /* Anchor positioning needs a unique anchor-name per pair. Rather than make
1139
+ the author invent one, pair up every popovertarget with its panel and
1140
+ generate it. Where the browser lacks anchor positioning this is inert and
1141
+ the Floating UI adapter or Deck's own placement takes over. */
1142
+ let anchorSeq = 0;
1143
+ function wireAnchors(root) {
1144
+ if (!CSS.supports('anchor-name: --a')) return;
1145
+ $$('[popovertarget]', root).forEach(trigger => {
1146
+ const panel = document.getElementById(trigger.getAttribute('popovertarget'));
1147
+ if (!panel || trigger.dataset.deckAnchored) return;
1148
+ trigger.dataset.deckAnchored = '1';
1149
+ const name = panel.style.getPropertyValue('--anchor') || `--dk-a${++anchorSeq}`;
1150
+ trigger.classList.add('anchor');
1151
+ trigger.style.setProperty('--anchor', name);
1152
+ panel.style.setProperty('--anchor', name);
1153
+ trigger.setAttribute('data-deck-anchor', '#' + panel.id);
1154
+ });
1155
+ }
1156
+
1157
+ function wireTheme(root) {
1158
+ $$('[data-deck-theme]', root).forEach(btn => {
1159
+ if (btn.dataset.deckWired) return;
1160
+ btn.dataset.deckWired = '1';
1161
+ btn.addEventListener('click', () => {
1162
+ /* The theme in effect, not just the attribute. With no saved choice there
1163
+ is no attribute and the OS decides, so reading the attribute alone made
1164
+ the first press on a dark machine set "dark" on a page already dark. */
1165
+ const set = document.documentElement.getAttribute('data-theme');
1166
+ const dark = set ? set === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
1167
+ Deck.theme(dark ? 'light' : 'dark');
1168
+ });
1169
+ });
1170
+ }
1171
+
1172
+ /* Fallback for browsers with no scroll-driven animations. The CSS above
1173
+ handles show and hide everywhere else, so this only runs where
1174
+ animation-timeline is missing -- and never as a scroll handler. A sentinel
1175
+ the height of the reveal threshold sits at the top of the document; when it
1176
+ leaves the viewport, we are past it. */
1177
+ let bttObserver = null;
1178
+
1179
+ function wireBackToTop(root) {
1180
+ if (CSS.supports('animation-timeline: scroll()')) return; // CSS handles it
1181
+ if (reduced()) return; // CSS leaves the link visible; nothing to toggle
1182
+ const links = $$('.back-to-top', root).filter(n => !n.dataset.deckBtt);
1183
+ if (!links.length) return;
1184
+ /* data-deck-btt is both the once-guard and the CSS hook: without it the
1185
+ link stays visible, so a browser that runs no JS still gets a working
1186
+ one rather than an invisible one. */
1187
+ links.forEach(n => { n.dataset.deckBtt = '1'; });
1188
+
1189
+ let sentinel = document.getElementById('dk-btt-sentinel');
1190
+ if (!sentinel) {
1191
+ sentinel = document.createElement('div');
1192
+ sentinel.id = 'dk-btt-sentinel';
1193
+ sentinel.setAttribute('aria-hidden', 'true');
1194
+ sentinel.style.cssText =
1195
+ 'position:absolute;inset-block-start:0;inset-inline-start:0;' +
1196
+ 'inline-size:1px;block-size:400px;visibility:hidden;pointer-events:none;';
1197
+ document.body.prepend(sentinel);
1198
+ }
1199
+
1200
+ if (bttObserver) bttObserver.disconnect();
1201
+ bttObserver = new IntersectionObserver(entries => {
1202
+ const past = !entries[entries.length - 1].isIntersecting;
1203
+ $$('.back-to-top[data-deck-btt]').forEach(n => n.classList.toggle('is-visible', past));
1204
+ });
1205
+ bttObserver.observe(sentinel);
1206
+ }
1207
+
1208
+ /* ====================================================================== */
1209
+
1210
+ const Deck = {
1211
+ iconSprite: spriteURL,
1212
+ version: '0.1',
1213
+
1214
+ init(root = document) {
1215
+ $$('[data-deck-datepicker]', root).forEach(n => { if (!n._deck) n._deck = new DatePicker(n); });
1216
+ $$('[data-deck-combo]', root).forEach(n => { if (!n._deck) n._deck = new Combo(n); });
1217
+ $$('[data-deck-grid]', root).forEach(n => { if (!n._deck) n._deck = new Grid(n); });
1218
+ wireGroups(root);
1219
+ wireAnchors(root);
1220
+ wireTheme(root);
1221
+ wireBackToTop(root);
1222
+ wireRipple(root);
1223
+ wireReveal(root);
1224
+ wireValidationShake(root);
1225
+ wireTick(root);
1226
+ wireTilt(root);
1227
+ wireFlip(root);
1228
+ wireCoverflow(root);
1229
+ wireStack(root);
1230
+ return Deck;
1231
+ },
1232
+
1233
+ toast(opts) { return Toaster.push(opts); },
1234
+ toasts: Toaster,
1235
+
1236
+ theme(mode) {
1237
+ if (!mode) return document.documentElement.getAttribute('data-theme') || 'auto';
1238
+ document.documentElement.setAttribute('data-theme', mode);
1239
+ try { localStorage.setItem('deck-theme', mode); } catch (e) {}
1240
+ return mode;
1241
+ },
1242
+
1243
+ hue(n) { document.documentElement.style.setProperty('--hue-brand', n); },
1244
+
1245
+ /* Locale-aware month, weekday, and first-day-of-week data from Intl. */
1246
+ locale(tag) { return names(tag); },
1247
+
1248
+ /* Wrap a DOM change so the browser tweens between before and after.
1249
+ Falls back to running the change immediately where unsupported or where
1250
+ the person asked for reduced motion.
1251
+ Deck.transition(() => row.remove());
1252
+ Deck.transition(() => list.prepend(newRow), { name: 'row-42' }); */
1253
+ transition(update, opts = {}) {
1254
+ if (reduced() || !document.startViewTransition) {
1255
+ const out = update();
1256
+ return { finished: Promise.resolve(out), ready: Promise.resolve() };
1257
+ }
1258
+ if (opts.direction) document.documentElement.dataset.vt = opts.direction;
1259
+ const vt = document.startViewTransition(update);
1260
+ vt.finished.finally(() => { delete document.documentElement.dataset.vt; });
1261
+ return vt;
1262
+ },
1263
+
1264
+ /* Play a one-shot animation class and clean up after itself.
1265
+ Deck.play(field, 'shake');
1266
+ await Deck.play(card, 'flash-good'); */
1267
+ play(node, className) {
1268
+ if (!node) return Promise.resolve();
1269
+ node.classList.remove(className);
1270
+ void node.offsetWidth;
1271
+ node.classList.add(className, 'is-animating');
1272
+ return new Promise(resolve => {
1273
+ const done = () => {
1274
+ node.classList.remove(className, 'is-animating');
1275
+ resolve(node);
1276
+ };
1277
+ node.addEventListener('animationend', done, { once: true });
1278
+ setTimeout(done, 2000);
1279
+ });
1280
+ },
1281
+
1282
+ /* Height:auto expand/collapse with no measuring.
1283
+ Deck.toggle(panel); */
1284
+ toggle(node, force) {
1285
+ const open = force ?? !node.classList.contains('is-open');
1286
+ node.classList.add('expand');
1287
+ node.classList.toggle('is-open', open);
1288
+ return open;
1289
+ },
1290
+
1291
+ /* Read or set text direction. Deck is written in logical properties, so
1292
+ this is all that a full RTL flip requires.
1293
+ Deck.dir('rtl'); */
1294
+ dir(value) {
1295
+ if (!value) return document.documentElement.getAttribute('dir') || 'ltr';
1296
+ document.documentElement.setAttribute('dir', value);
1297
+ try { localStorage.setItem('deck-dir', value); } catch (e) {}
1298
+ return value;
1299
+ },
1300
+
1301
+ /* Flip a card, advance a depth stack, rotate a cube. */
1302
+ flip(node, force) {
1303
+ const on = node.classList.toggle('is-flipped', force);
1304
+ return on;
1305
+ },
1306
+ advance(stack) { stack.dispatchEvent(new CustomEvent('deck:advance')); },
1307
+ face(cube, name) { cube.dataset.face = name; return name; },
1308
+
1309
+ reduced
1310
+ };
1311
+
1312
+ wireViewTransitions();
1313
+
1314
+ document.addEventListener('DOMContentLoaded', () => {
1315
+ try {
1316
+ const saved = localStorage.getItem('deck-theme');
1317
+ if (saved) document.documentElement.setAttribute('data-theme', saved);
1318
+ const dir = localStorage.getItem('deck-dir');
1319
+ if (dir) document.documentElement.setAttribute('dir', dir);
1320
+ } catch (e) {}
1321
+ Deck.init();
1322
+ });
1323
+
1324
+ return Deck;
1325
+ })();
1326
+
1327
+ /* Expose on the global so the optional scripts and page code can find Deck
1328
+ whether it was loaded as a classic script, a module, or from a bundler. */
1329
+ if (typeof window !== 'undefined') window.Deck = Deck;
1330
+ if (typeof globalThis !== 'undefined') globalThis.Deck = Deck;
1331
+ if (typeof module !== 'undefined' && module.exports) module.exports = Deck;