@timmo001/groundwork-ui 0.0.0-stage → 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/LICENSE +201 -0
- package/README.md +32 -2
- package/package.json +39 -4
- package/src/dismiss.ts +165 -0
- package/src/element.ts +375 -0
- package/src/field-info.ts +56 -0
- package/src/fields.ts +346 -0
- package/src/gw-alert.ts +117 -0
- package/src/gw-badge.ts +171 -0
- package/src/gw-banner.ts +120 -0
- package/src/gw-button.ts +438 -0
- package/src/gw-card.ts +187 -0
- package/src/gw-checkbox.ts +113 -0
- package/src/gw-chip.ts +122 -0
- package/src/gw-command-menu.ts +283 -0
- package/src/gw-dialog.ts +246 -0
- package/src/gw-dot.ts +89 -0
- package/src/gw-empty-state.ts +107 -0
- package/src/gw-expansion.ts +224 -0
- package/src/gw-facts.ts +141 -0
- package/src/gw-fade-in.ts +108 -0
- package/src/gw-gauge.ts +328 -0
- package/src/gw-input.ts +127 -0
- package/src/gw-menu-button.ts +386 -0
- package/src/gw-meter.ts +194 -0
- package/src/gw-notification-center.ts +155 -0
- package/src/gw-notification.ts +222 -0
- package/src/gw-option-list.ts +616 -0
- package/src/gw-page-header.ts +125 -0
- package/src/gw-popover.ts +382 -0
- package/src/gw-section-title.ts +71 -0
- package/src/gw-segmented.ts +301 -0
- package/src/gw-select.ts +361 -0
- package/src/gw-setting-row.ts +168 -0
- package/src/gw-skeleton.ts +178 -0
- package/src/gw-spinner.ts +84 -0
- package/src/gw-toast.ts +167 -0
- package/src/index.ts +76 -0
- package/src/layout.ts +182 -0
- package/src/motion.ts +105 -0
- package/src/styles.ts +122 -0
|
@@ -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
|
+
}
|