@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.
- package/LICENSE +21 -0
- package/README.md +1327 -0
- package/bin/deck.mjs +219 -0
- package/build.mjs +338 -0
- package/dist/api-buckets.json +10486 -0
- package/dist/api.json +57896 -0
- package/dist/brand/deck-apple-touch-icon.png +0 -0
- package/dist/brand/deck-logo-dark.svg +1 -0
- package/dist/brand/deck-logo-light.svg +1 -0
- package/dist/brand/deck-logo-stacked.svg +1 -0
- package/dist/brand/deck-logo.svg +1 -0
- package/dist/brand/deck-mark.svg +7 -0
- package/dist/brand/deck-og.png +0 -0
- package/dist/collisions.json +223 -0
- package/dist/deck-adapters.js +482 -0
- package/dist/deck-adapters.min.js +23 -0
- package/dist/deck-extras.js +823 -0
- package/dist/deck-extras.min.js +5 -0
- package/dist/deck-icons.svg +184 -0
- package/dist/deck.bundle.js +2638 -0
- package/dist/deck.bundle.min.js +86 -0
- package/dist/deck.css +7904 -0
- package/dist/deck.esm.js +2644 -0
- package/dist/deck.js +1331 -0
- package/dist/deck.min.css +32 -0
- package/dist/deck.min.js +60 -0
- package/dist/layers/anchor.css +192 -0
- package/dist/layers/buttons.css +210 -0
- package/dist/layers/charts.css +337 -0
- package/dist/layers/combobox.css +248 -0
- package/dist/layers/components.css +868 -0
- package/dist/layers/container.css +199 -0
- package/dist/layers/datagrid.css +335 -0
- package/dist/layers/datepicker.css +305 -0
- package/dist/layers/forms.css +401 -0
- package/dist/layers/gradients.css +302 -0
- package/dist/layers/inputs.css +522 -0
- package/dist/layers/layers.css +87 -0
- package/dist/layers/layout.css +235 -0
- package/dist/layers/logical.css +226 -0
- package/dist/layers/media.css +400 -0
- package/dist/layers/mobile.css +245 -0
- package/dist/layers/motion.css +379 -0
- package/dist/layers/nav.css +771 -0
- package/dist/layers/perf.css +234 -0
- package/dist/layers/print.css +198 -0
- package/dist/layers/reset.css +103 -0
- package/dist/layers/space3d.css +280 -0
- package/dist/layers/toasts.css +201 -0
- package/dist/layers/tokens.css +229 -0
- package/dist/layers/type.css +114 -0
- package/dist/layers/utilities.css +241 -0
- package/dist/sizes.json +52 -0
- package/dist/usage.json +6340 -0
- package/package.json +94 -0
- package/src/00-layers.css +87 -0
- package/src/01-tokens.css +229 -0
- package/src/02-reset.css +103 -0
- package/src/03-type.css +114 -0
- package/src/04-layout.css +235 -0
- package/src/05-buttons.css +210 -0
- package/src/06-forms.css +401 -0
- package/src/07-components.css +868 -0
- package/src/08-mobile.css +245 -0
- package/src/09-utilities.css +241 -0
- package/src/10-datepicker.css +305 -0
- package/src/11-combobox.css +248 -0
- package/src/12-datagrid.css +335 -0
- package/src/13-toasts.css +201 -0
- package/src/14-charts.css +337 -0
- package/src/16-motion.css +379 -0
- package/src/18-container.css +199 -0
- package/src/19-logical.css +226 -0
- package/src/20-gradients.css +302 -0
- package/src/21-space3d.css +280 -0
- package/src/22-nav.css +771 -0
- package/src/23-inputs.css +522 -0
- package/src/24-media.css +400 -0
- package/src/25-anchor.css +192 -0
- package/src/26-perf.css +234 -0
- package/src/99-print.css +198 -0
- package/src/brand/deck-apple-touch-icon.png +0 -0
- package/src/brand/deck-logo-dark.svg +1 -0
- package/src/brand/deck-logo-light.svg +1 -0
- package/src/brand/deck-logo-stacked.svg +1 -0
- package/src/brand/deck-logo.svg +1 -0
- package/src/brand/deck-mark.svg +7 -0
- package/src/brand/deck-og.png +0 -0
- package/src/brand/sources.json +7 -0
- package/src/deck-icons.svg +184 -0
- package/src/js/deck-adapters.js +482 -0
- package/src/js/deck-extras.js +823 -0
- 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
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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;
|