@selkit/dom 0.1.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/dist/index.js ADDED
@@ -0,0 +1,653 @@
1
+ // src/dom.ts
2
+ import { computeVirtualRange, createSelkit } from "@selkit/core";
3
+
4
+ // src/positioner.ts
5
+ function computePosition(triggerRect, dropdownHeight, viewportHeight, gap = 4) {
6
+ const spaceBelow = viewportHeight - triggerRect.bottom;
7
+ const spaceAbove = triggerRect.top;
8
+ const placement = spaceBelow < dropdownHeight && spaceAbove > spaceBelow ? "top" : "bottom";
9
+ const top = placement === "bottom" ? triggerRect.bottom + gap : triggerRect.top - gap - dropdownHeight;
10
+ return { placement, top, left: triggerRect.left, width: triggerRect.width };
11
+ }
12
+ function attachPositioner(trigger, dropdown, autoWidth = false) {
13
+ const update = () => {
14
+ const rect = trigger.getBoundingClientRect();
15
+ const pos = computePosition(rect, dropdown.offsetHeight, window.innerHeight);
16
+ dropdown.style.position = "fixed";
17
+ dropdown.style.top = `${pos.top}px`;
18
+ dropdown.style.left = `${pos.left}px`;
19
+ if (autoWidth) {
20
+ dropdown.style.minWidth = `${pos.width}px`;
21
+ dropdown.style.width = "max-content";
22
+ } else {
23
+ dropdown.style.width = `${pos.width}px`;
24
+ }
25
+ dropdown.dataset.placement = pos.placement;
26
+ };
27
+ const onScroll = () => update();
28
+ const onResize = () => update();
29
+ window.addEventListener("scroll", onScroll, true);
30
+ window.addEventListener("resize", onResize);
31
+ update();
32
+ return {
33
+ update,
34
+ destroy() {
35
+ window.removeEventListener("scroll", onScroll, true);
36
+ window.removeEventListener("resize", onResize);
37
+ }
38
+ };
39
+ }
40
+
41
+ // src/dom.ts
42
+ var LOAD_MORE_THRESHOLD = 32;
43
+ var DEFAULT_ITEM_HEIGHT = 36;
44
+ var SR_ONLY_CSS = "position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0";
45
+ function resolveParent(parent) {
46
+ if (!parent) return null;
47
+ if (typeof parent !== "string") return parent;
48
+ const el = document.querySelector(parent);
49
+ if (!el) throw new Error(`[selkit] \u627E\u4E0D\u5230 dropdownParent ${parent}`);
50
+ return el;
51
+ }
52
+ function parseSelectElement(select) {
53
+ const options = [];
54
+ const selectedValues = [];
55
+ let placeholder = select.dataset.placeholder;
56
+ const readOption = (o) => {
57
+ if (o.value === "") {
58
+ if (!placeholder) placeholder = o.textContent?.trim() || void 0;
59
+ return null;
60
+ }
61
+ if (o.selected) selectedValues.push(o.value);
62
+ return {
63
+ value: o.value,
64
+ label: (o.label || o.textContent || o.value).trim(),
65
+ ...o.disabled ? { disabled: true } : {}
66
+ };
67
+ };
68
+ for (const child of Array.from(select.children)) {
69
+ if (child instanceof HTMLOptGroupElement) {
70
+ const opts = [];
71
+ for (const o of Array.from(child.children)) {
72
+ if (o instanceof HTMLOptionElement) {
73
+ const parsed = readOption(o);
74
+ if (parsed) opts.push(parsed);
75
+ }
76
+ }
77
+ if (opts.length) {
78
+ options.push({
79
+ label: child.label,
80
+ ...child.disabled ? { disabled: true } : {},
81
+ options: opts
82
+ });
83
+ }
84
+ } else if (child instanceof HTMLOptionElement) {
85
+ const parsed = readOption(child);
86
+ if (parsed) options.push(parsed);
87
+ }
88
+ }
89
+ const value = select.multiple ? selectedValues : selectedValues[0] ?? null;
90
+ return {
91
+ options,
92
+ value,
93
+ multiple: select.multiple,
94
+ disabled: select.disabled,
95
+ placeholder,
96
+ name: select.name || void 0
97
+ };
98
+ }
99
+ function mergeSelectConfig(config, select) {
100
+ const parsed = parseSelectElement(select);
101
+ return {
102
+ ...config,
103
+ options: config.options ?? parsed.options,
104
+ multiple: config.multiple ?? parsed.multiple,
105
+ value: config.value ?? parsed.value,
106
+ disabled: config.disabled ?? parsed.disabled,
107
+ placeholder: config.placeholder ?? parsed.placeholder,
108
+ name: config.name ?? parsed.name
109
+ };
110
+ }
111
+ var SelkitDom = class {
112
+ controller;
113
+ element;
114
+ #prefix;
115
+ #multiple;
116
+ #checkboxes;
117
+ #autogrow;
118
+ #dropdownAutoWidth;
119
+ #clearable;
120
+ #placeholder;
121
+ #name;
122
+ #virtual;
123
+ #itemHeight;
124
+ #dropdownParent;
125
+ #templateSelection;
126
+ #templateOption;
127
+ #sourceSelect;
128
+ #control;
129
+ #field;
130
+ #input;
131
+ #indicators;
132
+ #dropdown;
133
+ #live;
134
+ #hiddenContainer = null;
135
+ #selectPrevDisplay = "";
136
+ #dragFrom = -1;
137
+ #positioner = null;
138
+ #unsubscribe;
139
+ #offClose;
140
+ #offCreate;
141
+ #offAnnounce;
142
+ #onDocPointer;
143
+ constructor(target, config) {
144
+ const host = typeof target === "string" ? document.querySelector(target) : target;
145
+ if (!host) {
146
+ throw new Error(`[selkit] \u627E\u4E0D\u5230\u639B\u8F09\u76EE\u6A19 ${String(target)}`);
147
+ }
148
+ const sourceSelect = host instanceof HTMLSelectElement ? host : null;
149
+ const cfg = sourceSelect ? mergeSelectConfig(config, sourceSelect) : config;
150
+ this.#sourceSelect = sourceSelect;
151
+ this.#prefix = cfg.classPrefix ?? "selkit";
152
+ this.#multiple = cfg.multiple ?? false;
153
+ this.#checkboxes = cfg.checkboxes ?? false;
154
+ this.#autogrow = cfg.autogrow ?? false;
155
+ this.#dropdownAutoWidth = cfg.dropdownAutoWidth ?? false;
156
+ this.#clearable = cfg.clearable ?? !this.#multiple;
157
+ this.#placeholder = cfg.placeholder ?? "";
158
+ this.#name = cfg.name;
159
+ this.#virtual = cfg.virtualScroll ?? false;
160
+ this.#itemHeight = cfg.itemHeight ?? DEFAULT_ITEM_HEIGHT;
161
+ this.#dropdownParent = resolveParent(cfg.dropdownParent);
162
+ this.#templateSelection = cfg.templateSelection;
163
+ this.#templateOption = cfg.templateOption;
164
+ this.controller = createSelkit(cfg);
165
+ this.element = document.createElement("div");
166
+ this.element.className = this.#prefix;
167
+ if (this.#multiple) this.element.classList.add(this.#cls("", "multiple"));
168
+ if (this.#multiple && this.#checkboxes) {
169
+ this.element.classList.add(this.#cls("", "checkboxes"));
170
+ }
171
+ if (this.#autogrow) this.element.classList.add(this.#cls("", "autogrow"));
172
+ if (this.#dropdownAutoWidth) {
173
+ this.element.classList.add(this.#cls("", "auto-width"));
174
+ }
175
+ this.#control = document.createElement("div");
176
+ this.#control.className = this.#cls("control");
177
+ this.#control.tabIndex = 0;
178
+ this.#field = document.createElement("div");
179
+ this.#field.className = this.#cls("field");
180
+ this.#input = document.createElement("input");
181
+ this.#input.className = this.#cls("input");
182
+ this.#input.type = "text";
183
+ this.#input.autocomplete = "off";
184
+ this.#input.setAttribute("aria-autocomplete", "list");
185
+ const ariaLabel = cfg.ariaLabel ?? this.#placeholder;
186
+ if (ariaLabel) this.#input.setAttribute("aria-label", ariaLabel);
187
+ this.#indicators = document.createElement("div");
188
+ this.#indicators.className = this.#cls("indicators");
189
+ this.#dropdown = document.createElement("div");
190
+ this.#dropdown.className = this.#cls("dropdown");
191
+ this.#dropdown.hidden = true;
192
+ this.#live = document.createElement("div");
193
+ this.#live.className = this.#cls("live");
194
+ this.#live.setAttribute("role", "status");
195
+ this.#live.setAttribute("aria-live", "polite");
196
+ this.#live.setAttribute("aria-atomic", "true");
197
+ this.#live.style.cssText = SR_ONLY_CSS;
198
+ this.#field.append(this.#input);
199
+ this.#control.append(this.#field, this.#indicators);
200
+ if (this.#dropdownParent) {
201
+ this.#dropdown.classList.add(this.#prefix);
202
+ this.element.append(this.#control);
203
+ this.#dropdownParent.append(this.#dropdown);
204
+ } else {
205
+ this.element.append(this.#control, this.#dropdown);
206
+ }
207
+ this.element.append(this.#live);
208
+ if (sourceSelect) {
209
+ sourceSelect.after(this.element);
210
+ this.#selectPrevDisplay = sourceSelect.style.display;
211
+ sourceSelect.style.display = "none";
212
+ sourceSelect.setAttribute("aria-hidden", "true");
213
+ sourceSelect.tabIndex = -1;
214
+ } else {
215
+ host.append(this.element);
216
+ if (this.#name) {
217
+ this.#hiddenContainer = document.createElement("div");
218
+ this.#hiddenContainer.style.display = "none";
219
+ this.element.append(this.#hiddenContainer);
220
+ }
221
+ }
222
+ this.#bindEvents();
223
+ this.#onDocPointer = (e) => {
224
+ const target2 = e.target;
225
+ if (!this.element.contains(target2) && !this.#dropdown.contains(target2)) {
226
+ this.controller.close();
227
+ }
228
+ };
229
+ document.addEventListener("pointerdown", this.#onDocPointer);
230
+ this.#offClose = this.controller.on("close", () => {
231
+ if (this.#input.value !== "") {
232
+ this.#input.value = "";
233
+ this.controller.setQuery("");
234
+ }
235
+ });
236
+ this.#offCreate = this.controller.on("create", () => {
237
+ this.#input.value = "";
238
+ });
239
+ this.#offAnnounce = this.controller.on("announce", ({ message }) => {
240
+ this.#live.textContent = message;
241
+ });
242
+ this.#unsubscribe = this.controller.subscribe((s) => this.#render(s));
243
+ this.#render(this.controller.getState());
244
+ }
245
+ destroy() {
246
+ this.#unsubscribe();
247
+ this.#offClose();
248
+ this.#offCreate();
249
+ this.#offAnnounce();
250
+ this.#positioner?.destroy();
251
+ document.removeEventListener("pointerdown", this.#onDocPointer);
252
+ this.controller.destroy();
253
+ this.#dropdown.remove();
254
+ this.element.remove();
255
+ if (this.#sourceSelect) {
256
+ this.#sourceSelect.style.display = this.#selectPrevDisplay;
257
+ this.#sourceSelect.removeAttribute("aria-hidden");
258
+ this.#sourceSelect.tabIndex = 0;
259
+ }
260
+ }
261
+ // ── class 命名 (BEM) ─────────────────────────────────────
262
+ #cls(name, modifier) {
263
+ const base = name ? `${this.#prefix}__${name}` : this.#prefix;
264
+ return modifier ? `${base}--${modifier}` : base;
265
+ }
266
+ // ── 事件綁定(轉呼叫 controller 不含邏輯)─────────────────
267
+ #bindEvents() {
268
+ this.#control.addEventListener("pointerdown", (e) => {
269
+ e.stopPropagation();
270
+ const target = e.target;
271
+ if (target.closest(`.${this.#cls("clear")}`)) {
272
+ e.preventDefault();
273
+ this.controller.clear();
274
+ return;
275
+ }
276
+ const tagRemove = target.closest(
277
+ `.${this.#cls("tag-remove")}`
278
+ );
279
+ if (tagRemove) {
280
+ e.preventDefault();
281
+ const idx = Number(tagRemove.dataset.index);
282
+ const sel = this.controller.getState().selected[idx];
283
+ if (sel) this.controller.deselect(sel.value);
284
+ return;
285
+ }
286
+ if (target !== this.#input) {
287
+ e.preventDefault();
288
+ this.#input.focus();
289
+ this.controller.toggle();
290
+ } else {
291
+ this.controller.open();
292
+ }
293
+ });
294
+ this.#input.addEventListener("input", () => {
295
+ this.controller.open();
296
+ this.controller.setQuery(this.#input.value);
297
+ const q = this.controller.getState().query;
298
+ if (q !== this.#input.value) this.#input.value = q;
299
+ });
300
+ this.#control.addEventListener("keydown", (e) => this.#onKeydown(e));
301
+ this.#dropdown.addEventListener("pointerdown", (e) => {
302
+ e.stopPropagation();
303
+ const optEl = e.target.closest(
304
+ `.${this.#cls("option")}`
305
+ );
306
+ if (!optEl || optEl.getAttribute("aria-disabled") === "true") return;
307
+ e.preventDefault();
308
+ if (optEl.dataset.create === "true") {
309
+ this.controller.createTag();
310
+ return;
311
+ }
312
+ const index = Number(optEl.dataset.index);
313
+ const opt = this.controller.getState().visibleOptions[index];
314
+ if (!opt) return;
315
+ if (this.#multiple) this.controller.toggleSelect(opt.value);
316
+ else this.controller.select(opt.value);
317
+ });
318
+ this.#dropdown.addEventListener("scroll", () => {
319
+ const el = this.#dropdown;
320
+ if (el.scrollTop + el.clientHeight >= el.scrollHeight - LOAD_MORE_THRESHOLD) {
321
+ this.controller.loadMore();
322
+ }
323
+ if (this.#virtual) this.#renderOptions(this.controller.getState());
324
+ });
325
+ this.#field.addEventListener("dragstart", (e) => {
326
+ const tag = e.target.closest(
327
+ `.${this.#cls("tag")}`
328
+ );
329
+ if (!tag) return;
330
+ this.#dragFrom = Number(tag.dataset.index);
331
+ if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
332
+ });
333
+ this.#field.addEventListener("dragover", (e) => {
334
+ if (this.#dragFrom < 0) return;
335
+ e.preventDefault();
336
+ });
337
+ this.#field.addEventListener("drop", (e) => {
338
+ if (this.#dragFrom < 0) return;
339
+ e.preventDefault();
340
+ const tag = e.target.closest(
341
+ `.${this.#cls("tag")}`
342
+ );
343
+ const to = tag ? Number(tag.dataset.index) : this.controller.getState().selected.length - 1;
344
+ this.controller.moveSelected(this.#dragFrom, to);
345
+ this.#dragFrom = -1;
346
+ });
347
+ this.#field.addEventListener("dragend", () => {
348
+ this.#dragFrom = -1;
349
+ });
350
+ }
351
+ #onKeydown(e) {
352
+ const st = this.controller.getState();
353
+ switch (e.key) {
354
+ case "ArrowDown":
355
+ e.preventDefault();
356
+ st.isOpen ? this.controller.moveActive(1) : this.controller.open();
357
+ break;
358
+ case "ArrowUp":
359
+ e.preventDefault();
360
+ st.isOpen ? this.controller.moveActive(-1) : this.controller.open();
361
+ break;
362
+ case "Enter":
363
+ if (st.isOpen) {
364
+ e.preventDefault();
365
+ this.controller.selectActive();
366
+ }
367
+ break;
368
+ case "Escape":
369
+ if (st.isOpen) {
370
+ e.preventDefault();
371
+ this.controller.close();
372
+ }
373
+ break;
374
+ case "Home":
375
+ if (st.isOpen) {
376
+ e.preventDefault();
377
+ this.controller.moveActiveToStart();
378
+ }
379
+ break;
380
+ case "End":
381
+ if (st.isOpen) {
382
+ e.preventDefault();
383
+ this.controller.moveActiveToEnd();
384
+ }
385
+ break;
386
+ case "Backspace":
387
+ if (this.#multiple && this.#input.value === "" && st.selected.length) {
388
+ this.controller.backspace();
389
+ this.#input.value = this.controller.getState().query;
390
+ }
391
+ break;
392
+ }
393
+ }
394
+ // ── 渲染 ─────────────────────────────────────────────────
395
+ #render(s) {
396
+ this.#renderField(s);
397
+ this.#renderIndicators(s);
398
+ this.#renderOptions(s);
399
+ this.#syncA11y(s);
400
+ this.#syncOpen(s);
401
+ this.#syncForm();
402
+ this.element.classList.toggle(this.#cls("", "open"), s.isOpen);
403
+ this.element.classList.toggle(this.#cls("", "disabled"), s.disabled);
404
+ }
405
+ /** 套用 templateSelection 到已選容器 字串走 textContent Node 直接掛入 無模板則用 label */
406
+ #fillSelection(host, option, meta) {
407
+ if (!this.#templateSelection) {
408
+ host.textContent = option.label;
409
+ return;
410
+ }
411
+ const out = this.#templateSelection(option, meta);
412
+ if (out instanceof Node) host.append(out);
413
+ else host.textContent = out;
414
+ }
415
+ #renderField(s) {
416
+ for (const child of Array.from(this.#field.children)) {
417
+ if (child !== this.#input) child.remove();
418
+ }
419
+ const frag = document.createDocumentFragment();
420
+ if (this.#multiple) {
421
+ s.selected.forEach((opt, i) => {
422
+ const tag = document.createElement("span");
423
+ tag.className = this.#cls("tag");
424
+ tag.draggable = true;
425
+ tag.dataset.index = String(i);
426
+ const label = document.createElement("span");
427
+ label.className = this.#cls("tag-label");
428
+ this.#fillSelection(label, opt, { index: i, multiple: true });
429
+ const remove = document.createElement("button");
430
+ remove.type = "button";
431
+ remove.className = this.#cls("tag-remove");
432
+ remove.dataset.index = String(i);
433
+ remove.setAttribute("aria-label", `Remove ${opt.label}`);
434
+ remove.textContent = "\xD7";
435
+ tag.append(label, remove);
436
+ frag.append(tag);
437
+ });
438
+ } else {
439
+ const sel = s.selected[0];
440
+ if (sel && this.#input.value === "") {
441
+ const single = document.createElement("span");
442
+ single.className = this.#cls("single-value");
443
+ this.#fillSelection(single, sel, { index: 0, multiple: false });
444
+ frag.append(single);
445
+ }
446
+ }
447
+ this.#field.insertBefore(frag, this.#input);
448
+ this.#input.placeholder = s.selected.length === 0 ? this.#placeholder : "";
449
+ this.#syncInputSize();
450
+ }
451
+ /** autogrow:以 size 屬性讓輸入框寬度貼齊內容(值或佔位文字)*/
452
+ #syncInputSize() {
453
+ if (!this.#autogrow) return;
454
+ const basis = this.#input.value || this.#input.placeholder;
455
+ this.#input.size = Math.max(1, basis.length);
456
+ }
457
+ #renderIndicators(s) {
458
+ this.#indicators.replaceChildren();
459
+ if (this.#clearable && s.selected.length > 0) {
460
+ const clear = document.createElement("button");
461
+ clear.type = "button";
462
+ clear.className = this.#cls("clear");
463
+ clear.setAttribute("aria-label", "Clear");
464
+ clear.textContent = "\xD7";
465
+ this.#indicators.append(clear);
466
+ }
467
+ const arrow = document.createElement("span");
468
+ arrow.className = this.#cls("arrow");
469
+ arrow.setAttribute("aria-hidden", "true");
470
+ this.#indicators.append(arrow);
471
+ }
472
+ #renderOptions(s) {
473
+ this.#dropdown.replaceChildren();
474
+ const a11y = this.controller.a11y();
475
+ const view = this.controller.getGroupedView();
476
+ if (view.rows.length === 0) {
477
+ const empty = document.createElement("div");
478
+ empty.className = this.#cls("empty");
479
+ empty.textContent = this.controller.getEmptyMessage();
480
+ this.#dropdown.append(empty);
481
+ return;
482
+ }
483
+ const hasGroups = view.rows.some((r) => r.type === "group");
484
+ if (this.#virtual && !hasGroups) {
485
+ const range = computeVirtualRange({
486
+ scrollTop: this.#dropdown.scrollTop,
487
+ viewportHeight: this.#dropdown.clientHeight,
488
+ itemHeight: this.#itemHeight,
489
+ itemCount: view.rows.length
490
+ });
491
+ this.#dropdown.append(this.#spacer(range.paddingTop));
492
+ for (let i = range.startIndex; i < range.endIndex; i++) {
493
+ const row = view.rows[i];
494
+ if (row?.type === "option") {
495
+ this.#dropdown.append(this.#buildOption(row, a11y, s.activeIndex));
496
+ } else if (row?.type === "create") {
497
+ this.#dropdown.append(this.#buildCreateRow(row, a11y, s.activeIndex));
498
+ }
499
+ }
500
+ this.#dropdown.append(this.#spacer(range.paddingBottom));
501
+ return;
502
+ }
503
+ for (const row of view.rows) {
504
+ if (row.type === "group") {
505
+ const group = document.createElement("div");
506
+ group.className = this.#cls("group");
507
+ if (row.disabled) group.classList.add(this.#cls("group", "disabled"));
508
+ group.textContent = row.label;
509
+ this.#dropdown.append(group);
510
+ continue;
511
+ }
512
+ if (row.type === "create") {
513
+ this.#dropdown.append(this.#buildCreateRow(row, a11y, s.activeIndex));
514
+ continue;
515
+ }
516
+ this.#dropdown.append(this.#buildOption(row, a11y, s.activeIndex));
517
+ }
518
+ }
519
+ /** 「建立新項」列 共用 option 樣式與 a11y 但點擊走 createTag */
520
+ #buildCreateRow(row, a11y, activeIndex) {
521
+ const attrs = a11y.option(row.index);
522
+ const el = document.createElement("div");
523
+ el.className = `${this.#cls("option")} ${this.#cls("create")}`;
524
+ el.id = attrs.id;
525
+ el.dataset.index = String(row.index);
526
+ el.dataset.create = "true";
527
+ el.setAttribute("role", "option");
528
+ el.setAttribute("aria-selected", "false");
529
+ if (row.index === activeIndex) {
530
+ el.classList.add(this.#cls("option", "active"));
531
+ }
532
+ el.textContent = row.label;
533
+ return el;
534
+ }
535
+ /** 撐高佔位節點 維持虛擬捲動時的捲動總高度 */
536
+ #spacer(height) {
537
+ const el = document.createElement("div");
538
+ el.style.height = `${height}px`;
539
+ el.setAttribute("aria-hidden", "true");
540
+ return el;
541
+ }
542
+ #buildOption(row, a11y, activeIndex) {
543
+ const attrs = a11y.option(row.index);
544
+ const option = document.createElement("div");
545
+ option.className = this.#cls("option");
546
+ option.id = attrs.id;
547
+ option.dataset.index = String(row.index);
548
+ option.setAttribute("role", "option");
549
+ option.setAttribute("aria-selected", String(attrs["aria-selected"]));
550
+ if (attrs["aria-disabled"]) option.setAttribute("aria-disabled", "true");
551
+ if (row.index === activeIndex) {
552
+ option.classList.add(this.#cls("option", "active"));
553
+ }
554
+ if (attrs["aria-selected"]) {
555
+ option.classList.add(this.#cls("option", "selected"));
556
+ }
557
+ if (this.#templateOption) {
558
+ const out = this.#templateOption(row.option, {
559
+ index: row.index,
560
+ active: row.index === activeIndex,
561
+ selected: attrs["aria-selected"]
562
+ });
563
+ if (out instanceof Node) option.append(out);
564
+ else option.textContent = out;
565
+ } else {
566
+ option.textContent = row.option.label;
567
+ }
568
+ return option;
569
+ }
570
+ #syncA11y(s) {
571
+ const a = this.controller.a11y();
572
+ const c = this.#control;
573
+ c.setAttribute("role", a.trigger.role);
574
+ c.setAttribute("aria-expanded", String(a.trigger["aria-expanded"]));
575
+ c.setAttribute("aria-haspopup", a.trigger["aria-haspopup"]);
576
+ c.setAttribute("aria-controls", a.trigger["aria-controls"]);
577
+ const active = a.trigger["aria-activedescendant"];
578
+ if (active) c.setAttribute("aria-activedescendant", active);
579
+ else c.removeAttribute("aria-activedescendant");
580
+ if (a.trigger["aria-disabled"]) c.setAttribute("aria-disabled", "true");
581
+ else c.removeAttribute("aria-disabled");
582
+ this.#dropdown.id = a.listbox.id;
583
+ this.#dropdown.setAttribute("role", a.listbox.role);
584
+ if (a.listbox["aria-multiselectable"]) {
585
+ this.#dropdown.setAttribute("aria-multiselectable", "true");
586
+ }
587
+ this.#input.disabled = s.disabled;
588
+ this.#input.readOnly = !this.controller.isSearchable();
589
+ }
590
+ #syncOpen(s) {
591
+ if (s.isOpen) {
592
+ this.#dropdown.hidden = false;
593
+ if (this.#positioner) this.#positioner.update();
594
+ else
595
+ this.#positioner = attachPositioner(
596
+ this.#control,
597
+ this.#dropdown,
598
+ this.#dropdownAutoWidth
599
+ );
600
+ } else {
601
+ this.#dropdown.hidden = true;
602
+ this.#positioner?.destroy();
603
+ this.#positioner = null;
604
+ }
605
+ }
606
+ // ── 表單同步 ──────────────────────────────────────────────
607
+ #syncForm() {
608
+ if (this.#sourceSelect) this.#syncToSelect(this.#sourceSelect);
609
+ else if (this.#hiddenContainer) this.#syncHiddenInputs(this.#hiddenContainer);
610
+ }
611
+ #syncToSelect(select) {
612
+ const selected = this.controller.getState().selected;
613
+ const selectedSet = new Set(selected.map((o) => String(o.value)));
614
+ for (const opt of selected) {
615
+ const value = String(opt.value);
616
+ if (!Array.from(select.options).some((o) => o.value === value)) {
617
+ const el = document.createElement("option");
618
+ el.value = value;
619
+ el.textContent = opt.label;
620
+ select.append(el);
621
+ }
622
+ }
623
+ for (const o of Array.from(select.options)) {
624
+ o.selected = selectedSet.has(o.value);
625
+ }
626
+ select.dispatchEvent(new Event("change", { bubbles: true }));
627
+ }
628
+ #syncHiddenInputs(container) {
629
+ container.replaceChildren();
630
+ const name = this.#name;
631
+ const inputName = this.#multiple ? `${name}[]` : name;
632
+ const selected = this.controller.getState().selected;
633
+ const values = this.#multiple ? selected : selected.slice(0, 1);
634
+ for (const opt of values) {
635
+ const input = document.createElement("input");
636
+ input.type = "hidden";
637
+ input.name = inputName;
638
+ input.value = String(opt.value);
639
+ container.append(input);
640
+ }
641
+ }
642
+ };
643
+ function createSelkitDom(host, config = {}) {
644
+ return new SelkitDom(host, config);
645
+ }
646
+ export {
647
+ SelkitDom as Selkit,
648
+ SelkitDom,
649
+ attachPositioner,
650
+ computePosition,
651
+ createSelkitDom,
652
+ createSelkitDom as sk
653
+ };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@selkit/dom",
3
+ "version": "0.1.0",
4
+ "description": "Vanilla JS renderer for Selkit — DOM, events, a11y and default positioner.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.cjs",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "require": "./dist/index.cjs"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "sideEffects": false,
21
+ "dependencies": {
22
+ "@selkit/core": "0.1.0"
23
+ },
24
+ "devDependencies": {
25
+ "axe-core": "^4.12.1",
26
+ "jsdom": "^24.1.0",
27
+ "tsup": "^8.0.0",
28
+ "typescript": "^5.4.0",
29
+ "vitest": "^1.6.0"
30
+ },
31
+ "homepage": "https://cluion.github.io/selkit/",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/cluion/selkit.git",
35
+ "directory": "packages/dom"
36
+ },
37
+ "bugs": {
38
+ "url": "https://github.com/cluion/selkit/issues"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "scripts": {
44
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean",
45
+ "test": "vitest run",
46
+ "test:watch": "vitest",
47
+ "typecheck": "tsc --noEmit"
48
+ }
49
+ }