@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
|
@@ -0,0 +1,2638 @@
|
|
|
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;
|
|
1332
|
+
|
|
1333
|
+
/*! ==========================================================================
|
|
1334
|
+
* deck-extras.js v0.1 — behaviour for the extended component set
|
|
1335
|
+
* Loads after deck.js and registers itself with Deck.init.
|
|
1336
|
+
* No dependencies, including the QR encoder.
|
|
1337
|
+
* ======================================================================== */
|
|
1338
|
+
|
|
1339
|
+
(function (Deck) {
|
|
1340
|
+
'use strict';
|
|
1341
|
+
if (!Deck) { console.warn('deck-extras.js: load deck.js first'); return; }
|
|
1342
|
+
|
|
1343
|
+
const $ = (s, r = document) => r.querySelector(s);
|
|
1344
|
+
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
|
|
1345
|
+
const once = node => { if (node.dataset.deckX) return false; node.dataset.deckX = '1'; return true; };
|
|
1346
|
+
const reduced = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
1347
|
+
|
|
1348
|
+
/* ======================================================================
|
|
1349
|
+
Carousel
|
|
1350
|
+
<div class="carousel" data-deck-carousel data-autoplay="6000">
|
|
1351
|
+
====================================================================== */
|
|
1352
|
+
|
|
1353
|
+
function carousels(root) {
|
|
1354
|
+
$$('[data-deck-carousel]', root).forEach(car => {
|
|
1355
|
+
if (!once(car)) return;
|
|
1356
|
+
const track = $('.carousel-track', car);
|
|
1357
|
+
if (!track) return;
|
|
1358
|
+
const slides = $$('.carousel-slide', track);
|
|
1359
|
+
/* ::scroll-marker and ::scroll-button give us the dots, the arrows, the
|
|
1360
|
+
active and disabled states and the keyboard order for free. Where they
|
|
1361
|
+
exist the CSS hides the DIY ones, so drop the references here and the
|
|
1362
|
+
building and wiring below skips itself. */
|
|
1363
|
+
const diy = !CSS.supports('selector(::scroll-marker)');
|
|
1364
|
+
const dots = diy ? $('.carousel-dots', car) : null;
|
|
1365
|
+
const prev = diy ? $('.carousel-prev', car) : null;
|
|
1366
|
+
const next = diy ? $('.carousel-next', car) : null;
|
|
1367
|
+
const rtl = () => getComputedStyle(track).direction === 'rtl';
|
|
1368
|
+
|
|
1369
|
+
if (dots && !dots.children.length) {
|
|
1370
|
+
dots.innerHTML = slides.map((s, i) =>
|
|
1371
|
+
`<button type="button" class="carousel-dot" data-go="${i}" aria-label="Slide ${i + 1}"></button>`).join('');
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
const current = () => {
|
|
1375
|
+
const mid = track.scrollLeft + track.clientWidth / 2;
|
|
1376
|
+
let best = 0, bestD = Infinity;
|
|
1377
|
+
slides.forEach((s, i) => {
|
|
1378
|
+
const d = Math.abs(s.offsetLeft + s.offsetWidth / 2 - mid);
|
|
1379
|
+
if (d < bestD) { bestD = d; best = i; }
|
|
1380
|
+
});
|
|
1381
|
+
return best;
|
|
1382
|
+
};
|
|
1383
|
+
|
|
1384
|
+
const go = i => {
|
|
1385
|
+
const s = slides[Math.max(0, Math.min(slides.length - 1, i))];
|
|
1386
|
+
if (!s) return;
|
|
1387
|
+
track.scrollTo({
|
|
1388
|
+
left: s.offsetLeft - (track.clientWidth - s.offsetWidth) / 2,
|
|
1389
|
+
behavior: reduced() ? 'auto' : 'smooth'
|
|
1390
|
+
});
|
|
1391
|
+
};
|
|
1392
|
+
|
|
1393
|
+
const paint = () => {
|
|
1394
|
+
const i = current();
|
|
1395
|
+
if (dots) $$('.carousel-dot', dots).forEach((d, n) =>
|
|
1396
|
+
d.setAttribute('aria-current', String(n === i)));
|
|
1397
|
+
if (prev) prev.disabled = i === 0;
|
|
1398
|
+
if (next) next.disabled = i === slides.length - 1;
|
|
1399
|
+
car.dispatchEvent(new CustomEvent('deck:slide', { bubbles: true, detail: { index: i } }));
|
|
1400
|
+
};
|
|
1401
|
+
|
|
1402
|
+
track.addEventListener('scroll', () => requestAnimationFrame(paint), { passive: true });
|
|
1403
|
+
prev?.addEventListener('click', () => go(current() - 1));
|
|
1404
|
+
next?.addEventListener('click', () => go(current() + 1));
|
|
1405
|
+
dots?.addEventListener('click', e => {
|
|
1406
|
+
const b = e.target.closest('[data-go]');
|
|
1407
|
+
if (b) go(Number(b.dataset.go));
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
car.addEventListener('keydown', e => {
|
|
1411
|
+
const back = rtl() ? 'ArrowRight' : 'ArrowLeft';
|
|
1412
|
+
const fwd = rtl() ? 'ArrowLeft' : 'ArrowRight';
|
|
1413
|
+
if (e.key === back) { e.preventDefault(); go(current() - 1); }
|
|
1414
|
+
if (e.key === fwd) { e.preventDefault(); go(current() + 1); }
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
const ms = Number(car.dataset.autoplay || 0);
|
|
1418
|
+
if (ms > 0 && !reduced()) {
|
|
1419
|
+
let timer = setInterval(tick, ms);
|
|
1420
|
+
function tick() {
|
|
1421
|
+
const i = current();
|
|
1422
|
+
go(i >= slides.length - 1 ? 0 : i + 1);
|
|
1423
|
+
}
|
|
1424
|
+
const stop = () => clearInterval(timer);
|
|
1425
|
+
const start = () => { stop(); timer = setInterval(tick, ms); };
|
|
1426
|
+
car.addEventListener('pointerenter', stop);
|
|
1427
|
+
car.addEventListener('pointerleave', start);
|
|
1428
|
+
car.addEventListener('focusin', stop);
|
|
1429
|
+
document.addEventListener('visibilitychange', () => document.hidden ? stop() : start());
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
paint();
|
|
1433
|
+
car.carousel = { go, current, paint };
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/* ======================================================================
|
|
1438
|
+
Drawer, mega menu, speed dial, banner
|
|
1439
|
+
====================================================================== */
|
|
1440
|
+
|
|
1441
|
+
function drawers(root) {
|
|
1442
|
+
$$('[data-deck-drawer]', root).forEach(trigger => {
|
|
1443
|
+
if (!once(trigger)) return;
|
|
1444
|
+
const target = $(trigger.dataset.deckDrawer);
|
|
1445
|
+
if (!target) return;
|
|
1446
|
+
trigger.addEventListener('click', () => target.showModal());
|
|
1447
|
+
// Click on the backdrop closes it; the dialog element itself does not
|
|
1448
|
+
target.addEventListener('click', e => {
|
|
1449
|
+
if (e.target === target) target.close();
|
|
1450
|
+
});
|
|
1451
|
+
$$('[data-drawer-close]', target).forEach(b =>
|
|
1452
|
+
b.addEventListener('click', () => target.close()));
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function megaMenus(root) {
|
|
1457
|
+
$$('[data-deck-mega]', root).forEach(trigger => {
|
|
1458
|
+
if (!once(trigger)) return;
|
|
1459
|
+
const panel = $(trigger.dataset.deckMega);
|
|
1460
|
+
if (!panel) return;
|
|
1461
|
+
const place = () => {
|
|
1462
|
+
/* 25-anchor.css anchors .mega where the browser supports it, and these
|
|
1463
|
+
inline insets would then be resolved against the anchored region and
|
|
1464
|
+
double-offset the panel. */
|
|
1465
|
+
if (CSS.supports('anchor-name: --a')) return;
|
|
1466
|
+
const r = trigger.getBoundingClientRect();
|
|
1467
|
+
panel.style.insetBlockStart = (r.bottom + 8) + 'px';
|
|
1468
|
+
const w = panel.offsetWidth;
|
|
1469
|
+
const start = Math.min(Math.max(8, r.left + r.width / 2 - w / 2), innerWidth - w - 8);
|
|
1470
|
+
panel.style.insetInlineStart = start + 'px';
|
|
1471
|
+
};
|
|
1472
|
+
panel.addEventListener('toggle', e => { if (e.newState === 'open') place(); });
|
|
1473
|
+
addEventListener('resize', () => { if (panel.matches(':popover-open')) place(); });
|
|
1474
|
+
// Hover intent on pointer devices, click everywhere
|
|
1475
|
+
if (matchMedia('(hover: hover)').matches) {
|
|
1476
|
+
let t;
|
|
1477
|
+
const open = () => { clearTimeout(t); t = setTimeout(() => panel.showPopover(), 90); };
|
|
1478
|
+
const close = () => { clearTimeout(t); t = setTimeout(() => panel.hidePopover(), 180); };
|
|
1479
|
+
[trigger, panel].forEach(n => {
|
|
1480
|
+
n.addEventListener('pointerenter', open);
|
|
1481
|
+
n.addEventListener('pointerleave', close);
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function speedDials(root) {
|
|
1488
|
+
$$('.speed-dial', root).forEach(dial => {
|
|
1489
|
+
if (!once(dial)) return;
|
|
1490
|
+
const fab = $('.fab', dial);
|
|
1491
|
+
const set = on => {
|
|
1492
|
+
dial.classList.toggle('is-open', on);
|
|
1493
|
+
fab?.setAttribute('aria-expanded', String(on));
|
|
1494
|
+
};
|
|
1495
|
+
fab?.addEventListener('click', () => set(!dial.classList.contains('is-open')));
|
|
1496
|
+
document.addEventListener('click', e => {
|
|
1497
|
+
if (!dial.contains(e.target)) set(false);
|
|
1498
|
+
});
|
|
1499
|
+
dial.addEventListener('keydown', e => { if (e.key === 'Escape') { set(false); fab?.focus(); } });
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
function banners(root) {
|
|
1504
|
+
$$('.banner', root).forEach(banner => {
|
|
1505
|
+
if (!once(banner)) return;
|
|
1506
|
+
const key = banner.dataset.dismissKey;
|
|
1507
|
+
if (key) {
|
|
1508
|
+
try { if (localStorage.getItem('deck-banner-' + key)) { banner.hidden = true; return; } } catch (e) {}
|
|
1509
|
+
}
|
|
1510
|
+
$('.banner-close', banner)?.addEventListener('click', () => {
|
|
1511
|
+
banner.hidden = true;
|
|
1512
|
+
if (key) { try { localStorage.setItem('deck-banner-' + key, '1'); } catch (e) {} }
|
|
1513
|
+
});
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/* ======================================================================
|
|
1518
|
+
Number, phone, range pair
|
|
1519
|
+
====================================================================== */
|
|
1520
|
+
|
|
1521
|
+
function numbers(root) {
|
|
1522
|
+
$$('.number', root).forEach(box => {
|
|
1523
|
+
if (!once(box)) return;
|
|
1524
|
+
const input = $('input', box);
|
|
1525
|
+
const step = Number(input.step) || 1;
|
|
1526
|
+
const min = input.min === '' ? -Infinity : Number(input.min);
|
|
1527
|
+
const max = input.max === '' ? Infinity : Number(input.max);
|
|
1528
|
+
const decimals = (String(step).split('.')[1] || '').length;
|
|
1529
|
+
|
|
1530
|
+
const nudge = dir => {
|
|
1531
|
+
const now = Number(input.value) || 0;
|
|
1532
|
+
const next = Math.min(max, Math.max(min, now + dir * step));
|
|
1533
|
+
input.value = next.toFixed(decimals);
|
|
1534
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1535
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1536
|
+
paint();
|
|
1537
|
+
};
|
|
1538
|
+
const paint = () => {
|
|
1539
|
+
const now = Number(input.value);
|
|
1540
|
+
$$('button', box).forEach(b => {
|
|
1541
|
+
const d = Number(b.dataset.step || (b === box.firstElementChild ? -1 : 1));
|
|
1542
|
+
b.disabled = d < 0 ? now <= min : now >= max;
|
|
1543
|
+
});
|
|
1544
|
+
};
|
|
1545
|
+
|
|
1546
|
+
$$('button', box).forEach(b => {
|
|
1547
|
+
const dir = Number(b.dataset.step || (b === box.firstElementChild ? -1 : 1));
|
|
1548
|
+
let hold, repeat;
|
|
1549
|
+
const start = e => {
|
|
1550
|
+
e.preventDefault();
|
|
1551
|
+
nudge(dir);
|
|
1552
|
+
hold = setTimeout(() => { repeat = setInterval(() => nudge(dir), 70); }, 420);
|
|
1553
|
+
};
|
|
1554
|
+
const stop = () => { clearTimeout(hold); clearInterval(repeat); };
|
|
1555
|
+
b.addEventListener('pointerdown', start);
|
|
1556
|
+
['pointerup', 'pointerleave', 'pointercancel'].forEach(ev => b.addEventListener(ev, stop));
|
|
1557
|
+
});
|
|
1558
|
+
input.addEventListener('input', paint);
|
|
1559
|
+
paint();
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
function phones(root) {
|
|
1564
|
+
$$('.phone', root).forEach(box => {
|
|
1565
|
+
if (!once(box)) return;
|
|
1566
|
+
const input = $('input', box);
|
|
1567
|
+
const select = $('select', box);
|
|
1568
|
+
const codeEl = $('.phone-code', box);
|
|
1569
|
+
const flagEl = $('.phone-flag', box);
|
|
1570
|
+
|
|
1571
|
+
const sync = () => {
|
|
1572
|
+
const opt = select?.selectedOptions[0];
|
|
1573
|
+
if (!opt) return;
|
|
1574
|
+
if (codeEl) codeEl.textContent = opt.dataset.code || '';
|
|
1575
|
+
if (flagEl) flagEl.textContent = opt.dataset.flag || '';
|
|
1576
|
+
input.placeholder = opt.dataset.mask
|
|
1577
|
+
? opt.dataset.mask.replace(/#/g, '0')
|
|
1578
|
+
: input.placeholder;
|
|
1579
|
+
};
|
|
1580
|
+
select?.addEventListener('change', () => { sync(); format(); });
|
|
1581
|
+
|
|
1582
|
+
/* FINDINGS 62: this destroys a digit when someone edits the middle of a
|
|
1583
|
+
number. The loop stops at the end of the MASK, not the end of the
|
|
1584
|
+
digits, so inserting a character shifts everything along and the last
|
|
1585
|
+
digit falls off with no warning -- measured: caret at index 8 of
|
|
1586
|
+
"(202) 555-0147", type 9, get "(202) 559-5014". The row still reports
|
|
1587
|
+
itself .is-valid because it still holds ten digits.
|
|
1588
|
+
|
|
1589
|
+
Assigning input.value also collapses the caret to the end.
|
|
1590
|
+
|
|
1591
|
+
Fix: count digits before the caret, rebuild, restore the caret to the
|
|
1592
|
+
same digit offset; and refuse the keystroke when the mask is full
|
|
1593
|
+
rather than dropping data silently. */
|
|
1594
|
+
const format = () => {
|
|
1595
|
+
const mask = select?.selectedOptions[0]?.dataset.mask;
|
|
1596
|
+
const digits = input.value.replace(/\D/g, '');
|
|
1597
|
+
if (!mask) { input.value = digits; return; }
|
|
1598
|
+
let out = '', d = 0;
|
|
1599
|
+
for (const ch of mask) {
|
|
1600
|
+
if (d >= digits.length) break;
|
|
1601
|
+
out += ch === '#' ? digits[d++] : ch;
|
|
1602
|
+
}
|
|
1603
|
+
input.value = out;
|
|
1604
|
+
const full = (mask.match(/#/g) || []).length;
|
|
1605
|
+
box.classList.toggle('is-valid', digits.length === full);
|
|
1606
|
+
};
|
|
1607
|
+
input.addEventListener('input', format);
|
|
1608
|
+
input.addEventListener('blur', () => {
|
|
1609
|
+
box.dispatchEvent(new CustomEvent('deck:change', {
|
|
1610
|
+
bubbles: true,
|
|
1611
|
+
detail: {
|
|
1612
|
+
code: select?.selectedOptions[0]?.dataset.code || '',
|
|
1613
|
+
number: input.value.replace(/\D/g, ''),
|
|
1614
|
+
e164: (select?.selectedOptions[0]?.dataset.code || '') + input.value.replace(/\D/g, '')
|
|
1615
|
+
}
|
|
1616
|
+
}));
|
|
1617
|
+
});
|
|
1618
|
+
sync();
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
function rangePairs(root) {
|
|
1623
|
+
$$('.range-pair', root).forEach(pair => {
|
|
1624
|
+
if (!once(pair)) return;
|
|
1625
|
+
const [lo, hi] = $$('input[type="range"]', pair);
|
|
1626
|
+
if (!lo || !hi) return;
|
|
1627
|
+
const min = Number(lo.min || 0), max = Number(lo.max || 100);
|
|
1628
|
+
const pct = v => ((v - min) / (max - min)) * 100;
|
|
1629
|
+
const gap = Number(pair.dataset.gap || 0);
|
|
1630
|
+
|
|
1631
|
+
const paint = () => {
|
|
1632
|
+
if (Number(lo.value) > Number(hi.value) - gap) {
|
|
1633
|
+
// whichever handle moved last gets pushed back
|
|
1634
|
+
if (document.activeElement === lo) lo.value = String(Number(hi.value) - gap);
|
|
1635
|
+
else hi.value = String(Number(lo.value) + gap);
|
|
1636
|
+
}
|
|
1637
|
+
pair.style.setProperty('--lo', pct(Number(lo.value)).toFixed(2));
|
|
1638
|
+
pair.style.setProperty('--hi', pct(Number(hi.value)).toFixed(2));
|
|
1639
|
+
const readout = pair.parentElement?.querySelector('.range-readout');
|
|
1640
|
+
if (readout) {
|
|
1641
|
+
const [a, b] = readout.children;
|
|
1642
|
+
if (a) a.textContent = lo.dataset.prefix ? lo.dataset.prefix + lo.value : lo.value;
|
|
1643
|
+
if (b) b.textContent = hi.dataset.prefix ? hi.dataset.prefix + hi.value : hi.value;
|
|
1644
|
+
}
|
|
1645
|
+
pair.dispatchEvent(new CustomEvent('deck:change', {
|
|
1646
|
+
bubbles: true, detail: { min: Number(lo.value), max: Number(hi.value) }
|
|
1647
|
+
}));
|
|
1648
|
+
};
|
|
1649
|
+
[lo, hi].forEach(i => i.addEventListener('input', paint));
|
|
1650
|
+
paint();
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
/* ======================================================================
|
|
1655
|
+
Copy to clipboard
|
|
1656
|
+
====================================================================== */
|
|
1657
|
+
|
|
1658
|
+
async function writeClipboard(text) {
|
|
1659
|
+
try {
|
|
1660
|
+
await navigator.clipboard.writeText(text);
|
|
1661
|
+
return true;
|
|
1662
|
+
} catch (e) {
|
|
1663
|
+
// http origins and older engines
|
|
1664
|
+
const ta = document.createElement('textarea');
|
|
1665
|
+
ta.value = text;
|
|
1666
|
+
ta.setAttribute('readonly', '');
|
|
1667
|
+
ta.style.cssText = 'position:fixed;opacity:0;pointer-events:none';
|
|
1668
|
+
document.body.append(ta);
|
|
1669
|
+
ta.select();
|
|
1670
|
+
let ok = false;
|
|
1671
|
+
try { ok = document.execCommand('copy'); } catch (err) {}
|
|
1672
|
+
ta.remove();
|
|
1673
|
+
return ok;
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
function copiers(root) {
|
|
1678
|
+
$$('[data-deck-copy]', root).forEach(btn => {
|
|
1679
|
+
if (!once(btn)) return;
|
|
1680
|
+
btn.addEventListener('click', async () => {
|
|
1681
|
+
const sel = btn.dataset.deckCopy;
|
|
1682
|
+
const text = sel && sel !== 'true'
|
|
1683
|
+
? ($(sel)?.value ?? $(sel)?.textContent ?? sel)
|
|
1684
|
+
: (btn.closest('.copy')?.querySelector('code, .copy-value')?.textContent
|
|
1685
|
+
?? btn.previousElementSibling?.textContent ?? '');
|
|
1686
|
+
const ok = await writeClipboard(String(text).trim());
|
|
1687
|
+
btn.classList.toggle('is-copied', ok);
|
|
1688
|
+
/* FINDINGS 63: this is set AFTER the subtree has already changed, so
|
|
1689
|
+
the first press announces nothing -- a live region has to exist
|
|
1690
|
+
before its content changes. Every press after that announces,
|
|
1691
|
+
because the attribute is by then already there. Declare it in the
|
|
1692
|
+
markup, or write into a separate role="status" element. */
|
|
1693
|
+
btn.setAttribute('aria-live', 'polite');
|
|
1694
|
+
if (ok && !btn.classList.contains('copy-inline') && !$('.copy-done', btn)) {
|
|
1695
|
+
Deck.toast({ kind: 'good', title: 'Copied', duration: 2000 });
|
|
1696
|
+
}
|
|
1697
|
+
setTimeout(() => btn.classList.remove('is-copied'), 1800);
|
|
1698
|
+
});
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/* ======================================================================
|
|
1703
|
+
WYSIWYG editor
|
|
1704
|
+
====================================================================== */
|
|
1705
|
+
|
|
1706
|
+
function editors(root) {
|
|
1707
|
+
$$('.editor', root).forEach(ed => {
|
|
1708
|
+
if (!once(ed)) return;
|
|
1709
|
+
const content = $('.editor-content', ed);
|
|
1710
|
+
const target = ed.dataset.target ? $(ed.dataset.target) : null;
|
|
1711
|
+
const counter = $('.editor-count', ed);
|
|
1712
|
+
const limit = Number(ed.dataset.limit || 0);
|
|
1713
|
+
if (!content) return;
|
|
1714
|
+
content.contentEditable = 'true';
|
|
1715
|
+
content.setAttribute('role', 'textbox');
|
|
1716
|
+
content.setAttribute('aria-multiline', 'true');
|
|
1717
|
+
|
|
1718
|
+
const run = (cmd, value = null) => {
|
|
1719
|
+
content.focus();
|
|
1720
|
+
document.execCommand(cmd, false, value);
|
|
1721
|
+
paint();
|
|
1722
|
+
sync();
|
|
1723
|
+
};
|
|
1724
|
+
|
|
1725
|
+
const paint = () => {
|
|
1726
|
+
$$('[data-cmd]', ed).forEach(b => {
|
|
1727
|
+
let on = false;
|
|
1728
|
+
try { on = document.queryCommandState(b.dataset.cmd); } catch (e) {}
|
|
1729
|
+
b.setAttribute('aria-pressed', String(on));
|
|
1730
|
+
});
|
|
1731
|
+
if (counter) {
|
|
1732
|
+
const n = content.textContent.trim().length;
|
|
1733
|
+
counter.textContent = limit ? `${n} / ${limit}` : `${n} characters`;
|
|
1734
|
+
counter.classList.toggle('is-over', limit > 0 && n > limit);
|
|
1735
|
+
}
|
|
1736
|
+
};
|
|
1737
|
+
|
|
1738
|
+
const sync = () => {
|
|
1739
|
+
if (target) target.value = content.innerHTML;
|
|
1740
|
+
ed.dispatchEvent(new CustomEvent('deck:change', {
|
|
1741
|
+
bubbles: true, detail: { html: content.innerHTML, text: content.textContent }
|
|
1742
|
+
}));
|
|
1743
|
+
};
|
|
1744
|
+
|
|
1745
|
+
ed.addEventListener('click', e => {
|
|
1746
|
+
const b = e.target.closest('[data-cmd]');
|
|
1747
|
+
if (!b) return;
|
|
1748
|
+
e.preventDefault();
|
|
1749
|
+
const cmd = b.dataset.cmd;
|
|
1750
|
+
if (cmd === 'createLink') {
|
|
1751
|
+
const url = prompt('Link address');
|
|
1752
|
+
if (url) run('createLink', url);
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
run(cmd, b.dataset.value || null);
|
|
1756
|
+
});
|
|
1757
|
+
|
|
1758
|
+
$$('.editor-select', ed).forEach(sel => {
|
|
1759
|
+
sel.addEventListener('change', () => run('formatBlock', sel.value));
|
|
1760
|
+
});
|
|
1761
|
+
|
|
1762
|
+
// Paste as plain text, so a paste from Word does not import its styling
|
|
1763
|
+
content.addEventListener('paste', e => {
|
|
1764
|
+
e.preventDefault();
|
|
1765
|
+
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
|
|
1766
|
+
document.execCommand('insertText', false, text);
|
|
1767
|
+
});
|
|
1768
|
+
|
|
1769
|
+
content.addEventListener('input', () => { paint(); sync(); });
|
|
1770
|
+
content.addEventListener('keyup', paint);
|
|
1771
|
+
content.addEventListener('mouseup', paint);
|
|
1772
|
+
document.addEventListener('selectionchange', () => {
|
|
1773
|
+
if (content.contains(document.getSelection()?.anchorNode)) paint();
|
|
1774
|
+
});
|
|
1775
|
+
|
|
1776
|
+
if (target && target.value) content.innerHTML = target.value;
|
|
1777
|
+
paint();
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
/* ======================================================================
|
|
1782
|
+
Lazy images
|
|
1783
|
+
====================================================================== */
|
|
1784
|
+
|
|
1785
|
+
function lazies(root) {
|
|
1786
|
+
const imgs = $$('.lazy img, img[data-src]', root).filter(i => !i.dataset.deckX);
|
|
1787
|
+
if (!imgs.length) return;
|
|
1788
|
+
const load = img => {
|
|
1789
|
+
img.dataset.deckX = '1';
|
|
1790
|
+
if (img.dataset.src) img.src = img.dataset.src;
|
|
1791
|
+
if (img.dataset.srcset) img.srcset = img.dataset.srcset;
|
|
1792
|
+
const done = () => img.classList.add('is-loaded');
|
|
1793
|
+
if (img.complete && img.naturalWidth) done();
|
|
1794
|
+
else img.addEventListener('load', done, { once: true });
|
|
1795
|
+
img.addEventListener('error', () => img.closest('.lazy')?.classList.add('is-error'), { once: true });
|
|
1796
|
+
};
|
|
1797
|
+
if (!('IntersectionObserver' in window)) return imgs.forEach(load);
|
|
1798
|
+
const io = new IntersectionObserver(entries => {
|
|
1799
|
+
entries.forEach(e => {
|
|
1800
|
+
if (!e.isIntersecting) return;
|
|
1801
|
+
load(e.target);
|
|
1802
|
+
io.unobserve(e.target);
|
|
1803
|
+
});
|
|
1804
|
+
}, { rootMargin: '200px' });
|
|
1805
|
+
imgs.forEach(i => io.observe(i));
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
/* ======================================================================
|
|
1809
|
+
QR code
|
|
1810
|
+
A complete encoder: byte mode, versions 1 to 10, error correction
|
|
1811
|
+
L / M / Q / H, all eight masks scored by the standard penalty rules.
|
|
1812
|
+
No dependency, no network call, no canvas — it emits SVG.
|
|
1813
|
+
====================================================================== */
|
|
1814
|
+
|
|
1815
|
+
const QR = (() => {
|
|
1816
|
+
// Galois field GF(256) with the QR primitive polynomial 0x11D
|
|
1817
|
+
const EXP = new Uint8Array(512), LOG = new Uint8Array(256);
|
|
1818
|
+
for (let i = 0, x = 1; i < 255; i++) {
|
|
1819
|
+
EXP[i] = x; LOG[x] = i;
|
|
1820
|
+
x <<= 1; if (x & 0x100) x ^= 0x11d;
|
|
1821
|
+
}
|
|
1822
|
+
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
|
|
1823
|
+
const mul = (a, b) => (a && b) ? EXP[LOG[a] + LOG[b]] : 0;
|
|
1824
|
+
|
|
1825
|
+
// [ecPerBlock, group1Blocks, group1Data, group2Blocks, group2Data] by version, then L M Q H
|
|
1826
|
+
const RS = {
|
|
1827
|
+
1: [[7,1,19,0,0],[10,1,16,0,0],[13,1,13,0,0],[17,1,9,0,0]],
|
|
1828
|
+
2: [[10,1,34,0,0],[16,1,28,0,0],[22,1,22,0,0],[28,1,16,0,0]],
|
|
1829
|
+
3: [[15,1,55,0,0],[26,1,44,0,0],[18,2,17,0,0],[22,2,13,0,0]],
|
|
1830
|
+
4: [[20,1,80,0,0],[18,2,32,0,0],[26,2,24,0,0],[16,4,9,0,0]],
|
|
1831
|
+
5: [[26,1,108,0,0],[24,2,43,0,0],[18,2,15,2,16],[22,2,11,2,12]],
|
|
1832
|
+
6: [[18,2,68,0,0],[16,4,27,0,0],[24,4,19,0,0],[28,4,15,0,0]],
|
|
1833
|
+
7: [[20,2,78,0,0],[18,4,31,0,0],[18,2,14,4,15],[26,4,13,1,14]],
|
|
1834
|
+
8: [[24,2,97,0,0],[22,2,38,2,39],[22,4,18,2,19],[26,4,14,2,15]],
|
|
1835
|
+
9: [[30,2,116,0,0],[22,3,36,2,37],[20,4,16,4,17],[24,4,12,4,13]],
|
|
1836
|
+
10: [[18,2,68,2,69],[26,4,43,1,44],[24,6,19,2,20],[28,6,15,2,16]]
|
|
1837
|
+
};
|
|
1838
|
+
const ALIGN = {
|
|
1839
|
+
1: [], 2: [6,18], 3: [6,22], 4: [6,26], 5: [6,30],
|
|
1840
|
+
6: [6,34], 7: [6,22,38], 8: [6,24,42], 9: [6,26,46], 10: [6,28,50]
|
|
1841
|
+
};
|
|
1842
|
+
const ECL = { L: 0, M: 1, Q: 2, H: 3 };
|
|
1843
|
+
const ECL_BITS = { L: 1, M: 0, Q: 3, H: 2 };
|
|
1844
|
+
|
|
1845
|
+
function generator(n) {
|
|
1846
|
+
let poly = [1];
|
|
1847
|
+
for (let i = 0; i < n; i++) {
|
|
1848
|
+
const next = new Array(poly.length + 1).fill(0);
|
|
1849
|
+
for (let j = 0; j < poly.length; j++) {
|
|
1850
|
+
next[j] ^= mul(poly[j], EXP[i]);
|
|
1851
|
+
next[j + 1] ^= poly[j];
|
|
1852
|
+
}
|
|
1853
|
+
poly = next;
|
|
1854
|
+
}
|
|
1855
|
+
return poly;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
function ecBytes(data, n) {
|
|
1859
|
+
const gen = generator(n);
|
|
1860
|
+
const rem = new Array(n).fill(0);
|
|
1861
|
+
for (const byte of data) {
|
|
1862
|
+
const factor = byte ^ rem[0];
|
|
1863
|
+
rem.shift();
|
|
1864
|
+
rem.push(0);
|
|
1865
|
+
for (let i = 0; i < n; i++) rem[i] ^= mul(gen[i + 1], factor);
|
|
1866
|
+
}
|
|
1867
|
+
return rem;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
function formatBits(ecl, mask) {
|
|
1871
|
+
const data = (ECL_BITS[ecl] << 3) | mask;
|
|
1872
|
+
let rem = data;
|
|
1873
|
+
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >> 9) * 0x537);
|
|
1874
|
+
return ((data << 10) | rem) ^ 0x5412;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
function versionBits(v) {
|
|
1878
|
+
let rem = v;
|
|
1879
|
+
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >> 11) * 0x1f25);
|
|
1880
|
+
return (v << 12) | rem;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
function pickVersion(len, ecl) {
|
|
1884
|
+
for (let v = 1; v <= 10; v++) {
|
|
1885
|
+
const [ec, b1, d1, b2, d2] = RS[v][ECL[ecl]];
|
|
1886
|
+
const total = b1 * d1 + b2 * d2;
|
|
1887
|
+
const countBits = v < 10 ? 8 : 16;
|
|
1888
|
+
if (len <= Math.floor((total * 8 - 4 - countBits) / 8)) return v;
|
|
1889
|
+
}
|
|
1890
|
+
throw new Error('QR: content too long for versions 1 to 10. Shorten it or link to it.');
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
function encode(text, ecl = 'M') {
|
|
1894
|
+
const bytes = Array.from(new TextEncoder().encode(text));
|
|
1895
|
+
const version = pickVersion(bytes.length, ecl);
|
|
1896
|
+
const [ecPer, b1, d1, b2, d2] = RS[version][ECL[ecl]];
|
|
1897
|
+
const totalData = b1 * d1 + b2 * d2;
|
|
1898
|
+
const countBits = version < 10 ? 8 : 16;
|
|
1899
|
+
|
|
1900
|
+
// Bit stream: mode 0100, character count, payload, terminator, padding
|
|
1901
|
+
const bits = [];
|
|
1902
|
+
const push = (val, n) => { for (let i = n - 1; i >= 0; i--) bits.push((val >> i) & 1); };
|
|
1903
|
+
push(4, 4);
|
|
1904
|
+
push(bytes.length, countBits);
|
|
1905
|
+
bytes.forEach(b => push(b, 8));
|
|
1906
|
+
for (let i = 0; i < 4 && bits.length < totalData * 8; i++) bits.push(0);
|
|
1907
|
+
while (bits.length % 8) bits.push(0);
|
|
1908
|
+
const data = [];
|
|
1909
|
+
for (let i = 0; i < bits.length; i += 8) {
|
|
1910
|
+
data.push(parseInt(bits.slice(i, i + 8).join(''), 2));
|
|
1911
|
+
}
|
|
1912
|
+
const PAD = [0xec, 0x11];
|
|
1913
|
+
for (let i = 0; data.length < totalData; i++) data.push(PAD[i % 2]);
|
|
1914
|
+
|
|
1915
|
+
// Split into blocks, compute error correction, then interleave
|
|
1916
|
+
const blocks = [], ecs = [];
|
|
1917
|
+
let at = 0;
|
|
1918
|
+
for (let i = 0; i < b1 + b2; i++) {
|
|
1919
|
+
const size = i < b1 ? d1 : d2;
|
|
1920
|
+
const block = data.slice(at, at + size);
|
|
1921
|
+
at += size;
|
|
1922
|
+
blocks.push(block);
|
|
1923
|
+
ecs.push(ecBytes(block, ecPer));
|
|
1924
|
+
}
|
|
1925
|
+
const out = [];
|
|
1926
|
+
const maxData = Math.max(d1, d2);
|
|
1927
|
+
for (let i = 0; i < maxData; i++)
|
|
1928
|
+
blocks.forEach(b => { if (i < b.length) out.push(b[i]); });
|
|
1929
|
+
for (let i = 0; i < ecPer; i++)
|
|
1930
|
+
ecs.forEach(b => out.push(b[i]));
|
|
1931
|
+
|
|
1932
|
+
return { version, ecl, codewords: out };
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
function build(text, ecl = 'M') {
|
|
1936
|
+
const { version, codewords } = encode(text, ecl);
|
|
1937
|
+
const size = version * 4 + 17;
|
|
1938
|
+
const grid = Array.from({ length: size }, () => new Array(size).fill(null));
|
|
1939
|
+
const reserved = Array.from({ length: size }, () => new Array(size).fill(false));
|
|
1940
|
+
|
|
1941
|
+
const put = (x, y, v, res = true) => {
|
|
1942
|
+
if (x < 0 || y < 0 || x >= size || y >= size) return;
|
|
1943
|
+
grid[y][x] = v;
|
|
1944
|
+
if (res) reserved[y][x] = true;
|
|
1945
|
+
};
|
|
1946
|
+
|
|
1947
|
+
// Finder patterns and separators
|
|
1948
|
+
[[0, 0], [size - 7, 0], [0, size - 7]].forEach(([ox, oy]) => {
|
|
1949
|
+
for (let y = -1; y <= 7; y++) for (let x = -1; x <= 7; x++) {
|
|
1950
|
+
const on = (x >= 0 && x <= 6 && (y === 0 || y === 6)) ||
|
|
1951
|
+
(y >= 0 && y <= 6 && (x === 0 || x === 6)) ||
|
|
1952
|
+
(x >= 2 && x <= 4 && y >= 2 && y <= 4);
|
|
1953
|
+
put(ox + x, oy + y, on ? 1 : 0);
|
|
1954
|
+
}
|
|
1955
|
+
});
|
|
1956
|
+
|
|
1957
|
+
// Timing patterns
|
|
1958
|
+
for (let i = 8; i < size - 8; i++) {
|
|
1959
|
+
put(i, 6, i % 2 === 0 ? 1 : 0);
|
|
1960
|
+
put(6, i, i % 2 === 0 ? 1 : 0);
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
// Alignment patterns, skipping the ones that collide with finders
|
|
1964
|
+
const centers = ALIGN[version];
|
|
1965
|
+
centers.forEach(cy => centers.forEach(cx => {
|
|
1966
|
+
if ((cx === 6 && cy === 6) ||
|
|
1967
|
+
(cx === 6 && cy === size - 7) ||
|
|
1968
|
+
(cx === size - 7 && cy === 6)) return;
|
|
1969
|
+
for (let y = -2; y <= 2; y++) for (let x = -2; x <= 2; x++) {
|
|
1970
|
+
const on = Math.max(Math.abs(x), Math.abs(y)) !== 1;
|
|
1971
|
+
put(cx + x, cy + y, on ? 1 : 0);
|
|
1972
|
+
}
|
|
1973
|
+
}));
|
|
1974
|
+
|
|
1975
|
+
// Dark module, then reserve the two format strips so data skips them
|
|
1976
|
+
put(8, size - 8, 1);
|
|
1977
|
+
for (let i = 0; i < 9; i++) { reserved[8][i] = true; reserved[i][8] = true; }
|
|
1978
|
+
for (let i = 0; i < 8; i++) {
|
|
1979
|
+
reserved[8][size - 1 - i] = true;
|
|
1980
|
+
reserved[size - 1 - i][8] = true;
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// Version information blocks, versions 7 and up
|
|
1984
|
+
if (version >= 7) {
|
|
1985
|
+
const vb = versionBits(version);
|
|
1986
|
+
for (let i = 0; i < 18; i++) {
|
|
1987
|
+
const b = (vb >> i) & 1;
|
|
1988
|
+
const a = Math.floor(i / 3), c = i % 3;
|
|
1989
|
+
grid[a][size - 11 + c] = b; reserved[a][size - 11 + c] = true;
|
|
1990
|
+
grid[size - 11 + c][a] = b; reserved[size - 11 + c][a] = true;
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
// Place data in the zig-zag, skipping the timing column
|
|
1995
|
+
let bit = 0;
|
|
1996
|
+
const stream = [];
|
|
1997
|
+
codewords.forEach(cw => { for (let i = 7; i >= 0; i--) stream.push((cw >> i) & 1); });
|
|
1998
|
+
|
|
1999
|
+
let up = true;
|
|
2000
|
+
for (let right = size - 1; right > 0; right -= 2) {
|
|
2001
|
+
if (right === 6) right = 5;
|
|
2002
|
+
for (let step = 0; step < size; step++) {
|
|
2003
|
+
const y = up ? size - 1 - step : step;
|
|
2004
|
+
for (let c = 0; c < 2; c++) {
|
|
2005
|
+
const x = right - c;
|
|
2006
|
+
if (reserved[y][x]) continue;
|
|
2007
|
+
grid[y][x] = bit < stream.length ? stream[bit++] : 0;
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
up = !up;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
// Try every mask, keep the one with the lowest penalty
|
|
2014
|
+
const maskFn = [
|
|
2015
|
+
(x, y) => (x + y) % 2 === 0,
|
|
2016
|
+
(x, y) => y % 2 === 0,
|
|
2017
|
+
(x, y) => x % 3 === 0,
|
|
2018
|
+
(x, y) => (x + y) % 3 === 0,
|
|
2019
|
+
(x, y) => (Math.floor(y / 2) + Math.floor(x / 3)) % 2 === 0,
|
|
2020
|
+
(x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
|
|
2021
|
+
(x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
|
|
2022
|
+
(x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0
|
|
2023
|
+
];
|
|
2024
|
+
|
|
2025
|
+
let best = null, bestScore = Infinity;
|
|
2026
|
+
for (let m = 0; m < 8; m++) {
|
|
2027
|
+
const test = grid.map(r => r.slice());
|
|
2028
|
+
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
|
|
2029
|
+
if (!reserved[y][x] && maskFn[m](x, y)) test[y][x] ^= 1;
|
|
2030
|
+
}
|
|
2031
|
+
const fb = formatBits(ecl, m);
|
|
2032
|
+
const fbit = i => (fb >> i) & 1;
|
|
2033
|
+
// grid is indexed [row][column]
|
|
2034
|
+
// First copy: down column 8, then left along row 8
|
|
2035
|
+
for (let i = 0; i <= 5; i++) test[i][8] = fbit(i);
|
|
2036
|
+
test[7][8] = fbit(6);
|
|
2037
|
+
test[8][8] = fbit(7);
|
|
2038
|
+
test[8][7] = fbit(8);
|
|
2039
|
+
for (let i = 9; i <= 14; i++) test[8][14 - i] = fbit(i);
|
|
2040
|
+
// Second copy: along row 8 beside the top-right finder,
|
|
2041
|
+
// then down column 8 beside the bottom-left finder
|
|
2042
|
+
for (let i = 0; i <= 7; i++) test[8][size - 1 - i] = fbit(i);
|
|
2043
|
+
for (let i = 8; i <= 14; i++) test[size - 15 + i][8] = fbit(i);
|
|
2044
|
+
test[size - 8][8] = 1;
|
|
2045
|
+
const score = penalty(test, size);
|
|
2046
|
+
if (score < bestScore) { bestScore = score; best = test; }
|
|
2047
|
+
}
|
|
2048
|
+
return { modules: best, size, version };
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
function penalty(m, size) {
|
|
2052
|
+
let score = 0;
|
|
2053
|
+
// Rule 1: runs of five or more of the same colour
|
|
2054
|
+
const run = line => {
|
|
2055
|
+
let total = 0, count = 1;
|
|
2056
|
+
for (let i = 1; i < size; i++) {
|
|
2057
|
+
if (line[i] === line[i - 1]) count++;
|
|
2058
|
+
else { if (count >= 5) total += count - 2; count = 1; }
|
|
2059
|
+
}
|
|
2060
|
+
if (count >= 5) total += count - 2;
|
|
2061
|
+
return total;
|
|
2062
|
+
};
|
|
2063
|
+
for (let y = 0; y < size; y++) score += run(m[y]);
|
|
2064
|
+
for (let x = 0; x < size; x++) score += run(m.map(r => r[x]));
|
|
2065
|
+
// Rule 2: two by two blocks
|
|
2066
|
+
for (let y = 0; y < size - 1; y++) for (let x = 0; x < size - 1; x++) {
|
|
2067
|
+
const v = m[y][x];
|
|
2068
|
+
if (v === m[y][x + 1] && v === m[y + 1][x] && v === m[y + 1][x + 1]) score += 3;
|
|
2069
|
+
}
|
|
2070
|
+
// Rule 3: the finder-like 1011101 pattern with four light modules beside it
|
|
2071
|
+
const pat1 = [1,0,1,1,1,0,1,0,0,0,0];
|
|
2072
|
+
const pat2 = [0,0,0,0,1,0,1,1,1,0,1];
|
|
2073
|
+
const scan = line => {
|
|
2074
|
+
let s = 0;
|
|
2075
|
+
for (let i = 0; i <= size - 11; i++) {
|
|
2076
|
+
const slice = line.slice(i, i + 11).join('');
|
|
2077
|
+
if (slice === pat1.join('') || slice === pat2.join('')) s += 40;
|
|
2078
|
+
}
|
|
2079
|
+
return s;
|
|
2080
|
+
};
|
|
2081
|
+
for (let y = 0; y < size; y++) score += scan(m[y]);
|
|
2082
|
+
for (let x = 0; x < size; x++) score += scan(m.map(r => r[x]));
|
|
2083
|
+
// Rule 4: deviation from an even split of dark and light
|
|
2084
|
+
let dark = 0;
|
|
2085
|
+
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) dark += m[y][x];
|
|
2086
|
+
const ratio = Math.abs((dark * 100) / (size * size) - 50);
|
|
2087
|
+
score += Math.floor(ratio / 5) * 10;
|
|
2088
|
+
return score;
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
function svg(text, ecl = 'M') {
|
|
2092
|
+
const { modules, size } = build(text, ecl);
|
|
2093
|
+
// One path-free approach: merge horizontal runs into rects so the SVG
|
|
2094
|
+
// stays small even at version 10
|
|
2095
|
+
let rects = '';
|
|
2096
|
+
for (let y = 0; y < size; y++) {
|
|
2097
|
+
let x = 0;
|
|
2098
|
+
while (x < size) {
|
|
2099
|
+
if (!modules[y][x]) { x++; continue; }
|
|
2100
|
+
let w = 1;
|
|
2101
|
+
while (x + w < size && modules[y][x + w]) w++;
|
|
2102
|
+
rects += `<rect x="${x}" y="${y}" width="${w}" height="1"/>`;
|
|
2103
|
+
x += w;
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" role="img"><g>${rects}</g></svg>`;
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
return { svg, build, encode };
|
|
2110
|
+
})();
|
|
2111
|
+
|
|
2112
|
+
function qrCodes(root) {
|
|
2113
|
+
$$('[data-deck-qr]', root).forEach(node => {
|
|
2114
|
+
if (!once(node)) return;
|
|
2115
|
+
const value = node.dataset.deckQr || node.textContent.trim();
|
|
2116
|
+
const level = (node.dataset.ecl || 'M').toUpperCase();
|
|
2117
|
+
try {
|
|
2118
|
+
node.innerHTML = QR.svg(value, level) + (node.dataset.caption
|
|
2119
|
+
? `<span class="qr-caption">${node.dataset.caption}</span>` : '');
|
|
2120
|
+
} catch (err) {
|
|
2121
|
+
node.innerHTML = `<span class="text-sm text-muted">${err.message}</span>`;
|
|
2122
|
+
}
|
|
2123
|
+
});
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
/* ======================================================================
|
|
2127
|
+
Register
|
|
2128
|
+
====================================================================== */
|
|
2129
|
+
|
|
2130
|
+
const baseInit = Deck.init.bind(Deck);
|
|
2131
|
+
Deck.init = function (root = document) {
|
|
2132
|
+
baseInit(root);
|
|
2133
|
+
carousels(root);
|
|
2134
|
+
drawers(root);
|
|
2135
|
+
megaMenus(root);
|
|
2136
|
+
speedDials(root);
|
|
2137
|
+
banners(root);
|
|
2138
|
+
numbers(root);
|
|
2139
|
+
phones(root);
|
|
2140
|
+
rangePairs(root);
|
|
2141
|
+
copiers(root);
|
|
2142
|
+
editors(root);
|
|
2143
|
+
lazies(root);
|
|
2144
|
+
qrCodes(root);
|
|
2145
|
+
return Deck;
|
|
2146
|
+
};
|
|
2147
|
+
|
|
2148
|
+
Deck.qr = QR;
|
|
2149
|
+
Deck.copy = writeClipboard;
|
|
2150
|
+
|
|
2151
|
+
if (document.readyState !== 'loading') Deck.init();
|
|
2152
|
+
else document.addEventListener('DOMContentLoaded', () => Deck.init());
|
|
2153
|
+
|
|
2154
|
+
})(typeof window !== 'undefined' && window.Deck ? window.Deck
|
|
2155
|
+
: typeof Deck !== 'undefined' ? Deck : null);
|
|
2156
|
+
|
|
2157
|
+
/*! ==========================================================================
|
|
2158
|
+
* deck-adapters.js v0.1
|
|
2159
|
+
*
|
|
2160
|
+
* Deck's core is zero-dependency on purpose. This file is the opposite: it
|
|
2161
|
+
* is a set of adapters that light up ONLY if the corresponding library is
|
|
2162
|
+
* already on the page. Load none of them and everything still works; load
|
|
2163
|
+
* one and Deck hands that job over to a better engine and keeps providing
|
|
2164
|
+
* the styling.
|
|
2165
|
+
*
|
|
2166
|
+
* Detected, in order of how much they actually add:
|
|
2167
|
+
*
|
|
2168
|
+
* Floating UI window.FloatingUIDOM collision-aware placement, for
|
|
2169
|
+
* browsers without CSS anchor
|
|
2170
|
+
* positioning
|
|
2171
|
+
* Quill window.Quill replaces the deprecated
|
|
2172
|
+
* Tiptap window.Tiptap document.execCommand editor
|
|
2173
|
+
* Chart.js window.Chart real axes, tooltips, time scales
|
|
2174
|
+
* SortableJS window.Sortable drag reordering and kanban
|
|
2175
|
+
* Lucide window.lucide 1500 icons instead of Deck's 56
|
|
2176
|
+
*
|
|
2177
|
+
* Nothing here is required and nothing here is bundled. Add a script tag for
|
|
2178
|
+
* the ones you want.
|
|
2179
|
+
* ======================================================================== */
|
|
2180
|
+
|
|
2181
|
+
(function (Deck) {
|
|
2182
|
+
'use strict';
|
|
2183
|
+
if (!Deck) { console.warn('deck-adapters.js: load deck.js first'); return; }
|
|
2184
|
+
|
|
2185
|
+
const $ = (s, r = document) => r.querySelector(s);
|
|
2186
|
+
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
|
|
2187
|
+
const once = n => { if (n.dataset.deckA) return false; n.dataset.deckA = '1'; return true; };
|
|
2188
|
+
const css = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
|
|
2189
|
+
const hasAnchor = CSS.supports('anchor-name: --a');
|
|
2190
|
+
|
|
2191
|
+
const have = {
|
|
2192
|
+
get floating() { return !!window.FloatingUIDOM; },
|
|
2193
|
+
get quill() { return !!window.Quill; },
|
|
2194
|
+
get tiptap() { return !!(window.Tiptap && window.Tiptap.Editor); },
|
|
2195
|
+
get chart() { return !!window.Chart; },
|
|
2196
|
+
get sortable() { return !!window.Sortable; },
|
|
2197
|
+
get lucide() { return !!window.lucide; }
|
|
2198
|
+
};
|
|
2199
|
+
|
|
2200
|
+
/* ======================================================================
|
|
2201
|
+
1. Floating UI — placement
|
|
2202
|
+
Only used where CSS anchor positioning is missing. Where the browser has
|
|
2203
|
+
anchor positioning, the CSS in 25-anchor.css already does this natively,
|
|
2204
|
+
on the compositor, with no listeners, so the adapter stays out of the way.
|
|
2205
|
+
====================================================================== */
|
|
2206
|
+
|
|
2207
|
+
function floating(root) {
|
|
2208
|
+
if (hasAnchor || !have.floating) return;
|
|
2209
|
+
const { computePosition, autoUpdate, offset, flip, shift, arrow, size } = window.FloatingUIDOM;
|
|
2210
|
+
|
|
2211
|
+
$$('[data-deck-anchor]', root).forEach(trigger => {
|
|
2212
|
+
if (!once(trigger)) return;
|
|
2213
|
+
const panel = $(trigger.dataset.deckAnchor);
|
|
2214
|
+
if (!panel) return;
|
|
2215
|
+
const arrowEl = $('.tip-arrow', panel);
|
|
2216
|
+
const placement = panel.dataset.placement || 'bottom';
|
|
2217
|
+
const matchWidth = panel.classList.contains('menu-match') ||
|
|
2218
|
+
panel.classList.contains('combo-list');
|
|
2219
|
+
|
|
2220
|
+
const middleware = [
|
|
2221
|
+
offset(8),
|
|
2222
|
+
flip({ fallbackAxisSideDirection: 'start' }),
|
|
2223
|
+
shift({ padding: 8 }),
|
|
2224
|
+
size({
|
|
2225
|
+
padding: 8,
|
|
2226
|
+
apply({ availableHeight, rects, elements }) {
|
|
2227
|
+
Object.assign(elements.floating.style, {
|
|
2228
|
+
maxHeight: `${Math.max(120, availableHeight)}px`,
|
|
2229
|
+
...(matchWidth ? { width: `${rects.reference.width}px` } : {})
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
})
|
|
2233
|
+
];
|
|
2234
|
+
if (arrowEl) middleware.push(arrow({ element: arrowEl, padding: 6 }));
|
|
2235
|
+
|
|
2236
|
+
const place = async () => {
|
|
2237
|
+
const r = await computePosition(trigger, panel, { placement, strategy: 'fixed', middleware });
|
|
2238
|
+
Object.assign(panel.style, { left: `${r.x}px`, top: `${r.y}px`, position: 'fixed' });
|
|
2239
|
+
if (arrowEl && r.middlewareData.arrow) {
|
|
2240
|
+
const { x, y } = r.middlewareData.arrow;
|
|
2241
|
+
const side = { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }[r.placement.split('-')[0]];
|
|
2242
|
+
Object.assign(arrowEl.style, {
|
|
2243
|
+
left: x != null ? `${x}px` : '',
|
|
2244
|
+
top: y != null ? `${y}px` : '',
|
|
2245
|
+
[side]: '-4px'
|
|
2246
|
+
});
|
|
2247
|
+
}
|
|
2248
|
+
};
|
|
2249
|
+
|
|
2250
|
+
let stop = null;
|
|
2251
|
+
panel.addEventListener('toggle', e => {
|
|
2252
|
+
if (e.newState === 'open') { place(); stop = autoUpdate(trigger, panel, place); }
|
|
2253
|
+
else { stop?.(); stop = null; }
|
|
2254
|
+
});
|
|
2255
|
+
});
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
/* ======================================================================
|
|
2259
|
+
2. Editor — Quill or Tiptap
|
|
2260
|
+
Deck's own editor uses document.execCommand, which is deprecated and
|
|
2261
|
+
inconsistent. If either library is on the page, Deck hands the content
|
|
2262
|
+
region over and keeps its own toolbar chrome, so the markup and the CSS
|
|
2263
|
+
do not change.
|
|
2264
|
+
====================================================================== */
|
|
2265
|
+
|
|
2266
|
+
function editorAdapter(root) {
|
|
2267
|
+
if (!have.quill && !have.tiptap) return;
|
|
2268
|
+
|
|
2269
|
+
$$('.editor', root).forEach(ed => {
|
|
2270
|
+
if (!once(ed)) return;
|
|
2271
|
+
const content = $('.editor-content', ed);
|
|
2272
|
+
const target = ed.dataset.target ? $(ed.dataset.target) : null;
|
|
2273
|
+
const counter = $('.editor-count', ed);
|
|
2274
|
+
const limit = Number(ed.dataset.limit || 0);
|
|
2275
|
+
if (!content) return;
|
|
2276
|
+
|
|
2277
|
+
// Deck's own handler already owns this node; take it back cleanly
|
|
2278
|
+
content.contentEditable = 'false';
|
|
2279
|
+
content.removeAttribute('role');
|
|
2280
|
+
|
|
2281
|
+
const count = text => {
|
|
2282
|
+
if (!counter) return;
|
|
2283
|
+
const n = text.trim().length;
|
|
2284
|
+
counter.textContent = limit ? `${n} / ${limit}` : `${n} characters`;
|
|
2285
|
+
counter.classList.toggle('is-over', limit > 0 && n > limit);
|
|
2286
|
+
};
|
|
2287
|
+
|
|
2288
|
+
if (have.tiptap) {
|
|
2289
|
+
const { Editor } = window.Tiptap;
|
|
2290
|
+
const extensions = window.Tiptap.StarterKit ? [window.Tiptap.StarterKit] : [];
|
|
2291
|
+
const editor = new Editor({
|
|
2292
|
+
element: content,
|
|
2293
|
+
extensions,
|
|
2294
|
+
content: target?.value || content.innerHTML || '',
|
|
2295
|
+
onUpdate: ({ editor }) => {
|
|
2296
|
+
if (target) target.value = editor.getHTML();
|
|
2297
|
+
count(editor.getText());
|
|
2298
|
+
paint(editor);
|
|
2299
|
+
ed.dispatchEvent(new CustomEvent('deck:change', {
|
|
2300
|
+
bubbles: true, detail: { html: editor.getHTML(), text: editor.getText() }
|
|
2301
|
+
}));
|
|
2302
|
+
}
|
|
2303
|
+
});
|
|
2304
|
+
|
|
2305
|
+
const CMD = {
|
|
2306
|
+
bold: e => e.chain().focus().toggleBold().run(),
|
|
2307
|
+
italic: e => e.chain().focus().toggleItalic().run(),
|
|
2308
|
+
underline: e => e.chain().focus().toggleUnderline?.().run(),
|
|
2309
|
+
strikeThrough: e => e.chain().focus().toggleStrike().run(),
|
|
2310
|
+
insertUnorderedList: e => e.chain().focus().toggleBulletList().run(),
|
|
2311
|
+
insertOrderedList: e => e.chain().focus().toggleOrderedList().run(),
|
|
2312
|
+
formatBlock: (e, v) => v === 'p'
|
|
2313
|
+
? e.chain().focus().setParagraph().run()
|
|
2314
|
+
: e.chain().focus().toggleHeading({ level: Number(v.replace('h', '')) }).run(),
|
|
2315
|
+
undo: e => e.chain().focus().undo().run(),
|
|
2316
|
+
redo: e => e.chain().focus().redo().run(),
|
|
2317
|
+
createLink: e => {
|
|
2318
|
+
const url = prompt('Link address');
|
|
2319
|
+
if (url) e.chain().focus().setLink({ href: url }).run();
|
|
2320
|
+
}
|
|
2321
|
+
};
|
|
2322
|
+
const STATE = { bold: 'bold', italic: 'italic', underline: 'underline',
|
|
2323
|
+
strikeThrough: 'strike', insertUnorderedList: 'bulletList',
|
|
2324
|
+
insertOrderedList: 'orderedList' };
|
|
2325
|
+
const paint = e => $$('[data-cmd]', ed).forEach(b => {
|
|
2326
|
+
const name = STATE[b.dataset.cmd];
|
|
2327
|
+
if (name) b.setAttribute('aria-pressed', String(e.isActive(name)));
|
|
2328
|
+
});
|
|
2329
|
+
|
|
2330
|
+
ed.addEventListener('click', e => {
|
|
2331
|
+
const b = e.target.closest('[data-cmd]');
|
|
2332
|
+
if (!b) return;
|
|
2333
|
+
e.preventDefault();
|
|
2334
|
+
CMD[b.dataset.cmd]?.(editor, b.dataset.value);
|
|
2335
|
+
paint(editor);
|
|
2336
|
+
});
|
|
2337
|
+
$$('.editor-select', ed).forEach(sel =>
|
|
2338
|
+
sel.addEventListener('change', () => CMD.formatBlock(editor, sel.value)));
|
|
2339
|
+
ed.deckEditor = editor;
|
|
2340
|
+
count(editor.getText());
|
|
2341
|
+
|
|
2342
|
+
} else {
|
|
2343
|
+
const q = new window.Quill(content, {
|
|
2344
|
+
theme: null, // Deck supplies the chrome
|
|
2345
|
+
placeholder: content.dataset.placeholder || '',
|
|
2346
|
+
modules: { toolbar: false }
|
|
2347
|
+
});
|
|
2348
|
+
if (target?.value) q.clipboard.dangerouslyPasteHTML(target.value);
|
|
2349
|
+
|
|
2350
|
+
const paint = () => {
|
|
2351
|
+
const f = q.getFormat();
|
|
2352
|
+
$$('[data-cmd]', ed).forEach(b => {
|
|
2353
|
+
const map = { bold: 'bold', italic: 'italic', underline: 'underline',
|
|
2354
|
+
strikeThrough: 'strike' };
|
|
2355
|
+
const key = map[b.dataset.cmd];
|
|
2356
|
+
if (key) b.setAttribute('aria-pressed', String(!!f[key]));
|
|
2357
|
+
if (b.dataset.cmd === 'insertUnorderedList')
|
|
2358
|
+
b.setAttribute('aria-pressed', String(f.list === 'bullet'));
|
|
2359
|
+
if (b.dataset.cmd === 'insertOrderedList')
|
|
2360
|
+
b.setAttribute('aria-pressed', String(f.list === 'ordered'));
|
|
2361
|
+
});
|
|
2362
|
+
};
|
|
2363
|
+
|
|
2364
|
+
const CMD = {
|
|
2365
|
+
bold: () => q.format('bold', !q.getFormat().bold),
|
|
2366
|
+
italic: () => q.format('italic', !q.getFormat().italic),
|
|
2367
|
+
underline: () => q.format('underline', !q.getFormat().underline),
|
|
2368
|
+
strikeThrough: () => q.format('strike', !q.getFormat().strike),
|
|
2369
|
+
insertUnorderedList: () => q.format('list', q.getFormat().list === 'bullet' ? false : 'bullet'),
|
|
2370
|
+
insertOrderedList: () => q.format('list', q.getFormat().list === 'ordered' ? false : 'ordered'),
|
|
2371
|
+
formatBlock: v => q.format('header', v === 'p' ? false : Number(v.replace('h', ''))),
|
|
2372
|
+
undo: () => q.history.undo(),
|
|
2373
|
+
redo: () => q.history.redo(),
|
|
2374
|
+
createLink: () => { const u = prompt('Link address'); if (u) q.format('link', u); }
|
|
2375
|
+
};
|
|
2376
|
+
|
|
2377
|
+
ed.addEventListener('click', e => {
|
|
2378
|
+
const b = e.target.closest('[data-cmd]');
|
|
2379
|
+
if (!b) return;
|
|
2380
|
+
e.preventDefault();
|
|
2381
|
+
CMD[b.dataset.cmd]?.(b.dataset.value);
|
|
2382
|
+
paint();
|
|
2383
|
+
});
|
|
2384
|
+
$$('.editor-select', ed).forEach(sel =>
|
|
2385
|
+
sel.addEventListener('change', () => CMD.formatBlock(sel.value)));
|
|
2386
|
+
|
|
2387
|
+
q.on('text-change', () => {
|
|
2388
|
+
if (target) target.value = q.root.innerHTML;
|
|
2389
|
+
count(q.getText());
|
|
2390
|
+
ed.dispatchEvent(new CustomEvent('deck:change', {
|
|
2391
|
+
bubbles: true, detail: { html: q.root.innerHTML, text: q.getText() }
|
|
2392
|
+
}));
|
|
2393
|
+
});
|
|
2394
|
+
q.on('selection-change', paint);
|
|
2395
|
+
ed.deckEditor = q;
|
|
2396
|
+
count(q.getText());
|
|
2397
|
+
}
|
|
2398
|
+
});
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
/* ======================================================================
|
|
2402
|
+
3. Chart.js — themed from Deck's tokens
|
|
2403
|
+
Deck's CSS charts are good for dashboard tiles and they retheme with the
|
|
2404
|
+
hue slider. What they cannot do is a time axis, a crosshair, a zoom, or
|
|
2405
|
+
twenty thousand points. Where Chart.js is loaded, this makes it match
|
|
2406
|
+
Deck exactly and follow the theme and the hue at runtime.
|
|
2407
|
+
====================================================================== */
|
|
2408
|
+
|
|
2409
|
+
function chartTheme() {
|
|
2410
|
+
if (!have.chart) return null;
|
|
2411
|
+
const Chart = window.Chart;
|
|
2412
|
+
|
|
2413
|
+
const series = i => css(`--c${(i % 6) + 1}`) || css('--brand-500');
|
|
2414
|
+
|
|
2415
|
+
const apply = () => {
|
|
2416
|
+
Chart.defaults.font.family = css('--font-sans');
|
|
2417
|
+
Chart.defaults.font.size = 12;
|
|
2418
|
+
Chart.defaults.color = css('--text-muted');
|
|
2419
|
+
Chart.defaults.borderColor = css('--line');
|
|
2420
|
+
Chart.defaults.elements.line.tension = 0.3;
|
|
2421
|
+
Chart.defaults.elements.line.borderWidth = 2;
|
|
2422
|
+
Chart.defaults.elements.point.radius = 0;
|
|
2423
|
+
Chart.defaults.elements.point.hoverRadius = 5;
|
|
2424
|
+
Chart.defaults.elements.bar.borderRadius = 4;
|
|
2425
|
+
Chart.defaults.elements.bar.borderSkipped = 'bottom';
|
|
2426
|
+
|
|
2427
|
+
Chart.defaults.plugins.legend.labels.usePointStyle = true;
|
|
2428
|
+
Chart.defaults.plugins.legend.labels.boxWidth = 8;
|
|
2429
|
+
Chart.defaults.plugins.legend.labels.boxHeight = 8;
|
|
2430
|
+
Chart.defaults.plugins.legend.labels.padding = 14;
|
|
2431
|
+
|
|
2432
|
+
Object.assign(Chart.defaults.plugins.tooltip, {
|
|
2433
|
+
backgroundColor: css('--ink-900'),
|
|
2434
|
+
titleColor: css('--ink-50'),
|
|
2435
|
+
bodyColor: css('--ink-100'),
|
|
2436
|
+
borderColor: 'transparent',
|
|
2437
|
+
cornerRadius: 6,
|
|
2438
|
+
padding: 10,
|
|
2439
|
+
displayColors: true,
|
|
2440
|
+
usePointStyle: true,
|
|
2441
|
+
boxPadding: 4,
|
|
2442
|
+
titleFont: { weight: '620' }
|
|
2443
|
+
});
|
|
2444
|
+
|
|
2445
|
+
Chart.defaults.scale.grid.color = css('--line');
|
|
2446
|
+
Chart.defaults.scale.grid.drawTicks = false;
|
|
2447
|
+
Chart.defaults.scale.border = { display: false };
|
|
2448
|
+
Chart.defaults.scale.ticks.padding = 8;
|
|
2449
|
+
};
|
|
2450
|
+
|
|
2451
|
+
apply();
|
|
2452
|
+
|
|
2453
|
+
// Follow theme and hue changes without rebuilding the chart
|
|
2454
|
+
const repaint = () => {
|
|
2455
|
+
apply();
|
|
2456
|
+
Object.values(Chart.instances || {}).forEach(c => {
|
|
2457
|
+
c.data.datasets.forEach((d, i) => {
|
|
2458
|
+
if (d.deckSeries !== false) {
|
|
2459
|
+
d.borderColor = series(i);
|
|
2460
|
+
d.backgroundColor = d.fill ? `color-mix(in oklab, ${series(i)} 18%, transparent)` : series(i);
|
|
2461
|
+
}
|
|
2462
|
+
});
|
|
2463
|
+
c.update('none');
|
|
2464
|
+
});
|
|
2465
|
+
};
|
|
2466
|
+
new MutationObserver(repaint).observe(document.documentElement, {
|
|
2467
|
+
attributes: true, attributeFilter: ['data-theme', 'style']
|
|
2468
|
+
});
|
|
2469
|
+
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', repaint);
|
|
2470
|
+
|
|
2471
|
+
// Convenience: Deck.chart(canvas, config) applies the series palette
|
|
2472
|
+
Deck.chart = (canvas, config) => {
|
|
2473
|
+
(config.data?.datasets || []).forEach((d, i) => {
|
|
2474
|
+
if (d.borderColor == null) d.borderColor = series(i);
|
|
2475
|
+
if (d.backgroundColor == null) {
|
|
2476
|
+
d.backgroundColor = d.fill
|
|
2477
|
+
? `color-mix(in oklab, ${series(i)} 18%, transparent)`
|
|
2478
|
+
: series(i);
|
|
2479
|
+
}
|
|
2480
|
+
});
|
|
2481
|
+
return new Chart(canvas, config);
|
|
2482
|
+
};
|
|
2483
|
+
|
|
2484
|
+
return { apply, repaint, series };
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
/* ======================================================================
|
|
2488
|
+
4. SortableJS — drag reordering and kanban
|
|
2489
|
+
Deck's CSS already uses SortableJS's default class names, so no
|
|
2490
|
+
configuration is needed. Without the library, the fallback below uses the
|
|
2491
|
+
native HTML drag and drop API, which is worse on touch but costs nothing.
|
|
2492
|
+
====================================================================== */
|
|
2493
|
+
|
|
2494
|
+
function sortables(root) {
|
|
2495
|
+
$$('[data-deck-sortable]', root).forEach(list => {
|
|
2496
|
+
if (!once(list)) return;
|
|
2497
|
+
const group = list.dataset.deckSortable || undefined;
|
|
2498
|
+
const handle = list.dataset.handle || null;
|
|
2499
|
+
|
|
2500
|
+
const emit = detail => list.dispatchEvent(
|
|
2501
|
+
new CustomEvent('deck:reorder', { bubbles: true, detail }));
|
|
2502
|
+
|
|
2503
|
+
if (have.sortable) {
|
|
2504
|
+
window.Sortable.create(list, {
|
|
2505
|
+
group: group && group !== 'true' ? { name: group, pull: true, put: true } : undefined,
|
|
2506
|
+
handle,
|
|
2507
|
+
animation: Deck.reduced?.() ? 0 : 180,
|
|
2508
|
+
easing: 'cubic-bezier(.22,1,.36,1)',
|
|
2509
|
+
ghostClass: 'sortable-ghost',
|
|
2510
|
+
chosenClass: 'sortable-chosen',
|
|
2511
|
+
dragClass: 'sortable-drag',
|
|
2512
|
+
forceFallback: false,
|
|
2513
|
+
fallbackOnBody: true,
|
|
2514
|
+
swapThreshold: 0.66,
|
|
2515
|
+
delay: matchMedia('(pointer: coarse)').matches ? 140 : 0,
|
|
2516
|
+
delayOnTouchOnly: true,
|
|
2517
|
+
onEnd: e => {
|
|
2518
|
+
counts();
|
|
2519
|
+
emit({
|
|
2520
|
+
item: e.item, from: e.from, to: e.to,
|
|
2521
|
+
oldIndex: e.oldIndex, newIndex: e.newIndex,
|
|
2522
|
+
order: Array.from(e.to.children).map(c => c.dataset.id ?? null)
|
|
2523
|
+
});
|
|
2524
|
+
}
|
|
2525
|
+
});
|
|
2526
|
+
return;
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
// Native fallback
|
|
2530
|
+
Array.from(list.children).forEach(child => { child.draggable = true; });
|
|
2531
|
+
let dragged = null;
|
|
2532
|
+
list.addEventListener('dragstart', e => {
|
|
2533
|
+
dragged = e.target.closest('[draggable="true"]');
|
|
2534
|
+
if (!dragged) return;
|
|
2535
|
+
dragged.classList.add('sortable-chosen');
|
|
2536
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
2537
|
+
e.dataTransfer.setData('text/plain', dragged.dataset.id || '');
|
|
2538
|
+
});
|
|
2539
|
+
list.addEventListener('dragover', e => {
|
|
2540
|
+
e.preventDefault();
|
|
2541
|
+
const over = e.target.closest('[draggable="true"]');
|
|
2542
|
+
if (!over || over === dragged || !dragged) return;
|
|
2543
|
+
const r = over.getBoundingClientRect();
|
|
2544
|
+
const after = (e.clientY - r.top) / r.height > 0.5;
|
|
2545
|
+
over.parentElement.insertBefore(dragged, after ? over.nextSibling : over);
|
|
2546
|
+
});
|
|
2547
|
+
list.addEventListener('dragend', () => {
|
|
2548
|
+
dragged?.classList.remove('sortable-chosen');
|
|
2549
|
+
counts();
|
|
2550
|
+
emit({ item: dragged, from: list, to: list,
|
|
2551
|
+
order: Array.from(list.children).map(c => c.dataset.id ?? null) });
|
|
2552
|
+
dragged = null;
|
|
2553
|
+
});
|
|
2554
|
+
});
|
|
2555
|
+
|
|
2556
|
+
function counts() {
|
|
2557
|
+
$$('.kanban-col').forEach(col => {
|
|
2558
|
+
const n = $$('.kanban-card', col).length;
|
|
2559
|
+
const badge = $('.kanban-count', col);
|
|
2560
|
+
if (badge) badge.textContent = n;
|
|
2561
|
+
});
|
|
2562
|
+
}
|
|
2563
|
+
counts();
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
/* ======================================================================
|
|
2567
|
+
5. Lucide — 1500 icons instead of Deck's 56
|
|
2568
|
+
Deck's sprite covers the framework's own needs and the automotive set it
|
|
2569
|
+
was built for. For anything beyond that, write the Lucide name and this
|
|
2570
|
+
swaps in the path data, keeping Deck's .icon sizing and stroke rules.
|
|
2571
|
+
====================================================================== */
|
|
2572
|
+
|
|
2573
|
+
function lucideIcons(root) {
|
|
2574
|
+
if (!have.lucide) return;
|
|
2575
|
+
const toPascal = s => s.replace(/(^\w|-\w)/g, m => m.replace('-', '').toUpperCase());
|
|
2576
|
+
|
|
2577
|
+
$$('[data-icon]', root).forEach(node => {
|
|
2578
|
+
if (!once(node)) return;
|
|
2579
|
+
const name = node.dataset.icon;
|
|
2580
|
+
const node_ = window.lucide.icons?.[toPascal(name)] || window.lucide[toPascal(name)];
|
|
2581
|
+
if (!node_) { console.warn(`deck: no Lucide icon "${name}"`); return; }
|
|
2582
|
+
const children = Array.isArray(node_) ? node_[2] : node_.children || [];
|
|
2583
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
2584
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
2585
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
2586
|
+
svg.setAttribute('class', node.className || 'icon');
|
|
2587
|
+
children.forEach(([tag, attrs]) => {
|
|
2588
|
+
const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
|
2589
|
+
Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v));
|
|
2590
|
+
svg.append(el);
|
|
2591
|
+
});
|
|
2592
|
+
node.replaceWith(svg);
|
|
2593
|
+
});
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
/* ======================================================================
|
|
2597
|
+
Register
|
|
2598
|
+
====================================================================== */
|
|
2599
|
+
|
|
2600
|
+
const baseInit = Deck.init.bind(Deck);
|
|
2601
|
+
Deck.init = function (root = document) {
|
|
2602
|
+
baseInit(root);
|
|
2603
|
+
floating(root);
|
|
2604
|
+
editorAdapter(root);
|
|
2605
|
+
sortables(root);
|
|
2606
|
+
lucideIcons(root);
|
|
2607
|
+
return Deck;
|
|
2608
|
+
};
|
|
2609
|
+
|
|
2610
|
+
Deck.adapters = {
|
|
2611
|
+
get available() {
|
|
2612
|
+
return Object.fromEntries(Object.keys(have).map(k => [k, have[k]]));
|
|
2613
|
+
},
|
|
2614
|
+
/* What is actually doing the work right now. Useful in a console when a
|
|
2615
|
+
component behaves differently between two pages. */
|
|
2616
|
+
report() {
|
|
2617
|
+
const a = this.available;
|
|
2618
|
+
return {
|
|
2619
|
+
placement: hasAnchor ? 'CSS anchor positioning'
|
|
2620
|
+
: a.floating ? 'Floating UI'
|
|
2621
|
+
: 'Deck built-in',
|
|
2622
|
+
editor: a.tiptap ? 'Tiptap' : a.quill ? 'Quill' : 'Deck built-in (execCommand)',
|
|
2623
|
+
charts: a.chart ? 'Chart.js, themed by Deck' : 'Deck CSS charts',
|
|
2624
|
+
dragDrop: a.sortable ? 'SortableJS' : 'native drag and drop',
|
|
2625
|
+
icons: a.lucide ? 'Lucide + Deck sprite' : 'Deck sprite (74 icons)',
|
|
2626
|
+
virtualization: CSS.supports('content-visibility: auto')
|
|
2627
|
+
? 'content-visibility (engine)' : 'none'
|
|
2628
|
+
};
|
|
2629
|
+
}
|
|
2630
|
+
};
|
|
2631
|
+
|
|
2632
|
+
if (have.chart) Deck.chartTheme = chartTheme();
|
|
2633
|
+
|
|
2634
|
+
if (document.readyState !== 'loading') Deck.init();
|
|
2635
|
+
else document.addEventListener('DOMContentLoaded', () => Deck.init());
|
|
2636
|
+
|
|
2637
|
+
})(typeof window !== 'undefined' && window.Deck ? window.Deck
|
|
2638
|
+
: typeof Deck !== 'undefined' ? Deck : null);
|