@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/combobox.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* <xo-combobox> picks one value from a list you can filter by typing (the WAI-ARIA editable combobox with list autocomplete).
|
|
4
|
+
* `multiple` picks several, shown as removable chips. <xo-tags> is the same control for free text: type and press Enter or a comma.
|
|
5
|
+
*
|
|
6
|
+
* <xo-combobox name="author" label="Author" placeholder="Choose">
|
|
7
|
+
* <option value="1">Ada Lovelace</option>
|
|
8
|
+
* <option value="2">Grace Hopper</option>
|
|
9
|
+
* </xo-combobox>
|
|
10
|
+
* <xo-combobox name="topics" multiple>...</xo-combobox>
|
|
11
|
+
* <xo-tags name="keywords" placeholder="Add a keyword"></xo-tags>
|
|
12
|
+
*
|
|
13
|
+
* It is a form control (`XoControl`): it has `name`, `required`, `disabled`, `value`, validity, `dirty` and `touched`, and it takes part in
|
|
14
|
+
* <form xo-submit> like a native field. The value is a string, or an array of strings with `multiple` (and for <xo-tags>).
|
|
15
|
+
*
|
|
16
|
+
* - Options: `<option>` children, or `el.options = ['a', { value: 'b', label: 'B', disabled: true }]`; `selected` on an <option> sets the start value.
|
|
17
|
+
* The generated form (xo-form) passes the schema `enum` that way.
|
|
18
|
+
* - `creatable` lets the person add a value that is not in the list (always on for <xo-tags>); `remote` leaves filtering to you:
|
|
19
|
+
* listen to `xo-search` ({ detail: { query } }) and set `options`.
|
|
20
|
+
* - Keys: ArrowDown/ArrowUp open and move (the focus stays in the input), Enter picks, Escape closes, Backspace on an empty field removes the
|
|
21
|
+
* last chip; with `multiple` the list stays open after a pick.
|
|
22
|
+
* - Chips and the list are in the Light DOM: style `.xo-chip`, `.xo-combobox-list [role=option]`. `aria-activedescendant` tracks the highlighted option.
|
|
23
|
+
*/
|
|
24
|
+
import { XoControl, define, text } from '@xoong/core';
|
|
25
|
+
import { prepareLayer, showLayer, hideLayer } from './layer.js';
|
|
26
|
+
|
|
27
|
+
let counter = 0;
|
|
28
|
+
|
|
29
|
+
/** @typedef {{ value: string, label: string, disabled?: boolean }} Option */
|
|
30
|
+
|
|
31
|
+
/** @param {any} o @returns {Option} */
|
|
32
|
+
const normalize = (o) => (o && typeof o === 'object' ? { value: String(o.value), label: String(o.label ?? o.value), ...(o.disabled ? { disabled: true } : {}) } : { value: String(o), label: String(o) });
|
|
33
|
+
|
|
34
|
+
class XoCombobox extends XoControl {
|
|
35
|
+
static props = { ...XoControl.props, placeholder: String, label: String, multiple: Boolean, creatable: Boolean, remote: Boolean };
|
|
36
|
+
|
|
37
|
+
/** @type {Option[]} */ #options = [];
|
|
38
|
+
#fromSchema = true; // the options came from the schema (or nowhere yet), not from markup or `el.options`
|
|
39
|
+
/** @type {any} */ #schema = null;
|
|
40
|
+
/** @type {HTMLInputElement | null} */ #input = null;
|
|
41
|
+
/** @type {HTMLElement | null} */ #box = null;
|
|
42
|
+
/** @type {HTMLElement | null} */ #list = null;
|
|
43
|
+
#query = '';
|
|
44
|
+
#active = -1;
|
|
45
|
+
/** @type {Option[]} */ #shown = [];
|
|
46
|
+
#open = false;
|
|
47
|
+
#stopFollowing = () => {};
|
|
48
|
+
#built = false;
|
|
49
|
+
#id = `xo-combobox-${++counter}`;
|
|
50
|
+
#onDocument = (/** @type {Event} */ event) => { if (!this.contains(/** @type {Node} */ (event.target))) this.#close(); };
|
|
51
|
+
|
|
52
|
+
constructor() {
|
|
53
|
+
super();
|
|
54
|
+
// `options` or `schema` assigned before the element was upgraded (used before `define`) are own properties that hide the accessors: re-assign them
|
|
55
|
+
for (const key of /** @type {const} */ (['options', 'schema'])) {
|
|
56
|
+
if (Object.prototype.hasOwnProperty.call(this, key)) { const early = /** @type {any} */ (this)[key]; delete (/** @type {any} */ (this))[key]; /** @type {any} */ (this)[key] = early; }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---- options ---------------------------------------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
get options() { return this.#options; }
|
|
63
|
+
set options(list) {
|
|
64
|
+
this.#fromSchema = false;
|
|
65
|
+
this.#options = (list ?? []).map(normalize);
|
|
66
|
+
if (this.#built) { this.#syncText(); this.#renderChips(); if (this.#open) this.#refresh(); } // chips show labels, so they follow the options
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The schema node of the field (the form binder sets it): an `enum` gives the options, an array of an `enum` gives them with `multiple`.
|
|
71
|
+
* This is how a generated form (`widget: "xo-combobox"` hint) fills the control without any markup.
|
|
72
|
+
*/
|
|
73
|
+
get schema() { return this.#schema; }
|
|
74
|
+
set schema(node) {
|
|
75
|
+
this.#schema = node;
|
|
76
|
+
const many = node?.t === 'array';
|
|
77
|
+
const values = node?.enum ?? (many ? node?.items?.enum : undefined);
|
|
78
|
+
if (many && !this.multiple) this.setAttribute('multiple', '');
|
|
79
|
+
if (values && this.#fromSchema) {
|
|
80
|
+
this.#options = values.map(normalize);
|
|
81
|
+
if (this.#built) { this.#syncText(); this.#renderChips(); if (this.#open) this.#refresh(); }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether this control holds several values. */
|
|
86
|
+
get #many() { return this.multiple; }
|
|
87
|
+
get #values() { const v = this.value; return Array.isArray(v) ? v : v === '' || v == null ? [] : [String(v)]; }
|
|
88
|
+
|
|
89
|
+
setValue(v) {
|
|
90
|
+
if (this.#many && typeof v === 'string') v = v === '' ? [] : v.split(',').map((s) => s.trim()).filter(Boolean);
|
|
91
|
+
super.setValue(this.#many ? (Array.isArray(v) ? v.map(String) : []) : (v == null ? '' : String(v)));
|
|
92
|
+
if (this.#built) { this.#syncText(); this.#renderChips(); }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
isEmpty(v) { return v == null || v === '' || (Array.isArray(v) && v.length === 0); }
|
|
96
|
+
|
|
97
|
+
serialize(v) {
|
|
98
|
+
if (!Array.isArray(v)) return super.serialize(v);
|
|
99
|
+
if (typeof FormData === 'undefined') return JSON.stringify(v);
|
|
100
|
+
const data = new FormData();
|
|
101
|
+
for (const item of v) data.append(this.name, item);
|
|
102
|
+
return data;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get focusTarget() { return this.#input; }
|
|
106
|
+
|
|
107
|
+
// ---- lifecycle -------------------------------------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
connected() {
|
|
110
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => this.#build(), { once: true });
|
|
111
|
+
else queueMicrotask(() => this.#build());
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
disconnected() { document.removeEventListener('pointerdown', this.#onDocument); this.#stopFollowing(); }
|
|
115
|
+
|
|
116
|
+
#build() {
|
|
117
|
+
if (this.#built || !this.isConnected) return;
|
|
118
|
+
// <option> children are the data; the control draws its own content.
|
|
119
|
+
const fromMarkup = /** @type {HTMLOptionElement[]} */ ([...this.querySelectorAll(':scope > option')]);
|
|
120
|
+
if (fromMarkup.length) this.#fromSchema = false;
|
|
121
|
+
if (fromMarkup.length && !this.#options.length) this.#options = fromMarkup.map((o) => normalize({ value: o.value, label: o.textContent?.trim() || o.value, disabled: o.disabled }));
|
|
122
|
+
const chosen = fromMarkup.filter((o) => o.selected && o.hasAttribute('selected')).map((o) => o.value);
|
|
123
|
+
fromMarkup.forEach((o) => o.remove());
|
|
124
|
+
|
|
125
|
+
const box = document.createElement('div');
|
|
126
|
+
box.className = 'xo-combobox-box';
|
|
127
|
+
const input = document.createElement('input');
|
|
128
|
+
input.type = 'text';
|
|
129
|
+
input.className = 'xo-combobox-input';
|
|
130
|
+
input.autocomplete = 'off';
|
|
131
|
+
input.setAttribute('role', 'combobox');
|
|
132
|
+
input.setAttribute('aria-autocomplete', 'list');
|
|
133
|
+
input.setAttribute('aria-expanded', 'false');
|
|
134
|
+
input.setAttribute('aria-controls', `${this.#id}-list`);
|
|
135
|
+
input.setAttribute('aria-label', this.label || this.name || 'Combobox');
|
|
136
|
+
if (this.placeholder) input.placeholder = this.placeholder;
|
|
137
|
+
const list = document.createElement('ul');
|
|
138
|
+
list.id = `${this.#id}-list`;
|
|
139
|
+
list.className = 'xo-combobox-list';
|
|
140
|
+
list.setAttribute('role', 'listbox');
|
|
141
|
+
if (this.#many) list.setAttribute('aria-multiselectable', 'true');
|
|
142
|
+
prepareLayer(list);
|
|
143
|
+
box.append(input);
|
|
144
|
+
this.append(box, list);
|
|
145
|
+
this.#box = box; this.#input = input; this.#list = list;
|
|
146
|
+
this.#built = true;
|
|
147
|
+
|
|
148
|
+
// The inner input's own input/change events are not the control's: only userInput/userChange speak for the control.
|
|
149
|
+
for (const type of ['input', 'change']) input.addEventListener(type, (e) => e.stopPropagation());
|
|
150
|
+
input.addEventListener('input', () => { this.#query = input.value; this.#open || this.#openList(); this.#refresh(); if (this.remote) this.dispatchEvent(new CustomEvent('xo-search', { bubbles: true, detail: { query: this.#query } })); });
|
|
151
|
+
input.addEventListener('keydown', (e) => this.#onKey(e));
|
|
152
|
+
input.addEventListener('focus', () => { if (!this.#many) input.select(); });
|
|
153
|
+
input.addEventListener('click', () => { this.#openList(); }); // a click into the field shows the choices
|
|
154
|
+
input.addEventListener('blur', () => { setTimeout(() => { if (!this.contains(document.activeElement)) this.#blurred(); }, 0); });
|
|
155
|
+
box.addEventListener('mousedown', (e) => { if (e.target !== input && !(/** @type {Element} */ (e.target).closest('.xo-chip button'))) { e.preventDefault(); input.focus(); this.#openList(); this.#refresh(); } });
|
|
156
|
+
list.addEventListener('mousedown', (e) => e.preventDefault()); // keep the focus in the input while choosing
|
|
157
|
+
list.addEventListener('click', (e) => {
|
|
158
|
+
const li = /** @type {Element} */ (e.target).closest('[role=option]');
|
|
159
|
+
if (li) this.#choose(Number(/** @type {HTMLElement} */ (li).dataset.index));
|
|
160
|
+
});
|
|
161
|
+
box.addEventListener('click', (e) => {
|
|
162
|
+
const button = /** @type {Element} */ (e.target).closest('.xo-chip button');
|
|
163
|
+
if (button) this.#removeValue(/** @type {HTMLElement} */ (button).dataset.value ?? '');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
if (chosen.length && this.isEmpty(this.value)) this.setValue(this.#many ? chosen : chosen[0]);
|
|
167
|
+
else if (this.#many && typeof this.value === 'string') this.setValue(this.value);
|
|
168
|
+
if (this.#many === false && Array.isArray(this.value)) this.setValue(this.value[0] ?? '');
|
|
169
|
+
this.#syncText();
|
|
170
|
+
this.#renderChips();
|
|
171
|
+
this.markPristine();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
attributeChangedCallback(attr, old, value) {
|
|
175
|
+
super.attributeChangedCallback(attr, old, value);
|
|
176
|
+
if (!this.#built) return;
|
|
177
|
+
if (attr === 'placeholder' && this.#input) this.#input.placeholder = value ?? '';
|
|
178
|
+
if ((attr === 'disabled' || attr === 'readonly') && this.#input) { this.#input.disabled = this.isDisabled; this.#input.readOnly = this.readonly; }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---- display ---------------------------------------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
#labelOf(/** @type {string} */ value) { return this.#options.find((o) => o.value === value)?.label ?? value; }
|
|
184
|
+
|
|
185
|
+
/** Single mode shows the chosen label in the field; multiple mode keeps the field for typing. */
|
|
186
|
+
#syncText() {
|
|
187
|
+
if (!this.#input) return;
|
|
188
|
+
this.#input.disabled = this.isDisabled;
|
|
189
|
+
this.#input.readOnly = this.readonly;
|
|
190
|
+
if (!this.#many) { this.#query = ''; this.#input.value = this.#values[0] === undefined ? '' : this.#labelOf(this.#values[0]); }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#renderChips() {
|
|
194
|
+
const box = this.#box;
|
|
195
|
+
if (!box || !this.#many) return;
|
|
196
|
+
for (const chip of [...box.querySelectorAll('.xo-chip')]) chip.remove();
|
|
197
|
+
for (const value of this.#values) {
|
|
198
|
+
const chip = document.createElement('span');
|
|
199
|
+
chip.className = 'xo-chip';
|
|
200
|
+
const label = this.#labelOf(value);
|
|
201
|
+
chip.append(label);
|
|
202
|
+
const remove = document.createElement('button');
|
|
203
|
+
remove.type = 'button';
|
|
204
|
+
remove.tabIndex = -1;
|
|
205
|
+
remove.dataset.value = value;
|
|
206
|
+
remove.textContent = '×';
|
|
207
|
+
remove.setAttribute('aria-label', text('xoong.controls.remove', 'Remove {label}', { label }));
|
|
208
|
+
chip.append(remove);
|
|
209
|
+
box.insertBefore(chip, this.#input);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The options that match what was typed (all of them in `remote` mode), without those already chosen, plus the "add" row. */
|
|
214
|
+
#matches() {
|
|
215
|
+
const q = this.#query.trim().toLowerCase();
|
|
216
|
+
const taken = this.#many ? new Set(this.#values) : new Set();
|
|
217
|
+
const found = this.#options.filter((o) => !taken.has(o.value) && (this.remote || !q || o.label.toLowerCase().includes(q)));
|
|
218
|
+
const typed = this.#query.trim();
|
|
219
|
+
if (this.creatable) {
|
|
220
|
+
if (typed && !this.#options.some((o) => o.label.toLowerCase() === typed.toLowerCase()) && !taken.has(typed)) found.push({ value: typed, label: text('xoong.controls.create', 'Add "{value}"', { value: typed }), create: true });
|
|
221
|
+
}
|
|
222
|
+
return found;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
#refresh() {
|
|
226
|
+
const list = this.#list;
|
|
227
|
+
if (!list || !this.#input) return;
|
|
228
|
+
this.#shown = this.#matches();
|
|
229
|
+
list.replaceChildren();
|
|
230
|
+
if (!this.#shown.length) {
|
|
231
|
+
const empty = document.createElement('li');
|
|
232
|
+
empty.className = 'xo-empty';
|
|
233
|
+
empty.setAttribute('role', 'presentation');
|
|
234
|
+
empty.textContent = text('xoong.controls.noResults', 'No results');
|
|
235
|
+
list.append(empty);
|
|
236
|
+
}
|
|
237
|
+
this.#shown.forEach((o, i) => {
|
|
238
|
+
const li = document.createElement('li');
|
|
239
|
+
li.id = `${this.#id}-option-${i}`;
|
|
240
|
+
li.setAttribute('role', 'option');
|
|
241
|
+
li.dataset.index = String(i);
|
|
242
|
+
li.textContent = o.label;
|
|
243
|
+
const selected = !this.#many && this.#values[0] === o.value;
|
|
244
|
+
li.setAttribute('aria-selected', String(selected));
|
|
245
|
+
if (o.disabled) li.setAttribute('aria-disabled', 'true');
|
|
246
|
+
list.append(li);
|
|
247
|
+
});
|
|
248
|
+
this.#active = this.#shown.findIndex((o) => !o.disabled);
|
|
249
|
+
if (!this.#many && this.#values[0] !== undefined && !this.#query) { const at = this.#shown.findIndex((o) => o.value === this.#values[0]); if (at >= 0) this.#active = at; }
|
|
250
|
+
this.#highlight();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#highlight() {
|
|
254
|
+
const input = this.#input;
|
|
255
|
+
if (!input || !this.#list) return;
|
|
256
|
+
for (const li of this.#list.querySelectorAll('[role=option]')) li.classList.toggle('xo-active', li.id === `${this.#id}-option-${this.#active}`);
|
|
257
|
+
if (this.#active >= 0) { input.setAttribute('aria-activedescendant', `${this.#id}-option-${this.#active}`); this.#list.querySelector('.xo-active')?.scrollIntoView?.({ block: 'nearest' }); }
|
|
258
|
+
else input.removeAttribute('aria-activedescendant');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---- opening and choosing --------------------------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
#openList() {
|
|
264
|
+
if (this.#open || this.isDisabled || this.readonly || !this.#list || !this.#box) return;
|
|
265
|
+
this.#open = true;
|
|
266
|
+
this.#refresh();
|
|
267
|
+
this.#stopFollowing = showLayer(this.#list, this.#box, { matchWidth: true });
|
|
268
|
+
this.#input?.setAttribute('aria-expanded', 'true');
|
|
269
|
+
document.addEventListener('pointerdown', this.#onDocument);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#close() {
|
|
273
|
+
if (!this.#open || !this.#list) return;
|
|
274
|
+
this.#open = false;
|
|
275
|
+
this.#stopFollowing();
|
|
276
|
+
hideLayer(this.#list);
|
|
277
|
+
this.#input?.setAttribute('aria-expanded', 'false');
|
|
278
|
+
this.#input?.removeAttribute('aria-activedescendant');
|
|
279
|
+
document.removeEventListener('pointerdown', this.#onDocument);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#blurred() {
|
|
283
|
+
if (this.creatable && this.#many && this.#query.trim()) this.#add(this.#query.trim());
|
|
284
|
+
this.#close();
|
|
285
|
+
if (this.#many && this.#input) { this.#query = ''; this.#input.value = ''; } else this.#syncText();
|
|
286
|
+
this.markTouched();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
#choose(/** @type {number} */ index) {
|
|
290
|
+
const o = this.#shown[index];
|
|
291
|
+
if (!o || o.disabled) return;
|
|
292
|
+
this.#add(o.value, /** @type {any} */ (o).create);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
#add(/** @type {string} */ value, created = false) {
|
|
296
|
+
if (created && !this.#options.some((o) => o.value === value)) this.#options = [...this.#options, { value, label: value }];
|
|
297
|
+
if (this.#many) {
|
|
298
|
+
if (this.#values.includes(value)) return;
|
|
299
|
+
this.userInput([...this.#values, value]);
|
|
300
|
+
this.#query = '';
|
|
301
|
+
if (this.#input) this.#input.value = '';
|
|
302
|
+
this.#renderChips();
|
|
303
|
+
this.#refresh();
|
|
304
|
+
} else {
|
|
305
|
+
this.userInput(value);
|
|
306
|
+
this.#syncText();
|
|
307
|
+
this.#close();
|
|
308
|
+
}
|
|
309
|
+
this.userChange();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#removeValue(/** @type {string} */ value) {
|
|
313
|
+
this.userInput(this.#values.filter((v) => v !== value));
|
|
314
|
+
this.userChange();
|
|
315
|
+
this.#renderChips();
|
|
316
|
+
if (this.#open) this.#refresh();
|
|
317
|
+
this.#input?.focus();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
#onKey(/** @type {KeyboardEvent} */ event) {
|
|
321
|
+
const move = (/** @type {number} */ step) => {
|
|
322
|
+
if (!this.#open) { this.#openList(); return; }
|
|
323
|
+
const n = this.#shown.length;
|
|
324
|
+
if (!n) return;
|
|
325
|
+
let i = this.#active;
|
|
326
|
+
for (let tries = 0; tries < n; tries++) { i = (i + step + n) % n; if (!this.#shown[i].disabled) break; }
|
|
327
|
+
this.#active = i;
|
|
328
|
+
this.#highlight();
|
|
329
|
+
};
|
|
330
|
+
switch (event.key) {
|
|
331
|
+
case 'ArrowDown': event.preventDefault(); move(1); return;
|
|
332
|
+
case 'ArrowUp': event.preventDefault(); move(-1); return;
|
|
333
|
+
case 'Home': case 'End':
|
|
334
|
+
if (this.#open && this.#active >= 0) { event.preventDefault(); this.#active = event.key === 'Home' ? 0 : this.#shown.length - 1; this.#highlight(); }
|
|
335
|
+
return;
|
|
336
|
+
case 'Enter':
|
|
337
|
+
if (this.#open) event.preventDefault(); // Enter never submits the form while the list is open
|
|
338
|
+
if (this.#open && this.#active >= 0) this.#choose(this.#active);
|
|
339
|
+
else if (this.creatable && this.#query.trim()) { event.preventDefault(); this.#add(this.#query.trim(), true); }
|
|
340
|
+
return;
|
|
341
|
+
case ',':
|
|
342
|
+
if (this.creatable && this.#many && this.#query.trim()) { event.preventDefault(); this.#add(this.#query.trim(), true); }
|
|
343
|
+
return;
|
|
344
|
+
case 'Escape':
|
|
345
|
+
if (this.#open) { event.preventDefault(); event.stopPropagation(); this.#close(); this.#syncText(); }
|
|
346
|
+
return;
|
|
347
|
+
case 'Backspace':
|
|
348
|
+
if (this.#many && !this.#input?.value && this.#values.length) { event.preventDefault(); this.#removeValue(this.#values[this.#values.length - 1]); }
|
|
349
|
+
return;
|
|
350
|
+
default:
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
define('xo-combobox', XoCombobox);
|
|
355
|
+
|
|
356
|
+
/** <xo-tags>: a combobox of free text values (multiple and creatable by default). */
|
|
357
|
+
class XoTags extends XoCombobox {
|
|
358
|
+
connected() {
|
|
359
|
+
this.setAttribute('multiple', '');
|
|
360
|
+
this.setAttribute('creatable', '');
|
|
361
|
+
super.connected();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
define('xo-tags', XoTags);
|
package/src/controls.css
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/* Optional styles for @xoong/controls. Everything is in @layer xoong-controls and uses the --xo-* tokens of @xoong/ui, with fallbacks. */
|
|
2
|
+
@layer xoong-controls {
|
|
3
|
+
xo-tabs { display: block; }
|
|
4
|
+
xo-tabs > [role="tablist"] { display: flex; gap: var(--xo-space-1, .25rem); border-bottom: 1px solid var(--xo-color-border, #d8dee4); overflow-x: auto; }
|
|
5
|
+
xo-tabs [role="tab"] { background: none; border: 0; border-bottom: 2px solid transparent; padding: var(--xo-space-2, .5rem) var(--xo-space-3, .75rem); cursor: pointer; font: inherit; color: inherit; white-space: nowrap; }
|
|
6
|
+
xo-tabs [role="tab"][aria-selected="true"] { border-bottom-color: var(--xo-color-accent, #0969da); font-weight: 600; }
|
|
7
|
+
xo-tabs [role="tab"][data-invalid]::after { content: " \25CF"; color: var(--xo-color-danger, #cf222e); }
|
|
8
|
+
xo-tabs > [role="tabpanel"] { padding-top: var(--xo-space-3, .75rem); }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
@layer xoong-controls {
|
|
12
|
+
xo-menu { display: inline-block; }
|
|
13
|
+
xo-menu [role="menu"] { min-width: 10rem; padding: var(--xo-space-1, .25rem); background: var(--xo-color-surface, #fff); color: inherit; border: 1px solid var(--xo-color-border, #d8dee4); border-radius: var(--xo-radius, 6px); box-shadow: 0 6px 18px rgb(0 0 0 / .15); }
|
|
14
|
+
xo-menu [role="menu"]:not([popover]) { position: absolute; z-index: 10; }
|
|
15
|
+
xo-menu [role="menu"][hidden] { display: none; }
|
|
16
|
+
xo-menu :is([role="menuitem"], [role="menuitemradio"]) { display: block; width: 100%; box-sizing: border-box; text-align: start; background: none; border: 0; padding: var(--xo-space-2, .5rem) var(--xo-space-3, .75rem); font: inherit; color: inherit; text-decoration: none; cursor: pointer; border-radius: 4px; }
|
|
17
|
+
xo-menu :is([role="menuitem"], [role="menuitemradio"]):is(:hover, :focus-visible) { background: var(--xo-color-hover, rgb(127 127 127 / .15)); }
|
|
18
|
+
xo-menu [role="separator"] { margin: .25rem .5rem; border: 0; border-top: 1px solid var(--xo-color-border, #d8dee4); }
|
|
19
|
+
xo-menu :is([role="menuitem"], [role="menuitemradio"]):focus-visible { outline: var(--xo-focus-thin, 2px solid var(--xo-color-accent, #0969da)); outline-offset: -2px; } /* a fill alone is not a focus indicator (1.4.11) */
|
|
20
|
+
xo-menu [role="menuitemradio"] { padding-inline-start: 1.75rem; position: relative; }
|
|
21
|
+
xo-menu [role="menuitemradio"][aria-checked="true"] { font-weight: 600; }
|
|
22
|
+
xo-menu [role="menuitemradio"][aria-checked="true"]::before { content: "\2713"; position: absolute; inset-inline-start: .5rem; }
|
|
23
|
+
.xo-tooltip { padding: .25rem .5rem; border: 0; border-radius: 4px; background: #1b1f24; color: #fff; font-size: .85rem; max-width: 20rem; pointer-events: auto; }
|
|
24
|
+
.xo-tooltip[hidden] { display: none; }
|
|
25
|
+
xo-menu { position: relative; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@layer xoong-controls {
|
|
29
|
+
xo-combobox, xo-tags { display: block; position: relative; }
|
|
30
|
+
.xo-combobox-box { display: flex; flex-wrap: wrap; gap: var(--xo-space-1, .25rem); align-items: center; padding: var(--xo-space-1, .25rem) var(--xo-space-2, .5rem); border: 1px solid var(--xo-color-control-border, #838c9b); border-radius: var(--xo-radius, 6px); background: var(--xo-color-surface, #fff); }
|
|
31
|
+
.xo-combobox-box:focus-within { outline: 2px solid var(--xo-color-accent, #0969da); outline-offset: 1px; }
|
|
32
|
+
.xo-combobox-input { flex: 1 1 8rem; min-width: 6rem; min-height: 0; border: 0; outline: none; background: transparent; font: inherit; color: inherit; padding: var(--xo-space-1, .25rem) 0; }
|
|
33
|
+
.xo-chip { display: inline-flex; align-items: center; gap: .25rem; padding: 0 .25rem 0 .5rem; border-radius: 999px; background: var(--xo-color-hover, rgb(127 127 127 / .18)); font-size: .9em; }
|
|
34
|
+
.xo-chip button { min-height: 0; border: 0; background: none; cursor: pointer; font: inherit; color: inherit; line-height: 1; padding: .15rem .3rem; border-radius: 50%; }
|
|
35
|
+
.xo-chip button:hover { background: rgb(127 127 127 / .3); }
|
|
36
|
+
.xo-combobox-list { margin: 0; padding: var(--xo-space-1, .25rem); list-style: none; max-height: 16rem; overflow-y: auto; background: var(--xo-color-surface, #fff); color: inherit; border: 1px solid var(--xo-color-border, #d8dee4); border-radius: var(--xo-radius, 6px); box-shadow: 0 6px 18px rgb(0 0 0 / .15); }
|
|
37
|
+
.xo-combobox-list:not([popover]) { position: absolute; z-index: 10; inset-inline: 0; }
|
|
38
|
+
.xo-combobox-list[hidden] { display: none; }
|
|
39
|
+
.xo-combobox-list [role="option"], .xo-combobox-list .xo-empty { padding: var(--xo-space-2, .5rem) var(--xo-space-3, .75rem); border-radius: 4px; cursor: pointer; }
|
|
40
|
+
.xo-combobox-list .xo-empty { cursor: default; opacity: .7; }
|
|
41
|
+
.xo-combobox-list [role="option"].xo-active { background: var(--xo-color-hover, rgb(127 127 127 / .18)); }
|
|
42
|
+
.xo-combobox-list [role="option"][aria-selected="true"] { font-weight: 600; }
|
|
43
|
+
.xo-combobox-list [role="option"][aria-disabled="true"] { opacity: .5; cursor: not-allowed; }
|
|
44
|
+
xo-combobox[data-invalid] .xo-combobox-box, xo-tags[data-invalid] .xo-combobox-box { border-color: var(--xo-color-danger, #cf222e); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@layer xoong-controls {
|
|
48
|
+
/* xo-carousel: a scroll container with scroll snap; --xo-carousel-items (default 1) slides are visible, --xo-carousel-gap apart */
|
|
49
|
+
xo-carousel { display: block; position: relative; --xo-carousel-gap-used: var(--xo-carousel-gap, var(--xo-space-3, 1rem)); }
|
|
50
|
+
.xo-carousel-track { display: flex; gap: var(--xo-carousel-gap-used); overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scrollbar-width: none; position: relative; }
|
|
51
|
+
.xo-carousel-track::-webkit-scrollbar { display: none; }
|
|
52
|
+
.xo-carousel-track:focus-visible { outline: 2px solid var(--xo-color-accent, #0969da); outline-offset: 2px; border-radius: var(--xo-radius, 6px); }
|
|
53
|
+
.xo-carousel-track > * { flex: 0 0 calc((100% - (var(--xo-carousel-items, 1) - 1) * var(--xo-carousel-gap-used)) / var(--xo-carousel-items, 1)); min-width: 0; box-sizing: border-box; scroll-snap-align: start; }
|
|
54
|
+
.xo-carousel-controls { display: flex; align-items: center; gap: var(--xo-space-3, 1rem); margin-block-start: var(--xo-space-3, 1rem); }
|
|
55
|
+
.xo-carousel-controls [hidden] { display: none; }
|
|
56
|
+
.xo-carousel-nav { display: flex; gap: var(--xo-space-2, .5rem); }
|
|
57
|
+
.xo-carousel-nav button { width: 2.5rem; height: 2.5rem; min-height: 0; padding: 0; border-radius: 50%; font-size: 1.1rem; line-height: 1; }
|
|
58
|
+
.xo-carousel-nav button:disabled { opacity: .4; cursor: default; }
|
|
59
|
+
.xo-carousel-nav button:disabled:hover { border-color: var(--xo-color-control-border, #838c9b); }
|
|
60
|
+
:dir(rtl) > .xo-carousel-controls .xo-carousel-prev span, :dir(rtl) > .xo-carousel-controls .xo-carousel-next span { display: inline-block; transform: scaleX(-1); }
|
|
61
|
+
.xo-carousel-dots { display: flex; flex: 1; justify-content: center; flex-wrap: wrap; gap: var(--xo-space-2, .5rem); }
|
|
62
|
+
.xo-carousel-dots button { width: .65rem; height: .65rem; min-height: 0; padding: 0; border: 0; border-radius: 50%; background: var(--xo-color-control-border, #838c9b); cursor: pointer; }
|
|
63
|
+
.xo-carousel-dots button[aria-current="true"] { background: var(--xo-color-accent, #0969da); }
|
|
64
|
+
.xo-carousel-dots button.xo-carousel-thumb { width: 4rem; height: 3rem; border-radius: 4px; overflow: hidden; opacity: .6; background: none; }
|
|
65
|
+
.xo-carousel-dots button.xo-carousel-thumb[aria-current="true"] { opacity: 1; outline: 2px solid var(--xo-color-accent, #0969da); outline-offset: 2px; }
|
|
66
|
+
.xo-carousel-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
|
67
|
+
.xo-carousel-count { margin-inline-start: auto; color: var(--xo-color-muted, #5b6472); font-size: var(--xo-text-small, .875rem); font-variant-numeric: tabular-nums; }
|
|
68
|
+
}
|
package/src/copy.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* <xo-copy> shows a read-only value (an API key, a link) with a Copy button.
|
|
4
|
+
*
|
|
5
|
+
* <xo-copy value="xo_live_4f9a7f3a" label="API key"></xo-copy>
|
|
6
|
+
*
|
|
7
|
+
* - The button says "Copy", then "Copied" for 1.6 s (`data-state="copied"`, announced politely); the `copied` event fires with the text.
|
|
8
|
+
* When the browser refuses the clipboard the text in the field is selected so Ctrl/Cmd+C works, and `copy-failed` fires.
|
|
9
|
+
*/
|
|
10
|
+
import { XoControl, define, text } from '@xoong/core';
|
|
11
|
+
|
|
12
|
+
class XoCopy extends XoControl {
|
|
13
|
+
static props = { ...XoControl.props, label: String };
|
|
14
|
+
|
|
15
|
+
/** @type {HTMLInputElement | null} */ #input = null;
|
|
16
|
+
/** @type {HTMLButtonElement | null} */ #button = null;
|
|
17
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */ #timer;
|
|
18
|
+
|
|
19
|
+
setValue(v) { super.setValue(v == null ? '' : String(v)); if (this.#input) this.#input.value = super.value; }
|
|
20
|
+
get focusTarget() { return this.#button; }
|
|
21
|
+
|
|
22
|
+
connected() {
|
|
23
|
+
const wrap = document.createElement('div');
|
|
24
|
+
wrap.className = 'xo-copyfield';
|
|
25
|
+
const input = document.createElement('input');
|
|
26
|
+
input.readOnly = true;
|
|
27
|
+
input.setAttribute('aria-label', this.label || this.name || text('xoong.controls.value', 'Value'));
|
|
28
|
+
const button = document.createElement('button');
|
|
29
|
+
button.type = 'button';
|
|
30
|
+
button.setAttribute('aria-live', 'polite');
|
|
31
|
+
button.addEventListener('click', () => this.#copy());
|
|
32
|
+
wrap.append(input, button);
|
|
33
|
+
this.#input = input; this.#button = button;
|
|
34
|
+
this.replaceChildren(wrap);
|
|
35
|
+
queueMicrotask(() => { input.value = super.value; this.#paint(false); });
|
|
36
|
+
}
|
|
37
|
+
disconnected() { clearTimeout(this.#timer); }
|
|
38
|
+
|
|
39
|
+
async #copy() {
|
|
40
|
+
const value = String(super.value);
|
|
41
|
+
try {
|
|
42
|
+
await navigator.clipboard.writeText(value);
|
|
43
|
+
this.#paint(true);
|
|
44
|
+
this.dispatchEvent(new CustomEvent('copied', { bubbles: true, detail: { text: value } }));
|
|
45
|
+
clearTimeout(this.#timer);
|
|
46
|
+
this.#timer = setTimeout(() => this.#paint(false), 1600);
|
|
47
|
+
} catch {
|
|
48
|
+
this.#input?.select();
|
|
49
|
+
this.dispatchEvent(new CustomEvent('copy-failed', { bubbles: true, detail: { text: value } }));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
#paint(done) {
|
|
53
|
+
const b = this.#button;
|
|
54
|
+
if (!b) return;
|
|
55
|
+
b.innerHTML = `<span class="ic" data-ic="${done ? 'check' : 'copy'}" aria-hidden="true"></span>`;
|
|
56
|
+
b.append(done ? text('xoong.controls.copied', 'Copied') : text('xoong.controls.copy', 'Copy'));
|
|
57
|
+
if (done) b.dataset.state = 'copied'; else delete b.dataset.state;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
define('xo-copy', XoCopy);
|