@alacris/ui 0.2.3 → 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 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.3/src/index.js",
51
- "@alacris/ui/theme": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.2.3/src/theme/index.js",
52
- "@alacris/ui/components/": "https://cdn.jsdelivr.net/npm/@alacris/ui@0.2.3/src/components/"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alacris/ui",
3
- "version": "0.2.3",
3
+ "version": "0.3.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`
@@ -222,21 +222,22 @@ const styles = css`
222
222
  .filled.has-label textarea { padding-top: ${sys.space(7)}; }
223
223
  .with-leading.multiline .field { padding-inline-start: ${sys.space(4)}; }
224
224
  .with-leading.multiline textarea { padding-inline-start: 0; }
225
- /* A number field's stepper gets its own lane.
226
-
227
- The browser draws the spin buttons inside the input's content box, at the
228
- inline end, and they are painted over whatever is already there: the
229
- floating label, the placeholder, and the value itself once it is long
230
- enough. On a narrow field it lands squarely on the label a chevron
231
- sitting on the word it is meant to sit beside.
232
-
233
- So the end of the field is reserved for it. The input is padded by the
234
- stepper's width, and the label and legend are shortened by the same
235
- amount so a long label ellipsises before it reaches the buttons rather
236
- than sliding underneath them. appearance:none does not help here: it
237
- removes the field's own chrome and leaves the ::-webkit-*-spin-button
238
- alone, which is why this needs saying explicitly. */
239
- .numeric input { padding-inline-end: ${t.stepperWidth}; }
225
+ /* A number field's spin buttons are laid out at the inline end of the
226
+ input's *content* box rather than at the edge of the field. That is the
227
+ opposite of what it looks like, and it is why the obvious fix makes
228
+ things worse: padding the input to reserve a lane for them moves the end
229
+ of the content box inward and takes the buttons with it, so they land
230
+ further onto the label than they started. That is what shipped in 0.2.3
231
+ — a chevron sitting on the "n" of "Min", clipped to a single arrow —
232
+ and it was a regression, not the original complaint.
233
+
234
+ They cannot be moved out of the way either: margin-inline-end on the
235
+ pseudo-element is ignored. So they are left where the browser puts them,
236
+ at the end of the field where there is already room, and only the label
237
+ and the legend are shortened, so a long one ellipsises before it reaches
238
+ the buttons rather than sliding underneath them. appearance:none does
239
+ not help here: it removes the field's own chrome and leaves the
240
+ ::-webkit-*-spin-button alone, which is why this needs saying. */
240
241
  .numeric .label {
241
242
  max-inline-size: calc(100% - ${sys.space(4)} - ${t.stepperWidth});
242
243
  overflow: hidden;
@@ -246,15 +247,15 @@ const styles = css`
246
247
  .numeric legend { max-inline-size: calc(100% - ${t.stepperWidth}); }
247
248
  .numeric input::-webkit-outer-spin-button,
248
249
  .numeric input::-webkit-inner-spin-button {
249
- /* Held at the end of the reserved lane rather than tight against the
250
- text, and always visible: a stepper that appears on hover is a control
251
- nobody knows is there. */
250
+ /* Nudged off the text rather than sitting tight against it, and always
251
+ visible: a stepper that only appears on hover is a control nobody
252
+ knows is there. */
252
253
  margin: 0;
253
254
  margin-inline-start: ${sys.space(2)};
254
255
  opacity: 1;
255
256
  }
256
- /* Firefox draws no buttons at all unless asked, so the reserved lane would
257
- be an empty gap. Asking for them makes the two engines agree. */
257
+ /* Firefox draws no buttons at all unless asked. Asking for them makes the
258
+ two engines agree about what a number field looks like. */
258
259
  @supports (-moz-appearance: number-input) {
259
260
  .numeric input { -moz-appearance: number-input; }
260
261
  }