@timmo001/groundwork-ui 0.0.0-stage → 0.0.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.
@@ -0,0 +1,616 @@
1
+ /**
2
+ * `gw-option-list`: the searchable, windowed list behind the select and the
3
+ * command menu.
4
+ * Docs: https://groundwork.timmo.dev/components/inputs/#select
5
+ *
6
+ * @module
7
+ */
8
+ import { GwElement, css, h, setAttribute } from "./element.js";
9
+ import { base, createIcon } from "./styles.js";
10
+
11
+ /** One choice in a select or command menu. */
12
+ export interface OptionItem {
13
+ readonly value: string;
14
+ readonly label: string;
15
+ /** A second line under the label. */
16
+ readonly description?: string;
17
+ /** SVG path data for an icon before the label. */
18
+ readonly icon?: string;
19
+ /** Options with the same group are listed together under its name. */
20
+ readonly group?: string;
21
+ /** Words that also match a search, but aren't shown. */
22
+ readonly keywords?: readonly string[];
23
+ /** Colors shown as small swatches, such as a theme's palette. */
24
+ readonly swatches?: readonly string[];
25
+ }
26
+
27
+ // MDI's magnify and check
28
+ const searchIcon =
29
+ "M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z";
30
+
31
+ const checkIcon = "M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z";
32
+
33
+ // Every row kind has a fixed height, so the list knows where each row sits
34
+ // without measuring and only renders the ones in view
35
+ const optionHeight = 40;
36
+ const describedHeight = 52;
37
+ const groupHeight = 32;
38
+ const listPadding = 6;
39
+ const overscan = 6;
40
+ const pageSize = 8;
41
+
42
+ type Row =
43
+ | { readonly kind: "group"; readonly label: string }
44
+ | {
45
+ readonly kind: "option";
46
+ readonly option: OptionItem;
47
+ readonly index: number;
48
+ };
49
+
50
+ const matches = (option: OptionItem, query: string) =>
51
+ [
52
+ option.label,
53
+ option.description,
54
+ option.group,
55
+ option.value,
56
+ ...(option.keywords ?? []),
57
+ ].some((text) => text?.toLowerCase().includes(query) ?? false);
58
+
59
+ let lastList = 0;
60
+
61
+ /**
62
+ * The searchable, keyboard-driven list behind `gw-select` and
63
+ * `gw-command-menu`. Only the rows in view are rendered, so it stays quick
64
+ * with thousands of options. Type to filter, move with the arrow keys, Home,
65
+ * End and Page Up or Down, and pick with Enter or a click; it fires `pick`
66
+ * with the option's value. Call `reset()` each time it's shown.
67
+ *
68
+ * @element gw-option-list
69
+ * @summary A searchable, windowed list of options.
70
+ *
71
+ * @attr {OptionItem[]} options - The options, as JSON.
72
+ * @attr {string} value - The selected option's value.
73
+ * @attr {string} label - The list's accessible name.
74
+ * @attr {boolean} search - Shows a search field above the list.
75
+ * @attr {string} placeholder - The search placeholder. Defaults to `Search`.
76
+ *
77
+ * @cssprop --gw-option-list-height - The list's maximum height. Defaults to `20rem`.
78
+ *
79
+ * @fires pick - When an option is picked, with its `value` in `detail`.
80
+ */
81
+ export class GwOptionList extends GwElement {
82
+ static override properties = {
83
+ options: { type: "json" },
84
+ value: {},
85
+ label: {},
86
+ search: { type: "boolean", reflect: true },
87
+ placeholder: {},
88
+ } as const;
89
+
90
+ static override styles = [
91
+ base,
92
+ css`
93
+ :host {
94
+ display: flex;
95
+ flex-direction: column;
96
+ min-height: 0;
97
+ }
98
+
99
+ .search {
100
+ display: flex;
101
+ gap: var(--gw-space-2);
102
+ align-items: center;
103
+ padding: var(--gw-space-1) var(--gw-space-3);
104
+ color: var(--gw-color-text-secondary);
105
+ border-block-end: var(--gw-border-width) solid
106
+ var(--gw-color-border-quiet);
107
+ }
108
+
109
+ :host(:not([search])) .search {
110
+ display: none;
111
+ }
112
+
113
+ input {
114
+ flex: 1;
115
+ min-width: 0;
116
+ height: 2.5rem;
117
+ padding: 0;
118
+ font: inherit;
119
+ font-size: var(--gw-font-size-s);
120
+ color: var(--gw-color-text-strong);
121
+ background: none;
122
+ border: 0;
123
+ outline: none;
124
+ }
125
+
126
+ /* The search field is always focused while the list is open, so the
127
+ row's divider marks it rather than a ring */
128
+ input:focus-visible {
129
+ outline: none;
130
+ }
131
+
132
+ input::placeholder {
133
+ color: var(--gw-color-text-secondary);
134
+ }
135
+
136
+ [role="listbox"] {
137
+ position: relative;
138
+ max-height: var(--gw-option-list-height, 20rem);
139
+ overflow-y: auto;
140
+ overscroll-behavior: contain;
141
+ outline: none;
142
+ }
143
+
144
+ .spacer {
145
+ position: relative;
146
+ }
147
+
148
+ .row {
149
+ position: absolute;
150
+ inset-inline: ${listPadding}px;
151
+ box-sizing: border-box;
152
+ display: flex;
153
+ gap: var(--gw-space-3);
154
+ align-items: center;
155
+ padding: 0 var(--gw-space-2);
156
+ }
157
+
158
+ .group {
159
+ align-items: flex-end;
160
+ padding-block-end: var(--gw-space-1);
161
+ font-size: var(--gw-font-size-xs);
162
+ font-weight: var(--gw-font-weight-strong);
163
+ color: var(--gw-color-text-secondary);
164
+ }
165
+
166
+ [role="option"] {
167
+ font-size: var(--gw-font-size-s);
168
+ cursor: pointer;
169
+ border-radius: var(--gw-radius-control);
170
+ transition: background-color var(--gw-motion-fast)
171
+ var(--gw-motion-easing);
172
+ }
173
+
174
+ [role="option"][data-active] {
175
+ background: var(--gw-color-fill-neutral-hovered);
176
+ }
177
+
178
+ [role="option"][aria-selected="true"] {
179
+ color: var(--gw-color-text-strong);
180
+ }
181
+
182
+ [role="option"] > .icon {
183
+ flex: none;
184
+ color: var(--gw-color-text-secondary);
185
+ }
186
+
187
+ [role="option"] > .check {
188
+ color: var(--gw-color-accent);
189
+ }
190
+
191
+ .text {
192
+ display: flex;
193
+ flex: 1;
194
+ flex-direction: column;
195
+ min-width: 0;
196
+ }
197
+
198
+ .text span {
199
+ overflow: hidden;
200
+ text-overflow: ellipsis;
201
+ white-space: nowrap;
202
+ }
203
+
204
+ .description {
205
+ font-size: var(--gw-font-size-xs);
206
+ color: var(--gw-color-text-secondary);
207
+ }
208
+
209
+ .swatches {
210
+ display: flex;
211
+ flex: none;
212
+ }
213
+
214
+ .swatches span {
215
+ width: 0.875rem;
216
+ height: 0.875rem;
217
+ margin-inline-start: -0.25rem;
218
+ border: 1px solid
219
+ color-mix(in oklab, var(--gw-color-text-primary) 20%, transparent);
220
+ border-radius: 50%;
221
+ }
222
+
223
+ .none {
224
+ padding: var(--gw-space-4);
225
+ font-size: var(--gw-font-size-s);
226
+ color: var(--gw-color-text-secondary);
227
+ text-align: center;
228
+ }
229
+
230
+ .none[hidden] {
231
+ display: none;
232
+ }
233
+ `,
234
+ ];
235
+
236
+ declare options: readonly OptionItem[];
237
+ declare value: string;
238
+ declare label: string;
239
+ declare search: boolean;
240
+ declare placeholder: string;
241
+
242
+ readonly #id = `gw-options-${(lastList += 1)}`;
243
+
244
+ readonly #input = h("input", {
245
+ type: "search",
246
+ role: "combobox",
247
+ "aria-autocomplete": "list",
248
+ "aria-expanded": "true",
249
+ autocomplete: "off",
250
+ spellcheck: "false",
251
+ });
252
+
253
+ readonly #spacer = h("div", { class: "spacer" });
254
+
255
+ readonly #listbox = h(
256
+ "div",
257
+ { role: "listbox", tabindex: "-1" },
258
+ this.#spacer,
259
+ );
260
+
261
+ readonly #none = h("div", { class: "none", hidden: true }, "No matches");
262
+
263
+ #query = "";
264
+
265
+ #active = 0;
266
+
267
+ #rows: readonly Row[] = [];
268
+
269
+ #tops: readonly number[] = [];
270
+
271
+ #filtered: readonly OptionItem[] = [];
272
+
273
+ #frame = 0;
274
+
275
+ constructor() {
276
+ super();
277
+ this.options = [];
278
+ this.value = "";
279
+ this.label = "";
280
+ this.search = false;
281
+ this.placeholder = "Search";
282
+ }
283
+
284
+ /** The options left after the search, in order. */
285
+ get filtered() {
286
+ return this.#filtered;
287
+ }
288
+
289
+ /**
290
+ * Clears the search, marks the chosen option, focuses the search field or
291
+ * the list, and scrolls the chosen option into view.
292
+ */
293
+ reset() {
294
+ this.#query = "";
295
+ this.#input.value = "";
296
+ this.#build();
297
+ this.#active = Math.max(
298
+ 0,
299
+ this.#filtered.findIndex((option) => option.value === this.value),
300
+ );
301
+ (this.search ? this.#input : this.#listbox).focus({ preventScroll: true });
302
+ this.#reveal(true);
303
+ }
304
+
305
+ /** Moves the active option, as the arrow keys do. */
306
+ move(by: number) {
307
+ const last = this.#filtered.length - 1;
308
+
309
+ if (last < 0) return;
310
+
311
+ const next = this.#active + by;
312
+
313
+ // Single steps wrap around; page jumps stop at the ends
314
+ if (Math.abs(by) === 1) {
315
+ this.#active = next > last ? 0 : next < 0 ? last : next;
316
+ } else {
317
+ this.#active = Math.min(last, Math.max(0, next));
318
+ }
319
+
320
+ this.#reveal();
321
+ }
322
+
323
+ #pick(option: OptionItem | undefined) {
324
+ if (option === undefined) return;
325
+
326
+ this.dispatchEvent(
327
+ new CustomEvent("pick", {
328
+ detail: { value: option.value },
329
+ bubbles: true,
330
+ composed: true,
331
+ }),
332
+ );
333
+ }
334
+
335
+ readonly #onKeydown = (event: KeyboardEvent) => {
336
+ switch (event.key) {
337
+ case "ArrowDown":
338
+ this.move(1);
339
+ break;
340
+ case "ArrowUp":
341
+ this.move(-1);
342
+ break;
343
+ case "PageDown":
344
+ this.move(pageSize);
345
+ break;
346
+ case "PageUp":
347
+ this.move(-pageSize);
348
+ break;
349
+ case "Home":
350
+ if (this.search && event.target === this.#input) return;
351
+ this.move(-this.#active);
352
+ break;
353
+ case "End":
354
+ if (this.search && event.target === this.#input) return;
355
+ this.move(this.#filtered.length - 1 - this.#active);
356
+ break;
357
+ case "Enter":
358
+ this.#pick(this.#filtered[this.#active]);
359
+ break;
360
+ default:
361
+ return;
362
+ }
363
+
364
+ event.preventDefault();
365
+ };
366
+
367
+ #optionAt(event: Event) {
368
+ const row = event
369
+ .composedPath()
370
+ .find(
371
+ (node) =>
372
+ node instanceof HTMLElement && node.dataset.index !== undefined,
373
+ );
374
+
375
+ return row instanceof HTMLElement ? Number(row.dataset.index) : undefined;
376
+ }
377
+
378
+ protected override create(root: ShadowRoot) {
379
+ this.#input.addEventListener("input", () => {
380
+ this.#query = this.#input.value;
381
+ this.#build();
382
+ this.#active = 0;
383
+ this.#reveal(true);
384
+ });
385
+ this.#input.addEventListener("keydown", this.#onKeydown);
386
+ this.#listbox.addEventListener("keydown", this.#onKeydown);
387
+ this.#listbox.addEventListener("scroll", () => {
388
+ this.#schedule();
389
+ });
390
+ this.#listbox.addEventListener("pointermove", (event) => {
391
+ const index = this.#optionAt(event);
392
+
393
+ if (index !== undefined && index !== this.#active) {
394
+ this.#active = index;
395
+ this.#render();
396
+ }
397
+ });
398
+ this.#listbox.addEventListener("click", (event) => {
399
+ const index = this.#optionAt(event);
400
+
401
+ if (index !== undefined) this.#pick(this.#filtered[index]);
402
+ });
403
+ // Keeps focus in the search field when an option is pressed
404
+ this.#listbox.addEventListener("pointerdown", (event) => {
405
+ if (this.search) event.preventDefault();
406
+ });
407
+
408
+ root.append(
409
+ h("div", { class: "search" }, createIcon(searchIcon), this.#input),
410
+ this.#listbox,
411
+ this.#none,
412
+ );
413
+ }
414
+
415
+ protected override update(changed: ReadonlySet<string>) {
416
+ const listId = `${this.#id}-list`;
417
+
418
+ this.#listbox.id = listId;
419
+ setAttribute(this.#input, "aria-controls", listId);
420
+ setAttribute(this.#input, "placeholder", this.placeholder);
421
+ setAttribute(this.#input, "aria-label", this.label || this.placeholder);
422
+ setAttribute(this.#listbox, "aria-label", this.label || null);
423
+
424
+ if (changed.has("options") || changed.has("value")) {
425
+ this.#build();
426
+ this.#render();
427
+ }
428
+ }
429
+
430
+ #build() {
431
+ const query = this.#query.trim().toLowerCase();
432
+ const options = Array.isArray(this.options) ? this.options : [];
433
+
434
+ this.#filtered =
435
+ query === ""
436
+ ? options
437
+ : options.filter((option) => matches(option, query));
438
+
439
+ const rows: Row[] = [];
440
+ const tops: number[] = [];
441
+ let top = listPadding;
442
+ let group: string | undefined;
443
+
444
+ for (const [index, option] of this.#filtered.entries()) {
445
+ if (option.group !== undefined && option.group !== group) {
446
+ rows.push({ kind: "group", label: option.group });
447
+ tops.push(top);
448
+ top += groupHeight;
449
+ }
450
+
451
+ group = option.group;
452
+ rows.push({ kind: "option", option, index });
453
+ tops.push(top);
454
+ top += option.description === undefined ? optionHeight : describedHeight;
455
+ }
456
+
457
+ this.#rows = rows;
458
+ this.#tops = tops;
459
+ this.#spacer.style.height = `${rows.length === 0 ? 0 : top + listPadding}px`;
460
+ this.#none.hidden = rows.length > 0;
461
+ }
462
+
463
+ #heightOf(row: Row) {
464
+ if (row.kind === "group") return groupHeight;
465
+
466
+ return row.option.description === undefined
467
+ ? optionHeight
468
+ : describedHeight;
469
+ }
470
+
471
+ /** Scrolls the active option into view, then renders. */
472
+ #reveal(center = false) {
473
+ const position = this.#rows.findIndex(
474
+ (row) => row.kind === "option" && row.index === this.#active,
475
+ );
476
+
477
+ if (position >= 0) {
478
+ const listbox = this.#listbox;
479
+ const row = this.#rows[position];
480
+ const top = this.#tops[position] ?? 0;
481
+ const bottom = top + (row === undefined ? 0 : this.#heightOf(row));
482
+ // The group heading above the first option of a group stays in view
483
+ const previous = this.#rows[position - 1];
484
+
485
+ const headed =
486
+ previous?.kind === "group" ? (this.#tops[position - 1] ?? top) : top;
487
+
488
+ if (center) {
489
+ listbox.scrollTop = Math.max(0, top - listbox.clientHeight / 2);
490
+ } else if (headed - listPadding < listbox.scrollTop) {
491
+ listbox.scrollTop = Math.max(0, headed - listPadding);
492
+ } else if (
493
+ bottom + listPadding >
494
+ listbox.scrollTop + listbox.clientHeight
495
+ ) {
496
+ listbox.scrollTop = bottom + listPadding - listbox.clientHeight;
497
+ }
498
+ }
499
+
500
+ this.#render();
501
+ }
502
+
503
+ #schedule() {
504
+ if (this.#frame !== 0) return;
505
+
506
+ this.#frame = requestAnimationFrame(() => {
507
+ this.#frame = 0;
508
+ this.#render();
509
+ });
510
+ }
511
+
512
+ #render() {
513
+ const listbox = this.#listbox;
514
+ const start = listbox.scrollTop;
515
+ const end = start + (listbox.clientHeight || 320);
516
+ const rows: HTMLElement[] = [];
517
+
518
+ // The first row that reaches into view
519
+ let first = this.#rows.findIndex(
520
+ (row, position) =>
521
+ (this.#tops[position] ?? 0) + this.#heightOf(row) >= start,
522
+ );
523
+
524
+ if (first < 0) first = 0;
525
+
526
+ first = Math.max(0, first - overscan);
527
+
528
+ for (let position = first; position < this.#rows.length; position += 1) {
529
+ const top = this.#tops[position] ?? 0;
530
+
531
+ if (top > end && position > first + overscan * 2) break;
532
+
533
+ const row = this.#rows[position];
534
+
535
+ if (row !== undefined) rows.push(this.#rowElement(row, top));
536
+ }
537
+
538
+ this.#spacer.replaceChildren(...rows);
539
+
540
+ const active =
541
+ this.#filtered.length > 0 ? `${this.#id}-${this.#active}` : null;
542
+
543
+ setAttribute(this.#input, "aria-activedescendant", active);
544
+ setAttribute(this.#listbox, "aria-activedescendant", active);
545
+ }
546
+
547
+ #rowElement(row: Row, top: number) {
548
+ if (row.kind === "group") {
549
+ const element = h(
550
+ "div",
551
+ { class: "row group", role: "presentation" },
552
+ row.label,
553
+ );
554
+
555
+ element.style.top = `${top}px`;
556
+ element.style.height = `${groupHeight}px`;
557
+
558
+ return element;
559
+ }
560
+
561
+ const { option, index } = row;
562
+ const selected = option.value === this.value;
563
+
564
+ const element = h(
565
+ "div",
566
+ {
567
+ class: "row",
568
+ role: "option",
569
+ id: `${this.#id}-${index}`,
570
+ "data-index": index,
571
+ "aria-selected": selected ? "true" : "false",
572
+ "aria-setsize": this.#filtered.length,
573
+ "aria-posinset": index + 1,
574
+ "data-active": index === this.#active,
575
+ },
576
+ option.icon === undefined ? null : createIcon(option.icon),
577
+ h(
578
+ "span",
579
+ { class: "text" },
580
+ h("span", {}, option.label),
581
+ option.description === undefined
582
+ ? null
583
+ : h("span", { class: "description" }, option.description),
584
+ ),
585
+ option.swatches === undefined
586
+ ? null
587
+ : h(
588
+ "span",
589
+ { class: "swatches", "aria-hidden": "true" },
590
+ ...option.swatches.map((color) => {
591
+ const swatch = h("span");
592
+
593
+ swatch.style.background = color;
594
+
595
+ return swatch;
596
+ }),
597
+ ),
598
+ selected ? createIcon(checkIcon) : null,
599
+ );
600
+
601
+ if (selected) element.lastElementChild?.classList.add("check");
602
+
603
+ element.style.top = `${top}px`;
604
+ element.style.height = `${this.#heightOf(row)}px`;
605
+
606
+ return element;
607
+ }
608
+ }
609
+
610
+ GwOptionList.define("gw-option-list");
611
+
612
+ declare global {
613
+ interface HTMLElementTagNameMap {
614
+ "gw-option-list": GwOptionList;
615
+ }
616
+ }