@alacris/ui 0.2.4 → 0.4.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 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.1/dist/alacris.js",
50
- "@alacris/ui": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.2.4/src/index.js",
51
- "@alacris/ui/theme": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.2.4/src/theme/index.js",
52
- "@alacris/ui/components/": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.2.4/src/components/"
49
+ "@alacris/core": "https://cdn.jsdelivr.net/npm/@alacris/core@0.11.3/dist/alacris.js",
50
+ "@alacris/ui": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.4.0/src/index.js",
51
+ "@alacris/ui/theme": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.4.0/src/theme/index.js",
52
+ "@alacris/ui/components/": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.4.0/src/components/"
53
53
  }
54
54
  }
55
55
  </script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alacris/ui",
3
- "version": "0.2.4",
3
+ "version": "0.4.0",
4
4
  "description": "Themeable design system for Alacris — Material defaults, sixty-eight custom elements, ESM-only, no build step.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- overflow: auto;
206
- padding-block: ${sys.space(2)};
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 } = p;
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
- opts.forEach((o, i) => {
355
+ const activeEl = isOpen ? shownEls()[ai] : null;
356
+ opts.forEach((o) => {
273
357
  o.toggleAttribute('selected', optValue(o) === v);
274
- o.toggleAttribute('active', isOpen && i === ai);
358
+ o.toggleAttribute('active', o === activeEl);
275
359
  });
276
360
  });
277
- const activeId = computed(() => (open() ? optionEls()[activeIndex()]?.id ?? null : null));
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 = optionEls();
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
- const closePanel = () => open.set(false);
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 = optionEls();
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 = optionEls();
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 = optionEls();
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" role="listbox" id="listbox"
391
- aria-label=${() => label() || null} ref=${panelRef}>
392
- <slot></slot>
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`
@@ -12,7 +12,16 @@
12
12
  // @prop {boolean} clearable=false — trailing ✕ while there is content
13
13
  // @prop {string} name='' — form participation
14
14
  // @prop {number} maxlength=0 — >0 shows a character counter and enforces it
15
- // @prop {number} rows=3 — textarea rows
15
+ // @prop {number} rows=3
16
+ // @prop {string} autocomplete='' — absent unless set; 'off' keeps the
17
+ // browser's saved-value list out of a
18
+ // field whose contents are not a name,
19
+ // an address or a password
20
+ // @prop {string} autocapitalize='' — 'off' for anything case-sensitive
21
+ // @prop {string} autocorrect='' — 'off' to stop substitutions
22
+ // @prop {string} spellcheck='' — 'false' for code and query syntax
23
+ // @prop {string} inputmode=''
24
+ // @prop {string} enterkeyhint='' — textarea rows
16
25
  // @event input — every keystroke; detail: { value }
17
26
  // @event change — committed (blur/Enter); detail: { value }
18
27
  // @event clear — the clear affordance was used
@@ -284,10 +293,20 @@ define('ui-text-field', {
284
293
  variant: 'filled', label: '', value: '', type: 'text', placeholder: '',
285
294
  helper: '', error: '', disabled: false, required: false, clearable: false,
286
295
  name: '', maxlength: 0, rows: 3,
296
+ // A field the platform must keep its hands off has to be able to say so.
297
+ // These are absent unless set, so nothing changes for an ordinary field.
298
+ autocomplete: '', autocapitalize: '', autocorrect: '', spellcheck: '',
299
+ inputmode: '', enterkeyhint: '',
287
300
  },
288
301
  styles: [base, styles],
289
302
  setup(p, host) {
290
- const { variant, label, value, type, placeholder, helper, error, disabled, required, clearable, name, maxlength, rows } = p;
303
+ const { variant, label, value, type, placeholder, helper, error, disabled, required, clearable, name, maxlength, rows,
304
+ autocomplete, autocapitalize, autocorrect, spellcheck, inputmode, enterkeyhint } = p;
305
+
306
+ // An empty prop means "say nothing", so the browser keeps its default.
307
+ // Writing the attribute as an empty string would not: autocomplete="" is
308
+ // a value, and spellcheck="" reads as spellcheck="true".
309
+ const attr = (sig) => () => (sig() === '' ? null : sig());
291
310
  formBind(host, { name, value, disabled });
292
311
 
293
312
  const focused = signal(false);
@@ -325,6 +344,12 @@ define('ui-text-field', {
325
344
  maxlength=${() => (maxlength() > 0 ? maxlength() : null)}
326
345
  ?required=${required} ?disabled=${disabled}
327
346
  aria-invalid=${() => (error() ? 'true' : null)}
347
+ autocomplete=${attr(autocomplete)}
348
+ autocapitalize=${attr(autocapitalize)}
349
+ autocorrect=${attr(autocorrect)}
350
+ spellcheck=${attr(spellcheck)}
351
+ inputmode=${attr(inputmode)}
352
+ enterkeyhint=${attr(enterkeyhint)}
328
353
  @input=${onInput} @change=${commit}
329
354
  @focus=${() => focused.set(true)} @blur=${() => focused.set(false)}></textarea></span>`
330
355
  : html`<input part="input" ref=${(el) => (input = el)}
@@ -333,6 +358,12 @@ define('ui-text-field', {
333
358
  maxlength=${() => (maxlength() > 0 ? maxlength() : null)}
334
359
  ?required=${required} ?disabled=${disabled}
335
360
  aria-invalid=${() => (error() ? 'true' : null)}
361
+ autocomplete=${attr(autocomplete)}
362
+ autocapitalize=${attr(autocapitalize)}
363
+ autocorrect=${attr(autocorrect)}
364
+ spellcheck=${attr(spellcheck)}
365
+ inputmode=${attr(inputmode)}
366
+ enterkeyhint=${attr(enterkeyhint)}
336
367
  @input=${onInput} @change=${commit}
337
368
  @focus=${() => focused.set(true)} @blur=${() => focused.set(false)}>`)}
338
369
  ${() => (clearable() && value() !== '' && !disabled()