@xoong/controls 0.0.0-stage → 0.0.34
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 +28 -2
- package/package.json +56 -4
- package/src/carousel.js +248 -0
- package/src/combobox.js +364 -0
- package/src/controls.css +68 -0
- package/src/copy.js +60 -0
- package/src/date.js +254 -0
- package/src/index.js +13 -0
- package/src/layer.js +46 -0
- package/src/menu.js +150 -0
- package/src/password.js +53 -0
- package/src/slider.js +47 -0
- package/src/stepper.js +85 -0
- package/src/switch.js +71 -0
- package/src/tabs.js +119 -0
- package/src/time.js +68 -0
- package/src/tooltip.js +79 -0
package/src/date.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* <xo-date> is a date field with a calendar popover; `range` picks a start and an end.
|
|
4
|
+
*
|
|
5
|
+
* <xo-date name="publishedAt" label="Publish on" min="2026-01-01" max="2026-12-31"></xo-date>
|
|
6
|
+
* <xo-date name="stay" label="Stay" range></xo-date>
|
|
7
|
+
*
|
|
8
|
+
* - The value is ISO `YYYY-MM-DD` (a range: `{ start, end }`); the field shows the date in the user's locale (`Intl`) and also accepts typing
|
|
9
|
+
* (the locale order, or ISO). The calendar is the picker: a click on a day chooses it.
|
|
10
|
+
* - Enter or ArrowDown opens. In the calendar: arrows move by day and week, PageUp/PageDown by month, Home/End to the start/end of the week,
|
|
11
|
+
* Escape closes and returns the focus to the field. Shift+arrow extends a range.
|
|
12
|
+
* - The calendar is a `role=dialog` named "Choose date"; days are buttons (`aria-pressed` when chosen, `aria-current=date` for today), days
|
|
13
|
+
* outside `min`/`max` are disabled. A range shows its length in a polite live region with Clear and Apply.
|
|
14
|
+
* - `week-start` (1 = Monday ... 7 = Sunday) defaults to the locale's first day of the week. `change` fires when the person chooses.
|
|
15
|
+
*/
|
|
16
|
+
import { XoControl, define, text } from '@xoong/core';
|
|
17
|
+
import { prepareLayer, showLayer, hideLayer } from './layer.js';
|
|
18
|
+
|
|
19
|
+
let counter = 0;
|
|
20
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
21
|
+
const iso = (/** @type {Date} */ d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
22
|
+
const day = (/** @type {string} */ s) => { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s ?? ''); if (!m) return null; const d = new Date(+m[1], +m[2] - 1, +m[3], 12); return iso(d) === s ? d : null; };
|
|
23
|
+
const add = (/** @type {Date} */ d, /** @type {number} */ days) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + days, 12);
|
|
24
|
+
const months = (/** @type {Date} */ d, /** @type {number} */ n) => { const t = new Date(d.getFullYear(), d.getMonth() + n, 1, 12); t.setDate(Math.min(d.getDate(), new Date(t.getFullYear(), t.getMonth() + 1, 0).getDate())); return t; };
|
|
25
|
+
const empty = { start: '', end: '' };
|
|
26
|
+
|
|
27
|
+
class XoDate extends XoControl {
|
|
28
|
+
static props = { ...XoControl.props, label: String, min: String, max: String, range: Boolean, placeholder: String, weekStart: Number };
|
|
29
|
+
|
|
30
|
+
/** @type {HTMLInputElement | null} */ #input = null;
|
|
31
|
+
/** @type {HTMLButtonElement | null} */ #opener = null;
|
|
32
|
+
/** @type {HTMLElement | null} */ #panel = null;
|
|
33
|
+
#open = false;
|
|
34
|
+
#id = `xo-date-${++counter}`;
|
|
35
|
+
#cursor = new Date();
|
|
36
|
+
/** @type {string} */ #pending = ''; // a range start chosen, waiting for its end
|
|
37
|
+
#draft = { ...empty }; // a range being picked, applied with Apply
|
|
38
|
+
#stop = () => {};
|
|
39
|
+
#onDocument = (/** @type {Event} */ e) => { if (!this.contains(/** @type {Node} */ (e.target))) this.#close(false); };
|
|
40
|
+
|
|
41
|
+
setValue(v) { super.setValue(this.#clean(v)); this.#showText(); }
|
|
42
|
+
#clean(v) {
|
|
43
|
+
if (this.range) { const o = v && typeof v === 'object' ? v : {}; return { start: day(o.start) ? o.start : '', end: day(o.end) ? o.end : '' }; }
|
|
44
|
+
return day(v) ? v : '';
|
|
45
|
+
}
|
|
46
|
+
serialize(v) { return this.range ? (v.start ? JSON.stringify(v) : null) : v || null; }
|
|
47
|
+
isEmpty(v) { return this.range ? !v?.start : !v; }
|
|
48
|
+
get focusTarget() { return this.#input; }
|
|
49
|
+
validate(v) {
|
|
50
|
+
const [lo, hi] = [this.min, this.max];
|
|
51
|
+
for (const d of this.range ? [v.start, v.end] : [v]) { if (d && ((lo && d < lo) || (hi && d > hi))) return text('xoong.controls.dateRange', 'Choose a date within the allowed range.'); }
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
connected() {
|
|
56
|
+
const wrap = document.createElement('div');
|
|
57
|
+
wrap.className = 'xo-input-wrap xo-input-wrap--end';
|
|
58
|
+
const input = document.createElement('input');
|
|
59
|
+
input.type = 'text';
|
|
60
|
+
input.autocomplete = 'off';
|
|
61
|
+
input.setAttribute('aria-label', this.label || this.name || text('xoong.controls.date', 'Date'));
|
|
62
|
+
input.setAttribute('aria-haspopup', 'dialog');
|
|
63
|
+
input.setAttribute('aria-expanded', 'false');
|
|
64
|
+
input.placeholder = this.placeholder || (this.range ? text('xoong.controls.dateRangeHint', 'Start - end') : this.#sample());
|
|
65
|
+
const opener = document.createElement('button');
|
|
66
|
+
opener.type = 'button';
|
|
67
|
+
opener.className = 'xo-date-open';
|
|
68
|
+
opener.setAttribute('aria-label', text('xoong.controls.chooseDate', 'Choose date'));
|
|
69
|
+
opener.innerHTML = '<span class="ic" data-ic="calendar" aria-hidden="true"></span>';
|
|
70
|
+
opener.addEventListener('click', () => (this.#open ? this.#close(true) : this.#openCalendar()));
|
|
71
|
+
wrap.append(input, opener);
|
|
72
|
+
const panel = document.createElement('div');
|
|
73
|
+
panel.className = 'xo-float xo-date-panel';
|
|
74
|
+
panel.id = `${this.#id}-panel`;
|
|
75
|
+
panel.setAttribute('role', 'dialog');
|
|
76
|
+
panel.setAttribute('aria-label', text('xoong.controls.chooseDate', 'Choose date'));
|
|
77
|
+
prepareLayer(panel);
|
|
78
|
+
panel.addEventListener('keydown', (e) => this.#panelKey(e));
|
|
79
|
+
this.replaceChildren(wrap, panel);
|
|
80
|
+
this.#input = input; this.#opener = opener; this.#panel = panel;
|
|
81
|
+
input.addEventListener('keydown', (e) => {
|
|
82
|
+
if (e.key === 'ArrowDown' || (e.key === 'Enter' && !this.#open && !input.value.trim())) { e.preventDefault(); this.#openCalendar(); }
|
|
83
|
+
else if (e.key === 'Enter') this.#commitText();
|
|
84
|
+
else if (e.key === 'Escape' && this.#open) { e.stopPropagation(); this.#close(true); }
|
|
85
|
+
});
|
|
86
|
+
input.addEventListener('change', () => this.#commitText());
|
|
87
|
+
queueMicrotask(() => this.#showText()); // the base class applies a `value` attribute after connected()
|
|
88
|
+
this.#sync2();
|
|
89
|
+
}
|
|
90
|
+
disconnected() { document.removeEventListener('pointerdown', this.#onDocument); this.#stop(); }
|
|
91
|
+
|
|
92
|
+
attributeChangedCallback(attr, old, value) { super.attributeChangedCallback(attr, old, value); this.#sync2(); }
|
|
93
|
+
#sync2() { if (this.#input) { this.#input.disabled = this.isDisabled; this.#input.readOnly = this.readonly; if (this.#opener) this.#opener.disabled = this.isDisabled || this.readonly; } }
|
|
94
|
+
|
|
95
|
+
// ---- text <-> value ------------------------------------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
get #locale() { return this.closest('[lang]')?.getAttribute('lang') || document.documentElement.lang || undefined; }
|
|
98
|
+
#fmt(/** @type {string} */ s) { const d = day(s); return d ? new Intl.DateTimeFormat(this.#locale, { day: '2-digit', month: '2-digit', year: 'numeric' }).format(d) : ''; }
|
|
99
|
+
#sample() { return this.#fmt('2026-11-25') || 'YYYY-MM-DD'; }
|
|
100
|
+
#showText() {
|
|
101
|
+
if (!this.#input) return;
|
|
102
|
+
const v = super.value;
|
|
103
|
+
this.#input.value = this.range ? (v?.start ? `${this.#fmt(v.start)} - ${v.end ? this.#fmt(v.end) : ''}` : '') : this.#fmt(v);
|
|
104
|
+
}
|
|
105
|
+
/** Read a typed date: ISO, or numbers in the locale's day/month/year order. */
|
|
106
|
+
#parse(/** @type {string} */ s) {
|
|
107
|
+
s = s.trim();
|
|
108
|
+
if (day(s)) return s;
|
|
109
|
+
const n = s.match(/\d+/g);
|
|
110
|
+
if (!n || n.length !== 3) return '';
|
|
111
|
+
const order = new Intl.DateTimeFormat(this.#locale).formatToParts(new Date(2026, 10, 25)).filter((p) => p.type === 'day' || p.type === 'month' || p.type === 'year').map((p) => p.type);
|
|
112
|
+
const f = Object.fromEntries(order.map((k, i) => [k, +n[i]]));
|
|
113
|
+
if (f.year < 100) f.year += 2000;
|
|
114
|
+
const r = `${f.year}-${pad(f.month)}-${pad(f.day)}`;
|
|
115
|
+
return day(r) ? r : '';
|
|
116
|
+
}
|
|
117
|
+
#commitText() {
|
|
118
|
+
const raw = /** @type {HTMLInputElement} */ (this.#input).value.trim();
|
|
119
|
+
let v;
|
|
120
|
+
if (this.range) { const parts = raw.split(/\s+[-–—]\s+|\s*\/\s*/); const a = raw ? this.#parse(parts[0]) : ''; const b = parts[1] ? this.#parse(parts[1]) : ''; v = a ? (b && b < a ? { start: b, end: a } : { start: a, end: b }) : { ...empty }; }
|
|
121
|
+
else v = raw ? this.#parse(raw) : '';
|
|
122
|
+
if (raw && (this.range ? !v.start : !v)) { this.#showText(); return; } // not a date: keep what was chosen
|
|
123
|
+
this.#choose(v);
|
|
124
|
+
}
|
|
125
|
+
#choose(v) {
|
|
126
|
+
this.setValue(v);
|
|
127
|
+
this.userInput(super.value);
|
|
128
|
+
this.userChange();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---- calendar --------------------------------------------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
get #first() { return this.weekStart || (() => { try { return /** @type {any} */ (new Intl.Locale(this.#locale || navigator.language)).weekInfo?.firstDay ?? 1; } catch { return 1; } })(); }
|
|
134
|
+
|
|
135
|
+
#openCalendar() {
|
|
136
|
+
if (this.isDisabled || this.readonly || this.#open) return;
|
|
137
|
+
const v = super.value;
|
|
138
|
+
this.#draft = this.range ? { ...v } : { ...empty };
|
|
139
|
+
this.#pending = '';
|
|
140
|
+
this.#cursor = day(this.range ? v.start : v) ?? new Date();
|
|
141
|
+
this.#open = true;
|
|
142
|
+
this.#draw();
|
|
143
|
+
this.#stop = showLayer(/** @type {HTMLElement} */ (this.#panel), /** @type {HTMLElement} */ (this.#input).parentElement ?? this);
|
|
144
|
+
this.#input?.setAttribute('aria-expanded', 'true');
|
|
145
|
+
document.addEventListener('pointerdown', this.#onDocument);
|
|
146
|
+
this.#focusDay();
|
|
147
|
+
}
|
|
148
|
+
#close(refocus) {
|
|
149
|
+
if (!this.#open) return;
|
|
150
|
+
this.#open = false;
|
|
151
|
+
this.#stop();
|
|
152
|
+
hideLayer(/** @type {HTMLElement} */ (this.#panel));
|
|
153
|
+
this.#input?.setAttribute('aria-expanded', 'false');
|
|
154
|
+
document.removeEventListener('pointerdown', this.#onDocument);
|
|
155
|
+
if (refocus) this.#input?.focus();
|
|
156
|
+
}
|
|
157
|
+
#focusDay() { /** @type {HTMLElement | null} */ (this.#panel?.querySelector(`button[data-date="${iso(this.#cursor)}"]`))?.focus(); }
|
|
158
|
+
|
|
159
|
+
#out(/** @type {string} */ d) { return (!!this.min && d < this.min) || (!!this.max && d > this.max); }
|
|
160
|
+
|
|
161
|
+
#draw() {
|
|
162
|
+
const panel = /** @type {HTMLElement} */ (this.#panel);
|
|
163
|
+
const loc = this.#locale;
|
|
164
|
+
const y = this.#cursor.getFullYear(), m = this.#cursor.getMonth();
|
|
165
|
+
const head = document.createElement('div');
|
|
166
|
+
head.className = 'xo-date-head';
|
|
167
|
+
const nav = (/** @type {number} */ n, /** @type {string} */ label, /** @type {string} */ glyph) => {
|
|
168
|
+
const b = document.createElement('button');
|
|
169
|
+
b.type = 'button'; b.className = 'icon-only sm ghost'; b.setAttribute('aria-label', label); b.textContent = glyph;
|
|
170
|
+
b.addEventListener('click', () => { this.#cursor = months(this.#cursor, n); this.#draw(); this.#focusDay(); });
|
|
171
|
+
return b;
|
|
172
|
+
};
|
|
173
|
+
const title = document.createElement('b');
|
|
174
|
+
title.setAttribute('aria-live', 'polite');
|
|
175
|
+
title.textContent = new Intl.DateTimeFormat(loc, { month: 'long', year: 'numeric' }).format(new Date(y, m, 1, 12));
|
|
176
|
+
head.append(nav(-1, text('xoong.controls.prevMonth', 'Previous month'), '‹'), title, nav(1, text('xoong.controls.nextMonth', 'Next month'), '›'));
|
|
177
|
+
|
|
178
|
+
const grid = document.createElement('div');
|
|
179
|
+
grid.className = 'xo-calendar';
|
|
180
|
+
const first = this.#first;
|
|
181
|
+
const start = add(new Date(y, m, 1, 12), -((new Date(y, m, 1).getDay() || 7) - first + 7) % 7);
|
|
182
|
+
for (let i = 0; i < 7; i++) { const s = document.createElement('span'); s.textContent = new Intl.DateTimeFormat(loc, { weekday: 'short' }).format(add(start, i)).slice(0, 2); s.setAttribute('aria-hidden', 'true'); grid.append(s); }
|
|
183
|
+
const today = iso(new Date());
|
|
184
|
+
const v = super.value;
|
|
185
|
+
const [a, b] = this.range ? [this.#draft.start, this.#draft.end] : [v, ''];
|
|
186
|
+
for (let i = 0; i < 42; i++) {
|
|
187
|
+
const d = add(start, i), s = iso(d);
|
|
188
|
+
const btn = document.createElement('button');
|
|
189
|
+
btn.type = 'button';
|
|
190
|
+
btn.dataset.date = s;
|
|
191
|
+
btn.textContent = String(d.getDate());
|
|
192
|
+
btn.tabIndex = s === iso(this.#cursor) ? 0 : -1;
|
|
193
|
+
btn.setAttribute('aria-label', new Intl.DateTimeFormat(loc, { dateStyle: 'full' }).format(d));
|
|
194
|
+
if (d.getMonth() !== m) btn.classList.add('is-out');
|
|
195
|
+
if (s === today) { btn.classList.add('is-today'); btn.setAttribute('aria-current', 'date'); }
|
|
196
|
+
const sel = s === a || s === b;
|
|
197
|
+
if (sel) btn.classList.add('is-sel');
|
|
198
|
+
if (a && b && s > a && s < b) btn.classList.add('is-range');
|
|
199
|
+
btn.setAttribute('aria-pressed', String(sel));
|
|
200
|
+
if (this.#out(s)) btn.disabled = true;
|
|
201
|
+
btn.addEventListener('click', () => this.#pick(s));
|
|
202
|
+
grid.append(btn);
|
|
203
|
+
}
|
|
204
|
+
panel.replaceChildren(head, grid);
|
|
205
|
+
if (this.range) {
|
|
206
|
+
const foot = document.createElement('div');
|
|
207
|
+
foot.className = 'cal-foot';
|
|
208
|
+
const hint = document.createElement('span');
|
|
209
|
+
hint.className = 'hint'; hint.setAttribute('aria-live', 'polite'); hint.id = `${this.#id}-sum`;
|
|
210
|
+
hint.textContent = this.#summary();
|
|
211
|
+
const row = document.createElement('span');
|
|
212
|
+
row.className = 'row-inline';
|
|
213
|
+
const clear = document.createElement('button'); clear.type = 'button'; clear.className = 'sm'; clear.textContent = text('xoong.controls.clear', 'Clear');
|
|
214
|
+
clear.addEventListener('click', () => { this.#draft = { ...empty }; this.#pending = ''; this.#draw(); this.#focusDay(); });
|
|
215
|
+
const apply = document.createElement('button'); apply.type = 'button'; apply.className = 'sm primary'; apply.textContent = text('xoong.controls.apply', 'Apply');
|
|
216
|
+
apply.addEventListener('click', () => { this.#choose({ ...this.#draft }); this.#close(true); });
|
|
217
|
+
row.append(clear, apply);
|
|
218
|
+
foot.append(hint, row);
|
|
219
|
+
panel.append(foot);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
#summary() {
|
|
223
|
+
const { start, end } = this.#draft;
|
|
224
|
+
if (!start) return text('xoong.controls.pickStart', 'Pick the first day');
|
|
225
|
+
if (!end) return `${this.#fmt(start)} - ${text('xoong.controls.pickEnd', 'pick the last day')}`;
|
|
226
|
+
const n = Math.round((+day(end) - +day(start)) / 864e5) + 1;
|
|
227
|
+
return `${this.#fmt(start)} - ${this.#fmt(end)} · ${text('xoong.controls.days', '{n} days', { n })}`;
|
|
228
|
+
}
|
|
229
|
+
#pick(/** @type {string} */ s) {
|
|
230
|
+
if (this.#out(s)) return;
|
|
231
|
+
this.#cursor = /** @type {Date} */ (day(s));
|
|
232
|
+
if (!this.range) { this.#choose(s); this.#close(true); return; }
|
|
233
|
+
if (!this.#pending) { this.#pending = s; this.#draft = { start: s, end: '' }; }
|
|
234
|
+
else { const a = this.#pending; this.#pending = ''; this.#draft = a <= s ? { start: a, end: s } : { start: s, end: a }; }
|
|
235
|
+
this.#draw();
|
|
236
|
+
this.#focusDay();
|
|
237
|
+
}
|
|
238
|
+
#panelKey(/** @type {KeyboardEvent} */ e) {
|
|
239
|
+
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); this.#close(true); return; }
|
|
240
|
+
const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }[e.key];
|
|
241
|
+
let next = null;
|
|
242
|
+
if (step) next = add(this.#cursor, step);
|
|
243
|
+
else if (e.key === 'PageUp') next = months(this.#cursor, e.shiftKey ? -12 : -1);
|
|
244
|
+
else if (e.key === 'PageDown') next = months(this.#cursor, e.shiftKey ? 12 : 1);
|
|
245
|
+
else if (e.key === 'Home' || e.key === 'End') { const off = (this.#cursor.getDay() || 7) - this.#first; const w = ((off % 7) + 7) % 7; next = add(this.#cursor, e.key === 'Home' ? -w : 6 - w); }
|
|
246
|
+
if (!next || !(e.target instanceof HTMLButtonElement && e.target.dataset.date)) return;
|
|
247
|
+
e.preventDefault();
|
|
248
|
+
this.#cursor = next;
|
|
249
|
+
if (this.range && e.shiftKey && step && this.#pending) { const a = this.#pending, s = iso(next); this.#draft = a <= s ? { start: a, end: s } : { start: s, end: a }; }
|
|
250
|
+
this.#draw();
|
|
251
|
+
this.#focusDay();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
define('xo-date', XoDate);
|
package/src/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Importing the package registers every control.
|
|
2
|
+
import './tabs.js';
|
|
3
|
+
import './menu.js';
|
|
4
|
+
import './tooltip.js';
|
|
5
|
+
import './combobox.js';
|
|
6
|
+
import './carousel.js';
|
|
7
|
+
import './switch.js';
|
|
8
|
+
import './date.js';
|
|
9
|
+
import './time.js';
|
|
10
|
+
import './slider.js';
|
|
11
|
+
import './stepper.js';
|
|
12
|
+
import './password.js';
|
|
13
|
+
import './copy.js';
|
package/src/layer.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Where a popup (menu, option list) is drawn. With the Popover API it goes into the top layer, so a table, a dialog or a scroll area never
|
|
4
|
+
* clips it; the position is set through the CSS object model (allowed under a strict CSP, unlike a style attribute). Without the API the
|
|
5
|
+
* popup stays a normal element that `controls.css` positions under its anchor.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Prepare a popup element once. */
|
|
9
|
+
export function prepareLayer(/** @type {HTMLElement} */ panel) {
|
|
10
|
+
panel.hidden = true;
|
|
11
|
+
if ('showPopover' in panel) panel.setAttribute('popover', 'manual'); // manual: the control decides when it closes, the same in every browser
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Show the popup under `anchor`. Returns a function that stops following the anchor on scroll and resize.
|
|
16
|
+
* @param {HTMLElement} panel
|
|
17
|
+
* @param {HTMLElement} anchor
|
|
18
|
+
* @param {{ matchWidth?: boolean }} [options]
|
|
19
|
+
*/
|
|
20
|
+
export function showLayer(panel, anchor, { matchWidth = false } = {}) {
|
|
21
|
+
panel.hidden = false;
|
|
22
|
+
const top = panel.hasAttribute('popover');
|
|
23
|
+
if (top && !panel.matches(':popover-open')) panel.showPopover();
|
|
24
|
+
const place = () => {
|
|
25
|
+
if (!top) return;
|
|
26
|
+
const r = anchor.getBoundingClientRect();
|
|
27
|
+
const room = (globalThis.innerHeight || 768) - r.bottom;
|
|
28
|
+
const height = panel.getBoundingClientRect().height;
|
|
29
|
+
const flip = room < height + 8 && r.top > room; // not enough room below and more above: open upwards
|
|
30
|
+
Object.assign(panel.style, {
|
|
31
|
+
position: 'fixed', inset: 'auto', margin: '0', left: `${r.left}px`,
|
|
32
|
+
top: flip ? `${Math.max(4, r.top - height)}px` : `${r.bottom}px`,
|
|
33
|
+
...(matchWidth ? { width: `${r.width}px`, boxSizing: 'border-box' } : {}),
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
place();
|
|
37
|
+
if (!top) return () => {};
|
|
38
|
+
addEventListener('resize', place);
|
|
39
|
+
addEventListener('scroll', place, true);
|
|
40
|
+
return () => { removeEventListener('resize', place); removeEventListener('scroll', place, true); };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function hideLayer(/** @type {HTMLElement} */ panel) {
|
|
44
|
+
if (panel.hasAttribute('popover') && panel.matches(':popover-open')) panel.hidePopover();
|
|
45
|
+
panel.hidden = true;
|
|
46
|
+
}
|
package/src/menu.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* <xo-menu> is an action menu (the WAI-ARIA menu button pattern): one trigger and a list of items.
|
|
4
|
+
*
|
|
5
|
+
* <xo-menu label="Row actions">
|
|
6
|
+
* <button xo-menu-trigger>Actions</button>
|
|
7
|
+
* <button xo-click="POST /users/{id}/disable">Disable</button>
|
|
8
|
+
* <a href="/users/{id}">Open</a>
|
|
9
|
+
* </xo-menu>
|
|
10
|
+
*
|
|
11
|
+
* - The trigger is the child with `xo-menu-trigger` (the first button when none has it); every other child is an item.
|
|
12
|
+
* - Open with a click, Enter, Space or ArrowDown (ArrowUp opens on the last item). Inside: ArrowUp/ArrowDown (wrapping), Home, End,
|
|
13
|
+
* typing a letter jumps to the next item that starts with it. Escape closes and returns the focus to the trigger; Tab closes.
|
|
14
|
+
* Choosing an item (click or Enter) closes the menu and runs the item as usual (xo-click, a link, a click handler).
|
|
15
|
+
* - Where the browser has the Popover API the list is drawn in the top layer, so it is never clipped by a table or a scroll area.
|
|
16
|
+
* - `xo-menu-open` and `xo-menu-close` fire on the element. An `<hr>` between items is a divider (role=separator, skipped by the keys).
|
|
17
|
+
* - A choice menu: items with a `value` attribute are radio items (`menuitemradio`, `aria-checked`); the menu's `value` says which one is on.
|
|
18
|
+
* Choosing one sets `value` (an attribute, so CSS can read it) and fires `xo-menu-select` with `{ value }`.
|
|
19
|
+
*
|
|
20
|
+
* <xo-menu label="Palette" value="calm"><button xo-menu-trigger>Palette</button><button value="calm">Calm</button><button value="ember">Ember</button></xo-menu>
|
|
21
|
+
*/
|
|
22
|
+
import { XoElement, define } from '@xoong/core';
|
|
23
|
+
import { prepareLayer, showLayer, hideLayer } from './layer.js';
|
|
24
|
+
|
|
25
|
+
let counter = 0;
|
|
26
|
+
|
|
27
|
+
class XoMenu extends XoElement {
|
|
28
|
+
static props = { label: String, value: String };
|
|
29
|
+
|
|
30
|
+
/** @type {HTMLElement | null} */ #trigger = null;
|
|
31
|
+
/** @type {HTMLElement | null} */ #panel = null;
|
|
32
|
+
#stopFollowing = () => {};
|
|
33
|
+
#onDocument = (/** @type {Event} */ event) => { if (!this.contains(/** @type {Node} */ (event.target))) this.close(); };
|
|
34
|
+
// Escape works wherever the focus is: a click leaves it on the trigger (or on the page, in Safari, which does not focus buttons on click)
|
|
35
|
+
#onEscape = (/** @type {KeyboardEvent} */ event) => { if (event.key === 'Escape' && this.isOpen) { event.preventDefault(); this.close({ focus: true }); } };
|
|
36
|
+
|
|
37
|
+
connected() {
|
|
38
|
+
this.effect(() => this.#check(this.value));
|
|
39
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => this.#build(), { once: true });
|
|
40
|
+
else queueMicrotask(() => this.#build());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
disconnected() { document.removeEventListener('pointerdown', this.#onDocument); document.removeEventListener('keydown', this.#onEscape); }
|
|
44
|
+
|
|
45
|
+
get isOpen() { return !!this.#panel && !this.#panel.hidden; }
|
|
46
|
+
get #items() { return /** @type {HTMLElement[]} */ ([...(this.#panel?.children ?? [])]).filter((i) => i.getAttribute('role') !== 'separator'); }
|
|
47
|
+
|
|
48
|
+
#build() {
|
|
49
|
+
if (this.#panel || !this.isConnected) return;
|
|
50
|
+
const children = /** @type {HTMLElement[]} */ ([...this.children]);
|
|
51
|
+
const trigger = children.find((c) => c.hasAttribute('xo-menu-trigger')) ?? children.find((c) => c.localName === 'button');
|
|
52
|
+
if (!trigger) { console.error('xoong: <xo-menu> needs a trigger button (xo-menu-trigger)'); return; }
|
|
53
|
+
const id = `xo-menu-${++counter}`;
|
|
54
|
+
const panel = document.createElement('div');
|
|
55
|
+
panel.id = id;
|
|
56
|
+
panel.setAttribute('role', 'menu');
|
|
57
|
+
if (this.label) panel.setAttribute('aria-label', this.label);
|
|
58
|
+
for (const item of children) {
|
|
59
|
+
if (item === trigger) continue;
|
|
60
|
+
if (item.localName === 'hr') { item.setAttribute('role', 'separator'); panel.append(item); continue; } // a divider: not an item, skipped by the keys
|
|
61
|
+
item.setAttribute('role', item.hasAttribute('value') ? 'menuitemradio' : 'menuitem');
|
|
62
|
+
item.tabIndex = -1;
|
|
63
|
+
panel.append(item);
|
|
64
|
+
}
|
|
65
|
+
this.append(panel);
|
|
66
|
+
trigger.setAttribute('aria-haspopup', 'menu');
|
|
67
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
68
|
+
trigger.setAttribute('aria-controls', id);
|
|
69
|
+
if (trigger instanceof HTMLButtonElement && !trigger.type) trigger.type = 'button';
|
|
70
|
+
this.#trigger = trigger;
|
|
71
|
+
this.#panel = panel;
|
|
72
|
+
prepareLayer(panel);
|
|
73
|
+
this.#check(this.value);
|
|
74
|
+
trigger.addEventListener('click', () => (this.isOpen ? this.close() : this.open()));
|
|
75
|
+
trigger.addEventListener('keydown', (event) => {
|
|
76
|
+
if (event.key === 'ArrowDown') { event.preventDefault(); this.open('first'); }
|
|
77
|
+
else if (event.key === 'ArrowUp') { event.preventDefault(); this.open('last'); }
|
|
78
|
+
});
|
|
79
|
+
panel.addEventListener('click', (event) => {
|
|
80
|
+
const item = /** @type {Element} */ (event.target).closest('[role=menuitem], [role=menuitemradio]');
|
|
81
|
+
if (!item) return;
|
|
82
|
+
if (item.getAttribute('role') === 'menuitemradio') {
|
|
83
|
+
const value = item.getAttribute('value') ?? '';
|
|
84
|
+
const changed = value !== this.value;
|
|
85
|
+
this.setAttribute('value', value);
|
|
86
|
+
if (changed) this.dispatchEvent(new CustomEvent('xo-menu-select', { bubbles: true, detail: { value } }));
|
|
87
|
+
}
|
|
88
|
+
this.close({ focus: true });
|
|
89
|
+
});
|
|
90
|
+
panel.addEventListener('keydown', (event) => this.#onKey(event));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @param {'first' | 'last'} [at] focus an item (a mouse click opens without moving the focus) */
|
|
94
|
+
open(at) {
|
|
95
|
+
const panel = this.#panel;
|
|
96
|
+
if (!panel || this.isOpen) { if (at) this.#focusItem(at === 'last' ? this.#items.length - 1 : 0); return; }
|
|
97
|
+
this.#stopFollowing = showLayer(panel, /** @type {HTMLElement} */ (this.#trigger));
|
|
98
|
+
this.#trigger?.setAttribute('aria-expanded', 'true');
|
|
99
|
+
document.addEventListener('pointerdown', this.#onDocument);
|
|
100
|
+
document.addEventListener('keydown', this.#onEscape);
|
|
101
|
+
if (at) this.#focusItem(at === 'last' ? this.#items.length - 1 : 0);
|
|
102
|
+
this.dispatchEvent(new CustomEvent('xo-menu-open', { bubbles: true }));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
close({ focus = false } = {}) {
|
|
106
|
+
const panel = this.#panel;
|
|
107
|
+
if (!panel || !this.isOpen) return;
|
|
108
|
+
this.#stopFollowing();
|
|
109
|
+
hideLayer(panel);
|
|
110
|
+
this.#trigger?.setAttribute('aria-expanded', 'false');
|
|
111
|
+
document.removeEventListener('pointerdown', this.#onDocument);
|
|
112
|
+
document.removeEventListener('keydown', this.#onEscape);
|
|
113
|
+
if (focus) this.#trigger?.focus();
|
|
114
|
+
this.dispatchEvent(new CustomEvent('xo-menu-close', { bubbles: true }));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The radio item whose `value` is the menu's is the checked one. */
|
|
118
|
+
#check(/** @type {string | null | undefined} */ value) {
|
|
119
|
+
for (const item of this.#items) if (item.getAttribute('role') === 'menuitemradio') item.setAttribute('aria-checked', String(item.getAttribute('value') === value));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#focusItem(/** @type {number} */ index) {
|
|
123
|
+
const items = this.#items;
|
|
124
|
+
items[(index + items.length) % items.length]?.focus();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
#onKey(/** @type {KeyboardEvent} */ event) {
|
|
128
|
+
const items = this.#items;
|
|
129
|
+
const at = items.indexOf(/** @type {HTMLElement} */ (document.activeElement));
|
|
130
|
+
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); this.close({ focus: true }); return; }
|
|
131
|
+
if (event.key === 'Tab') { this.close(); return; }
|
|
132
|
+
if (event.key === ' ' && /** @type {Element} */ (event.target).localName === 'a') { event.preventDefault(); /** @type {HTMLElement} */ (event.target).click(); return; } // Space activates a link, as it does a button
|
|
133
|
+
/** @type {number | null} */ let next = null;
|
|
134
|
+
if (event.key === 'ArrowDown') next = at + 1;
|
|
135
|
+
else if (event.key === 'ArrowUp') next = at < 0 ? items.length - 1 : at - 1;
|
|
136
|
+
else if (event.key === 'Home') next = 0;
|
|
137
|
+
else if (event.key === 'End') next = items.length - 1;
|
|
138
|
+
else if (event.key.length === 1 && /\S/.test(event.key) && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
139
|
+
const letter = event.key.toLowerCase();
|
|
140
|
+
const order = [...items.slice(at + 1), ...items.slice(0, at + 1)];
|
|
141
|
+
const hit = order.find((i) => (i.textContent ?? '').trim().toLowerCase().startsWith(letter));
|
|
142
|
+
if (hit) { event.preventDefault(); hit.focus(); }
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (next === null) return;
|
|
146
|
+
event.preventDefault();
|
|
147
|
+
this.#focusItem(next);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
define('xo-menu', XoMenu);
|
package/src/password.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* <xo-password> is a password field with a button that shows or hides the text.
|
|
4
|
+
*
|
|
5
|
+
* <xo-password name="password" label="Password" autocomplete="new-password"></xo-password>
|
|
6
|
+
*
|
|
7
|
+
* - The eye button is `aria-pressed` while the text is shown and named "Show password" / "Hide password"; nothing else is announced.
|
|
8
|
+
* - `value` is the string; `autocomplete` goes to the input so password managers work.
|
|
9
|
+
*/
|
|
10
|
+
import { XoControl, define, text } from '@xoong/core';
|
|
11
|
+
|
|
12
|
+
class XoPassword extends XoControl {
|
|
13
|
+
static props = { ...XoControl.props, label: String, placeholder: String, autocomplete: String };
|
|
14
|
+
|
|
15
|
+
/** @type {HTMLInputElement | null} */ #input = null;
|
|
16
|
+
/** @type {HTMLButtonElement | null} */ #eye = null;
|
|
17
|
+
|
|
18
|
+
setValue(v) { super.setValue(v == null ? '' : String(v)); if (this.#input && this.#input.value !== super.value) this.#input.value = super.value; }
|
|
19
|
+
get focusTarget() { return this.#input; }
|
|
20
|
+
|
|
21
|
+
connected() {
|
|
22
|
+
const wrap = document.createElement('div');
|
|
23
|
+
wrap.className = 'xo-input-wrap xo-input-wrap--end';
|
|
24
|
+
const input = document.createElement('input');
|
|
25
|
+
input.type = 'password';
|
|
26
|
+
input.addEventListener('input', (e) => { e.stopPropagation(); this.userInput(input.value); });
|
|
27
|
+
input.addEventListener('change', (e) => { e.stopPropagation(); this.userChange(); });
|
|
28
|
+
const eye = document.createElement('button');
|
|
29
|
+
eye.type = 'button';
|
|
30
|
+
eye.className = 'ghost icon-only sm xo-input-end';
|
|
31
|
+
eye.innerHTML = '<span class="ic" data-ic="eye" aria-hidden="true"></span>';
|
|
32
|
+
eye.addEventListener('click', () => { input.type = input.type === 'password' ? 'text' : 'password'; this.#paint(); });
|
|
33
|
+
wrap.append(input, eye);
|
|
34
|
+
this.#input = input; this.#eye = eye;
|
|
35
|
+
this.replaceChildren(wrap);
|
|
36
|
+
queueMicrotask(() => { input.value = super.value; this.#paint(); });
|
|
37
|
+
}
|
|
38
|
+
attributeChangedCallback(attr, old, value) { super.attributeChangedCallback(attr, old, value); this.#paint(); }
|
|
39
|
+
|
|
40
|
+
#paint() {
|
|
41
|
+
const i = this.#input, e = this.#eye;
|
|
42
|
+
if (!i || !e) return;
|
|
43
|
+
const shown = i.type === 'text';
|
|
44
|
+
e.setAttribute('aria-pressed', String(shown));
|
|
45
|
+
e.setAttribute('aria-label', shown ? text('xoong.controls.hidePassword', 'Hide password') : text('xoong.controls.showPassword', 'Show password'));
|
|
46
|
+
i.setAttribute('aria-label', this.label || this.name || text('xoong.controls.password', 'Password'));
|
|
47
|
+
i.placeholder = this.placeholder || '';
|
|
48
|
+
if (this.autocomplete) i.autocomplete = /** @type {any} */ (this.autocomplete);
|
|
49
|
+
i.disabled = e.disabled = this.isDisabled;
|
|
50
|
+
i.readOnly = this.readonly;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
define('xo-password', XoPassword);
|
package/src/slider.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* <xo-slider> is a number on a track (a native range input, styled by the theme); `value` is a number.
|
|
4
|
+
*
|
|
5
|
+
* <xo-slider name="quality" label="Image quality" min="0" max="100" step="5" value="80"></xo-slider>
|
|
6
|
+
*
|
|
7
|
+
* - The native keys work (arrows, Page, Home/End). `input` fires while dragging, `change` when released.
|
|
8
|
+
* - `labels` names the stops for screen readers and for the visible value: `labels="Low,Medium,High"` with min=0 max=2 step=1 says "Medium".
|
|
9
|
+
* - The filled part of the track follows the value (the custom property `--p`, set through the CSS object model).
|
|
10
|
+
*/
|
|
11
|
+
import { XoControl, define } from '@xoong/core';
|
|
12
|
+
|
|
13
|
+
class XoSlider extends XoControl {
|
|
14
|
+
static props = { ...XoControl.props, label: String, min: Number, max: Number, step: Number, labels: String };
|
|
15
|
+
|
|
16
|
+
/** @type {HTMLInputElement | null} */ #input = null;
|
|
17
|
+
|
|
18
|
+
setValue(v) { const n = Number(v); super.setValue(v === '' || v == null || Number.isNaN(n) ? '' : n); this.#paint(); }
|
|
19
|
+
get focusTarget() { return this.#input; }
|
|
20
|
+
|
|
21
|
+
connected() {
|
|
22
|
+
const input = document.createElement('input');
|
|
23
|
+
input.type = 'range';
|
|
24
|
+
input.className = 'xo-range';
|
|
25
|
+
input.addEventListener('input', (e) => { e.stopPropagation(); this.userInput(Number(input.value)); this.#paint(); });
|
|
26
|
+
input.addEventListener('change', (e) => { e.stopPropagation(); this.userChange(); });
|
|
27
|
+
this.#input = input;
|
|
28
|
+
this.replaceChildren(input);
|
|
29
|
+
queueMicrotask(() => this.#paint());
|
|
30
|
+
}
|
|
31
|
+
attributeChangedCallback(attr, old, value) { super.attributeChangedCallback(attr, old, value); this.#paint(); }
|
|
32
|
+
|
|
33
|
+
#paint() {
|
|
34
|
+
const i = this.#input;
|
|
35
|
+
if (!i) return;
|
|
36
|
+
const min = this.min ?? 0, max = this.max ?? 100;
|
|
37
|
+
i.min = String(min); i.max = String(max); i.step = String(this.step || 1);
|
|
38
|
+
i.disabled = this.isDisabled || this.readonly;
|
|
39
|
+
i.setAttribute('aria-label', this.label || this.name || 'Value');
|
|
40
|
+
const v = super.value === '' ? min : super.value;
|
|
41
|
+
i.value = String(v);
|
|
42
|
+
i.style.setProperty('--p', `${max > min ? ((v - min) / (max - min)) * 100 : 0}%`);
|
|
43
|
+
const stop = this.labels ? this.labels.split(',')[Math.round((v - min) / (this.step || 1))]?.trim() : '';
|
|
44
|
+
if (stop) i.setAttribute('aria-valuetext', stop); else i.removeAttribute('aria-valuetext');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
define('xo-slider', XoSlider);
|
package/src/stepper.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* <xo-stepper> is a number with minus and plus buttons; `value` is a number (or '' when empty).
|
|
4
|
+
*
|
|
5
|
+
* <xo-stepper name="perPage" label="Items per page" min="1" max="100" step="5" value="25"></xo-stepper>
|
|
6
|
+
*
|
|
7
|
+
* - ArrowUp/ArrowDown change by `step`, PageUp/PageDown by ten steps, Home/End go to `min`/`max` when they are set. The value is clamped;
|
|
8
|
+
* at the limit the matching button is disabled. Typing is free until the field is left, then the number is clamped.
|
|
9
|
+
* - The input has the spinbutton semantics (`role=spinbutton`, `aria-valuemin/max/now`); the buttons are named Decrease and Increase.
|
|
10
|
+
*/
|
|
11
|
+
import { XoControl, define, text } from '@xoong/core';
|
|
12
|
+
|
|
13
|
+
class XoStepper extends XoControl {
|
|
14
|
+
static props = { ...XoControl.props, label: String, min: Number, max: Number, step: Number };
|
|
15
|
+
|
|
16
|
+
/** @type {HTMLInputElement | null} */ #input = null;
|
|
17
|
+
/** @type {HTMLButtonElement[]} */ #buttons = [];
|
|
18
|
+
|
|
19
|
+
setValue(v) { const n = Number(v); super.setValue(v === '' || v == null || Number.isNaN(n) ? '' : this.#clamp(n)); this.#paint(); }
|
|
20
|
+
get focusTarget() { return this.#input; }
|
|
21
|
+
#clamp(/** @type {number} */ n) { return Math.min(this.max ?? Infinity, Math.max(this.min ?? -Infinity, n)); }
|
|
22
|
+
get #step() { return this.step || 1; }
|
|
23
|
+
|
|
24
|
+
connected() {
|
|
25
|
+
const wrap = document.createElement('div');
|
|
26
|
+
wrap.className = 'xo-stepper';
|
|
27
|
+
const button = (/** @type {number} */ dir, /** @type {string} */ label, /** @type {string} */ icon) => {
|
|
28
|
+
const b = document.createElement('button');
|
|
29
|
+
b.type = 'button'; b.setAttribute('aria-label', label); b.tabIndex = -1;
|
|
30
|
+
b.innerHTML = `<span class="ic" data-ic="${icon}" aria-hidden="true"></span>`;
|
|
31
|
+
b.addEventListener('click', () => this.#move(dir));
|
|
32
|
+
return b;
|
|
33
|
+
};
|
|
34
|
+
const input = document.createElement('input');
|
|
35
|
+
input.setAttribute('inputmode', 'numeric');
|
|
36
|
+
input.setAttribute('role', 'spinbutton');
|
|
37
|
+
input.autocomplete = 'off';
|
|
38
|
+
input.addEventListener('input', (e) => e.stopPropagation()); // typing is free: the value is read when the field is left
|
|
39
|
+
input.addEventListener('change', (e) => { e.stopPropagation(); this.#commit(); });
|
|
40
|
+
input.addEventListener('keydown', (e) => {
|
|
41
|
+
const k = e.key;
|
|
42
|
+
if (k === 'Enter') { this.#commit(); return; }
|
|
43
|
+
const by = { ArrowUp: 1, ArrowDown: -1, PageUp: 10, PageDown: -10 }[k];
|
|
44
|
+
if (by) { e.preventDefault(); this.#move(by); }
|
|
45
|
+
else if ((k === 'Home' && this.min != null) || (k === 'End' && this.max != null)) { e.preventDefault(); this.#set(k === 'Home' ? /** @type {number} */ (this.min) : /** @type {number} */ (this.max)); }
|
|
46
|
+
});
|
|
47
|
+
this.#input = input;
|
|
48
|
+
this.#buttons = [button(-1, text('xoong.controls.decrease', 'Decrease'), 'minus'), button(1, text('xoong.controls.increase', 'Increase'), 'plus')];
|
|
49
|
+
wrap.append(this.#buttons[0], input, this.#buttons[1]);
|
|
50
|
+
this.replaceChildren(wrap);
|
|
51
|
+
queueMicrotask(() => this.#paint());
|
|
52
|
+
}
|
|
53
|
+
attributeChangedCallback(attr, old, value) { super.attributeChangedCallback(attr, old, value); this.#paint(); }
|
|
54
|
+
|
|
55
|
+
#move(/** @type {number} */ steps) {
|
|
56
|
+
if (this.isDisabled || this.readonly) return;
|
|
57
|
+
const now = super.value === '' ? (this.min ?? 0) - (steps > 0 ? this.#step : 0) : super.value;
|
|
58
|
+
this.#set(now + steps * this.#step);
|
|
59
|
+
}
|
|
60
|
+
#set(/** @type {number} */ n) {
|
|
61
|
+
const before = super.value;
|
|
62
|
+
this.setValue(n);
|
|
63
|
+
if (super.value !== before) { this.userInput(super.value); this.userChange(); }
|
|
64
|
+
}
|
|
65
|
+
#commit() {
|
|
66
|
+
const raw = /** @type {HTMLInputElement} */ (this.#input).value.trim();
|
|
67
|
+
if (raw === '') { if (super.value !== '') { this.setValue(''); this.userInput(''); this.userChange(); } return; }
|
|
68
|
+
const n = Number(raw.replace(',', '.'));
|
|
69
|
+
if (Number.isNaN(n)) this.#paint(); else this.#set(n);
|
|
70
|
+
this.#paint();
|
|
71
|
+
}
|
|
72
|
+
#paint() {
|
|
73
|
+
const i = this.#input;
|
|
74
|
+
if (!i) return;
|
|
75
|
+
const v = super.value;
|
|
76
|
+
i.value = v === '' ? '' : String(v);
|
|
77
|
+
i.setAttribute('aria-label', this.label || this.name || 'Number');
|
|
78
|
+
for (const [a, val] of [['aria-valuemin', this.min], ['aria-valuemax', this.max], ['aria-valuenow', v === '' ? undefined : v]]) { if (val == null) i.removeAttribute(/** @type {string} */ (a)); else i.setAttribute(/** @type {string} */ (a), String(val)); }
|
|
79
|
+
const off = this.isDisabled || this.readonly;
|
|
80
|
+
i.disabled = off;
|
|
81
|
+
this.#buttons[0].disabled = off || (v !== '' && this.min != null && v <= this.min);
|
|
82
|
+
this.#buttons[1].disabled = off || (v !== '' && this.max != null && v >= this.max);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
define('xo-stepper', XoStepper);
|