@alacris/ui 0.2.4 → 0.3.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/README.md +4 -4
- package/package.json +1 -1
- package/src/components/ui-option.js +5 -0
- package/src/components/ui-select.js +147 -14
package/README.md
CHANGED
|
@@ -46,10 +46,10 @@ The published package is plain ESM. Point an import map at a pinned CDN build of
|
|
|
46
46
|
<script type="importmap">
|
|
47
47
|
{
|
|
48
48
|
"imports": {
|
|
49
|
-
"@alacris/core": "https://cdn.jsdelivr.net/npm/@alacris/core@0.11.
|
|
50
|
-
"@alacris/ui": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.
|
|
51
|
-
"@alacris/ui/theme": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.
|
|
52
|
-
"@alacris/ui/components/": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.
|
|
49
|
+
"@alacris/core": "https://cdn.jsdelivr.net/npm/@alacris/core@0.11.2/dist/alacris.js",
|
|
50
|
+
"@alacris/ui": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.3.0/src/index.js",
|
|
51
|
+
"@alacris/ui/theme": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.3.0/src/theme/index.js",
|
|
52
|
+
"@alacris/ui/components/": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.3.0/src/components/"
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
</script>
|
package/package.json
CHANGED
|
@@ -30,6 +30,11 @@ let uid = 0;
|
|
|
30
30
|
|
|
31
31
|
const styles = css`
|
|
32
32
|
:host { display: block; cursor: pointer; user-select: none; }
|
|
33
|
+
/* A select that filters its options marks the ones that do not match. The
|
|
34
|
+
option hides itself rather than being hidden by a ::slotted() rule in the
|
|
35
|
+
select's shadow tree, which the option's own :host display would win
|
|
36
|
+
against. */
|
|
37
|
+
:host([data-ui-filtered]) { display: none; }
|
|
33
38
|
.control {
|
|
34
39
|
position: relative;
|
|
35
40
|
isolation: isolate;
|
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
// typing jumps to the next option starting with that letter. The panel closes
|
|
13
13
|
// on outside pointerdown and returns focus to the field.
|
|
14
14
|
//
|
|
15
|
+
// Past a handful of options the panel gets a filter field, because scrolling
|
|
16
|
+
// is not a way to find one entry among several hundred. It takes focus when
|
|
17
|
+
// the panel opens, the arrows and Enter work from it, and the options it
|
|
18
|
+
// hides are hidden from the keyboard too. The query is dropped when the panel
|
|
19
|
+
// closes.
|
|
20
|
+
//
|
|
15
21
|
// @prop {string} label=''
|
|
16
22
|
// @prop {string} value='' — the selected option's value
|
|
17
23
|
// @prop {string} variant='filled' — filled | outlined
|
|
@@ -19,6 +25,11 @@
|
|
|
19
25
|
// @prop {boolean} required=false
|
|
20
26
|
// @prop {string} name='' — form participation
|
|
21
27
|
// @prop {string} placeholder='' — shown while nothing is selected
|
|
28
|
+
// @prop {string} search='auto' — auto | always | never; 'auto' shows the
|
|
29
|
+
// filter once there are searchThreshold
|
|
30
|
+
// options or more
|
|
31
|
+
// @prop {number} searchThreshold=8
|
|
32
|
+
// @prop {string} searchPlaceholder='Search'
|
|
22
33
|
// @event change — an option was chosen; detail: { value }
|
|
23
34
|
// @event open — panel enter animation finished
|
|
24
35
|
// @event close — panel exit animation finished
|
|
@@ -202,13 +213,46 @@ const styles = css`
|
|
|
202
213
|
z-index: ${sys.z.modal};
|
|
203
214
|
min-inline-size: 112px;
|
|
204
215
|
max-block-size: 40vh;
|
|
205
|
-
|
|
206
|
-
|
|
216
|
+
/* The filter field stays put while the options scroll under it, so the
|
|
217
|
+
panel is a column and the list is what overflows. Without this the box
|
|
218
|
+
you are typing into scrolls off the top of the list it is filtering. */
|
|
219
|
+
display: flex;
|
|
220
|
+
flex-direction: column;
|
|
221
|
+
overflow: hidden;
|
|
207
222
|
background: ${t.panelBg};
|
|
208
223
|
border-radius: ${sys.radius.xs};
|
|
209
224
|
box-shadow: ${sys.elevation[2]};
|
|
210
225
|
transform-origin: top center;
|
|
211
226
|
}
|
|
227
|
+
.options { overflow: auto; padding-block: ${sys.space(2)}; }
|
|
228
|
+
|
|
229
|
+
.search {
|
|
230
|
+
flex: none;
|
|
231
|
+
display: flex;
|
|
232
|
+
align-items: center;
|
|
233
|
+
gap: ${sys.space(2)};
|
|
234
|
+
padding: ${sys.space(2)} ${sys.space(3)};
|
|
235
|
+
border-block-end: 1px solid ${t.outlineColor};
|
|
236
|
+
color: ${t.labelFg};
|
|
237
|
+
}
|
|
238
|
+
.search input {
|
|
239
|
+
/* A flex item will not shrink below its content, and a text input's
|
|
240
|
+
intrinsic width is wider than a narrow select. */
|
|
241
|
+
min-inline-size: 0;
|
|
242
|
+
flex: 1 1 auto;
|
|
243
|
+
background: transparent;
|
|
244
|
+
border: 0;
|
|
245
|
+
outline: none;
|
|
246
|
+
color: ${t.fg};
|
|
247
|
+
font: ${t.font};
|
|
248
|
+
padding: 0;
|
|
249
|
+
}
|
|
250
|
+
.search input::placeholder { color: ${t.labelFg}; }
|
|
251
|
+
.empty {
|
|
252
|
+
padding: ${sys.space(3)};
|
|
253
|
+
color: ${t.labelFg};
|
|
254
|
+
font: ${t.font};
|
|
255
|
+
}
|
|
212
256
|
|
|
213
257
|
.disabled { opacity: ${sys.state.disabledContent}; pointer-events: none; }
|
|
214
258
|
`;
|
|
@@ -218,15 +262,20 @@ define('ui-select', {
|
|
|
218
262
|
props: {
|
|
219
263
|
label: '', value: '', variant: 'filled', disabled: false, required: false,
|
|
220
264
|
name: '', placeholder: '',
|
|
265
|
+
// 'auto' shows the filter once there are more options than a person will
|
|
266
|
+
// read down; 'always' and 'never' say so outright.
|
|
267
|
+
search: 'auto', searchThreshold: 8, searchPlaceholder: 'Search',
|
|
221
268
|
},
|
|
222
269
|
styles: [base, styles],
|
|
223
270
|
setup(p, host) {
|
|
224
|
-
const { label, value, variant, disabled, required, name, placeholder
|
|
271
|
+
const { label, value, variant, disabled, required, name, placeholder,
|
|
272
|
+
search, searchThreshold, searchPlaceholder } = p;
|
|
225
273
|
formBind(host, { name, value, disabled });
|
|
226
274
|
|
|
227
275
|
const open = signal(false);
|
|
228
276
|
const activeIndex = signal(-1);
|
|
229
277
|
let fieldEl = null;
|
|
278
|
+
let searchEl = null;
|
|
230
279
|
let stopAuto = null;
|
|
231
280
|
|
|
232
281
|
// ---- option tracking: light-DOM children, live through a version signal
|
|
@@ -253,6 +302,40 @@ define('ui-select', {
|
|
|
253
302
|
const isDisabled = (o) => !!o.disabled || o.hasAttribute('disabled');
|
|
254
303
|
const optLabel = (o) => (o.textContent || '').trim();
|
|
255
304
|
|
|
305
|
+
// ---- filtering
|
|
306
|
+
//
|
|
307
|
+
// Scrolling is not a way to find one set among nine hundred, so past a
|
|
308
|
+
// handful of options the panel gets a filter. Matching folds case and
|
|
309
|
+
// accents, the same way the rest of a search box is expected to.
|
|
310
|
+
const query = signal('');
|
|
311
|
+
const fold = (v) => String(v).toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
|
|
312
|
+
const searching = computed(() => {
|
|
313
|
+
const mode = String(search() || 'auto');
|
|
314
|
+
if (mode === 'never' || mode === 'false') return false;
|
|
315
|
+
if (mode === 'always' || mode === 'true') return true;
|
|
316
|
+
return optionEls().length >= Number(searchThreshold() || 8);
|
|
317
|
+
});
|
|
318
|
+
// Every list the keyboard walks and the panel shows is this one, not
|
|
319
|
+
// optionEls: an arrow key that steps onto a hidden option looks broken.
|
|
320
|
+
const shownEls = computed(() => {
|
|
321
|
+
const opts = optionEls();
|
|
322
|
+
const q = fold(query()).trim();
|
|
323
|
+
if (!q || !searching()) return opts;
|
|
324
|
+
return opts.filter((o) => fold(optLabel(o)).includes(q));
|
|
325
|
+
});
|
|
326
|
+
effect(() => {
|
|
327
|
+
const shown = new Set(shownEls());
|
|
328
|
+
for (const o of optionEls()) o.toggleAttribute('data-ui-filtered', !shown.has(o));
|
|
329
|
+
});
|
|
330
|
+
// A narrowed list has a new first row, and the active option has to be on
|
|
331
|
+
// it — otherwise Enter commits something no longer on screen.
|
|
332
|
+
effect(() => {
|
|
333
|
+
const shown = shownEls();
|
|
334
|
+
if (!open()) return;
|
|
335
|
+
const cur = shown[activeIndex()];
|
|
336
|
+
if (!cur || isDisabled(cur)) activeIndex.set(shown.findIndex((o) => !isDisabled(o)));
|
|
337
|
+
});
|
|
338
|
+
|
|
256
339
|
const display = computed(() => {
|
|
257
340
|
const match = optionEls().find((o) => optValue(o) === value());
|
|
258
341
|
return match ? optLabel(match) : value();
|
|
@@ -269,23 +352,29 @@ define('ui-select', {
|
|
|
269
352
|
const v = value();
|
|
270
353
|
const isOpen = open();
|
|
271
354
|
const ai = activeIndex();
|
|
272
|
-
|
|
355
|
+
const activeEl = isOpen ? shownEls()[ai] : null;
|
|
356
|
+
opts.forEach((o) => {
|
|
273
357
|
o.toggleAttribute('selected', optValue(o) === v);
|
|
274
|
-
o.toggleAttribute('active',
|
|
358
|
+
o.toggleAttribute('active', o === activeEl);
|
|
275
359
|
});
|
|
276
360
|
});
|
|
277
|
-
const activeId = computed(() => (open() ?
|
|
361
|
+
const activeId = computed(() => (open() ? shownEls()[activeIndex()]?.id ?? null : null));
|
|
278
362
|
|
|
279
363
|
// ---- open/close
|
|
280
364
|
const openPanel = () => {
|
|
281
365
|
if (disabled() || open()) return;
|
|
282
|
-
const opts =
|
|
366
|
+
const opts = shownEls();
|
|
283
367
|
let i = opts.findIndex((o) => optValue(o) === value() && !isDisabled(o));
|
|
284
368
|
if (i < 0) i = opts.findIndex((o) => !isDisabled(o));
|
|
285
369
|
activeIndex.set(i);
|
|
286
370
|
open.set(true);
|
|
287
371
|
};
|
|
288
|
-
|
|
372
|
+
// The query does not survive the panel: reopening to a list still narrowed
|
|
373
|
+
// by what was typed last time reads as a select that has lost its options.
|
|
374
|
+
const closePanel = () => {
|
|
375
|
+
open.set(false);
|
|
376
|
+
query.set('');
|
|
377
|
+
};
|
|
289
378
|
|
|
290
379
|
const commit = (o) => {
|
|
291
380
|
if (!o || isDisabled(o)) return;
|
|
@@ -331,7 +420,7 @@ define('ui-select', {
|
|
|
331
420
|
|
|
332
421
|
// ---- keyboard
|
|
333
422
|
const move = (delta) => {
|
|
334
|
-
const opts =
|
|
423
|
+
const opts = shownEls();
|
|
335
424
|
if (!opts.length) return;
|
|
336
425
|
let i = activeIndex();
|
|
337
426
|
for (let n = 0; n < opts.length; n++) {
|
|
@@ -340,7 +429,7 @@ define('ui-select', {
|
|
|
340
429
|
}
|
|
341
430
|
};
|
|
342
431
|
const typeahead = (ch) => {
|
|
343
|
-
const opts =
|
|
432
|
+
const opts = shownEls();
|
|
344
433
|
const lower = ch.toLowerCase();
|
|
345
434
|
const start = activeIndex() + 1;
|
|
346
435
|
for (let n = 0; n < opts.length; n++) {
|
|
@@ -353,7 +442,7 @@ define('ui-select', {
|
|
|
353
442
|
};
|
|
354
443
|
const onKeydown = (e) => {
|
|
355
444
|
if (disabled()) return;
|
|
356
|
-
const opts =
|
|
445
|
+
const opts = shownEls();
|
|
357
446
|
if (!open()) {
|
|
358
447
|
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
359
448
|
e.preventDefault();
|
|
@@ -386,10 +475,54 @@ define('ui-select', {
|
|
|
386
475
|
stopAuto?.();
|
|
387
476
|
stopAuto = autoUpdate(el, fieldEl, { placement: 'bottom-start', matchWidth: true, offset: 4 });
|
|
388
477
|
};
|
|
478
|
+
|
|
479
|
+
// Typing filters, so the arrows and Enter have to work from the field the
|
|
480
|
+
// typing goes into. Space is deliberately absent: it is a character here,
|
|
481
|
+
// not a way to choose.
|
|
482
|
+
const onSearchKeydown = (e) => {
|
|
483
|
+
// One press, one action. The panel is rendered through presence(), which
|
|
484
|
+
// gives the subtree its own delegation root on top of the shadow root's,
|
|
485
|
+
// so a keydown inside it reaches this handler twice — and the second
|
|
486
|
+
// call arrives after commit() has already closed the panel and cleared
|
|
487
|
+
// the query, which is to say against the unfiltered list. Enter then
|
|
488
|
+
// chose the first option of the full list instead of the one on screen,
|
|
489
|
+
// and an arrow key moved two rows at a time. Stopping propagation
|
|
490
|
+
// settles the arrows; the open() guard is what makes it not matter how
|
|
491
|
+
// many times this runs.
|
|
492
|
+
if (!open()) return;
|
|
493
|
+
switch (e.key) {
|
|
494
|
+
case 'ArrowDown': e.preventDefault(); e.stopPropagation(); move(1); break;
|
|
495
|
+
case 'ArrowUp': e.preventDefault(); e.stopPropagation(); move(-1); break;
|
|
496
|
+
case 'Enter': e.preventDefault(); e.stopPropagation(); commit(shownEls()[activeIndex()]); break;
|
|
497
|
+
case 'Tab': closePanel(); break;
|
|
498
|
+
default:
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
// Focus follows the panel into existence; ref runs once the element is
|
|
502
|
+
// there, which setup() is far too early for.
|
|
503
|
+
const searchRef = (el) => {
|
|
504
|
+
searchEl = el;
|
|
505
|
+
if (el) queueMicrotask(() => el.focus());
|
|
506
|
+
};
|
|
507
|
+
const searchView = () => html`
|
|
508
|
+
<div class="search">
|
|
509
|
+
<ui-icon name="search"></ui-icon>
|
|
510
|
+
<input type="text" part="search" autocomplete="off" spellcheck="false"
|
|
511
|
+
role="combobox" aria-expanded="true" aria-controls="listbox"
|
|
512
|
+
aria-autocomplete="list" aria-activedescendant=${activeId}
|
|
513
|
+
aria-label=${() => searchPlaceholder() || 'Search'}
|
|
514
|
+
placeholder=${() => searchPlaceholder() || 'Search'}
|
|
515
|
+
.value=${query}
|
|
516
|
+
@input=${(e) => query.set(e.composedPath()[0].value)}
|
|
517
|
+
@keydown=${onSearchKeydown}
|
|
518
|
+
ref=${searchRef}></div>`;
|
|
389
519
|
const panelView = () => html`
|
|
390
|
-
<div class="panel" part="panel"
|
|
391
|
-
|
|
392
|
-
<
|
|
520
|
+
<div class="panel" part="panel" ref=${panelRef}>
|
|
521
|
+
${() => (searching() ? searchView() : null)}
|
|
522
|
+
<div class="options" role="listbox" id="listbox" aria-label=${() => label() || null}>
|
|
523
|
+
<slot></slot>
|
|
524
|
+
</div>
|
|
525
|
+
${() => (shownEls().length ? null : html`<div class="empty">No matches</div>`)}
|
|
393
526
|
</div>`;
|
|
394
527
|
|
|
395
528
|
return html`
|