@3sln/nook 0.0.0-stage → 0.2.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,307 @@
1
+ import { icon } from "./icons.js";
2
+ let sequence = 0;
3
+ const slot = (node, name) =>
4
+ [...node.children].find((child) => child.getAttribute("slot") === name);
5
+ const clone = (node) =>
6
+ node instanceof HTMLTemplateElement
7
+ ? node.content.cloneNode(true)
8
+ : node.cloneNode(true);
9
+ const slotText = (node, name) => {
10
+ const value = slot(node, name);
11
+ return (
12
+ (value instanceof HTMLTemplateElement
13
+ ? value.content.textContent
14
+ : value?.textContent
15
+ )?.trim() || ""
16
+ );
17
+ };
18
+ const optionLabel = (node) =>
19
+ node.getAttribute("label") ||
20
+ slotText(node, "label") ||
21
+ node.textContent.trim();
22
+ const valueOf = (node) => node.getAttribute("value") ?? node.textContent.trim();
23
+ export default function enumPicker({
24
+ source,
25
+ view,
26
+ make,
27
+ i18n,
28
+ title,
29
+ open,
30
+ close,
31
+ commit,
32
+ initialValue,
33
+ }) {
34
+ const trigger = make("button", "control enum-trigger");
35
+ trigger.type = "button";
36
+ trigger.className = "enum-trigger";
37
+ trigger.setAttribute("aria-haspopup", "listbox");
38
+ trigger.setAttribute("aria-expanded", "false");
39
+ const current = make("span", "enum-current");
40
+ current.className = "enum-current";
41
+ const arrow = make("span", "enum-chevron");
42
+ arrow.className = "enum-chevron";
43
+ arrow.innerHTML = icon("down");
44
+ trigger.append(current, arrow);
45
+ const proxy = make("input", "enum-value");
46
+ proxy.type = "text";
47
+ proxy.className = "enum-value";
48
+ proxy.tabIndex = -1;
49
+ proxy.autocomplete = "off";
50
+ proxy.setAttribute("aria-hidden", "true");
51
+ const wrap = make("span", "enum-control");
52
+ wrap.className = "enum-control";
53
+ const error = make("small", "enum-error");
54
+ error.className = "enum-error";
55
+ error.hidden = true;
56
+ error.setAttribute("role", "alert");
57
+ wrap.append(trigger, proxy, error);
58
+ const list = make("div", "enum-options");
59
+ list.className = "enum-options";
60
+ list.id = `nook-enum-${++sequence}`;
61
+ list.setAttribute("role", "listbox");
62
+ trigger.setAttribute("aria-controls", list.id);
63
+ current.id = list.id + "-value";
64
+ trigger.setAttribute("aria-describedby", current.id);
65
+ const empty = make("p", "enum-empty", "No options available.");
66
+ empty.className = "enum-empty";
67
+ view.body.append(list, empty);
68
+ const records = new Map();
69
+ let ordered = [],
70
+ active = null,
71
+ selectedSignature = "",
72
+ lastSourceValue = Symbol();
73
+ proxy.value = initialValue ?? source.getAttribute("value") ?? "";
74
+ const enabled = () => ordered.filter((record) => !record.button.disabled);
75
+ const focus = () => {
76
+ const target =
77
+ ordered.find(
78
+ (record) =>
79
+ record.button.getAttribute("aria-selected") === "true" &&
80
+ !record.button.disabled,
81
+ ) || enabled()[0];
82
+ if (!target) return false;
83
+ active = target;
84
+ for (const record of ordered)
85
+ record.button.tabIndex = record === target ? 0 : -1;
86
+ target.button.focus();
87
+ return true;
88
+ };
89
+ trigger.onclick = () => {
90
+ open();
91
+ };
92
+ proxy.addEventListener("invalid", (event) => {
93
+ event.preventDefault();
94
+ trigger.setAttribute("aria-invalid", "true");
95
+ i18n.text(
96
+ error,
97
+ proxy.value ? "This option is no longer available." : "Choose an option.",
98
+ );
99
+ error.hidden = false;
100
+ trigger.focus();
101
+ });
102
+ const select = (node) => {
103
+ const record = records.get(node);
104
+ if (
105
+ !record ||
106
+ record.button.disabled ||
107
+ node.hasAttribute("disabled") ||
108
+ node.hidden ||
109
+ !node.isConnected
110
+ )
111
+ return;
112
+ proxy.value = valueOf(node);
113
+ proxy.setCustomValidity("");
114
+ source.setAttribute("value", proxy.value);
115
+ reconcile();
116
+ commit(proxy.value);
117
+ close(trigger);
118
+ };
119
+ list.addEventListener("keydown", (event) => {
120
+ const candidates = enabled();
121
+ if (!candidates.length) return;
122
+ let target;
123
+ if (["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) {
124
+ event.preventDefault();
125
+ event.stopPropagation();
126
+ const index = candidates.findIndex(
127
+ (record) => record.button === list.getRootNode().activeElement,
128
+ );
129
+ target =
130
+ event.key === "Home"
131
+ ? candidates[0]
132
+ : event.key === "End"
133
+ ? candidates.at(-1)
134
+ : candidates[
135
+ (index +
136
+ (event.key === "ArrowUp" ? -1 : 1) +
137
+ candidates.length) %
138
+ candidates.length
139
+ ];
140
+ } else if (
141
+ event.key.length === 1 &&
142
+ !event.altKey &&
143
+ !event.ctrlKey &&
144
+ !event.metaKey &&
145
+ event.key !== " "
146
+ ) {
147
+ const start = candidates.findIndex((record) => record === active);
148
+ target = [
149
+ ...candidates.slice(start + 1),
150
+ ...candidates.slice(0, start + 1),
151
+ ].find((record) =>
152
+ i18n
153
+ .t(optionLabel(record.source))
154
+ .toLocaleLowerCase()
155
+ .startsWith(event.key.toLocaleLowerCase()),
156
+ );
157
+ if (target) {
158
+ event.preventDefault();
159
+ event.stopPropagation();
160
+ }
161
+ }
162
+ if (target) {
163
+ active = target;
164
+ for (const record of ordered)
165
+ record.button.tabIndex = record === target ? 0 : -1;
166
+ target.button.focus();
167
+ target.button.scrollIntoView?.({ block: "nearest" });
168
+ }
169
+ });
170
+ function reconcile() {
171
+ const heading = title();
172
+ i18n.text(view.heading, heading);
173
+ i18n.attr(view.el, "aria-label", heading);
174
+ i18n.attr(trigger, "aria-label", heading);
175
+ i18n.attr(list, "aria-label", heading);
176
+ const nodes = [...source.querySelectorAll("nook-option,option")].filter(
177
+ (node) =>
178
+ !node.hidden && node.closest("nook-setting,nook-arg") === source,
179
+ );
180
+ const wanted = new Set(nodes);
181
+ for (const [node, record] of records)
182
+ if (!wanted.has(node)) {
183
+ record.button.remove();
184
+ records.delete(node);
185
+ }
186
+ let cursor = list.firstChild;
187
+ ordered = [];
188
+ for (const node of nodes) {
189
+ let record = records.get(node);
190
+ if (!record) {
191
+ const button = make("button", "enum-option");
192
+ button.type = "button";
193
+ button.className = "enum-option";
194
+ button.setAttribute("role", "option");
195
+ button.onclick = () => select(node);
196
+ record = { source: node, button };
197
+ records.set(node, record);
198
+ }
199
+ const label = optionLabel(node) || valueOf(node),
200
+ description =
201
+ node.getAttribute("description") || slotText(node, "description");
202
+ if (record.signature !== node.outerHTML) {
203
+ const contents = make("span", "enum-option-content");
204
+ contents.className = "enum-option-content";
205
+ const custom = slot(node, "content");
206
+ if (custom) contents.append(clone(custom));
207
+ else {
208
+ const image = slot(node, "icon");
209
+ if (image) {
210
+ const imageWrap = make("span", "enum-option-icon");
211
+ imageWrap.className = "enum-option-icon";
212
+ imageWrap.append(clone(image));
213
+ contents.append(imageWrap);
214
+ }
215
+ const text = make("span", "enum-option-text");
216
+ text.append(make("span", "enum-option-label", label));
217
+ if (description)
218
+ text.append(make("small", "enum-option-description", description));
219
+ contents.append(text);
220
+ }
221
+ const check = make("span", "enum-check");
222
+ check.className = "enum-check";
223
+ check.innerHTML = icon("check");
224
+ record.button.replaceChildren(contents, check);
225
+ record.signature = node.outerHTML;
226
+ }
227
+ i18n.attr(record.button, "aria-label", label);
228
+ i18n.attr(record.button, "aria-description", description);
229
+ record.button.disabled = node.hasAttribute("disabled");
230
+ if (record.button !== cursor) list.insertBefore(record.button, cursor);
231
+ cursor = record.button.nextSibling;
232
+ ordered.push(record);
233
+ }
234
+ const attr = source.getAttribute("value");
235
+ if (attr !== lastSourceValue) {
236
+ if (attr !== null) proxy.value = attr;
237
+ lastSourceValue = attr;
238
+ }
239
+ if (attr === null && !proxy.value) {
240
+ const first = enabled()[0];
241
+ if (first) proxy.value = valueOf(first.source);
242
+ }
243
+ const selected = ordered.find(
244
+ (record) => valueOf(record.source) === proxy.value,
245
+ );
246
+ proxy.required = source.hasAttribute("required");
247
+ proxy.setCustomValidity(
248
+ proxy.value && !selected ? i18n.t("Choose an available option.") : "",
249
+ );
250
+ trigger.setAttribute(
251
+ "aria-invalid",
252
+ String(Boolean(proxy.value && !selected)),
253
+ );
254
+ if (selected && (!proxy.required || proxy.value)) error.hidden = true;
255
+ trigger.disabled = source.hasAttribute("disabled") || !enabled().length;
256
+ const signature = selected
257
+ ? `${selected.signature}|${proxy.value}`
258
+ : "empty";
259
+ if (signature !== selectedSignature) {
260
+ current.replaceChildren();
261
+ if (selected) {
262
+ const image = slot(selected.source, "icon");
263
+ if (image) {
264
+ const imageWrap = make("span", "enum-option-icon");
265
+ imageWrap.className = "enum-option-icon";
266
+ imageWrap.append(clone(image));
267
+ current.append(imageWrap);
268
+ }
269
+ current.append(
270
+ make(
271
+ "span",
272
+ "enum-selected-label",
273
+ optionLabel(selected.source) || proxy.value,
274
+ ),
275
+ );
276
+ } else
277
+ current.append(
278
+ make(
279
+ "span",
280
+ "enum-placeholder",
281
+ source.getAttribute("placeholder") || "Choose…",
282
+ ),
283
+ );
284
+ selectedSignature = signature;
285
+ }
286
+ if (!ordered.includes(active) || active?.button.disabled)
287
+ active = selected && !selected.button.disabled ? selected : enabled()[0];
288
+ for (const record of ordered) {
289
+ record.button.setAttribute("aria-selected", String(record === selected));
290
+ record.button.tabIndex = record === active ? 0 : -1;
291
+ }
292
+ empty.hidden = Boolean(nodes.length);
293
+ }
294
+ return {
295
+ element: wrap,
296
+ trigger,
297
+ proxy,
298
+ reconcile,
299
+ focus,
300
+ syncOpen(value) {
301
+ trigger.setAttribute("aria-expanded", String(value));
302
+ },
303
+ dispose() {
304
+ view.el.remove();
305
+ },
306
+ };
307
+ }