@selkit/dom 0.1.1 → 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/dist/index.cjs CHANGED
@@ -155,6 +155,11 @@ var SelkitDom = class {
155
155
  #dropdownParent;
156
156
  #templateSelection;
157
157
  #templateOption;
158
+ #templateArrow;
159
+ #templateClear;
160
+ #templateTagRemove;
161
+ #templateGroup;
162
+ #templateEmpty;
158
163
  #sourceSelect;
159
164
  #control;
160
165
  #field;
@@ -165,6 +170,8 @@ var SelkitDom = class {
165
170
  #hiddenContainer = null;
166
171
  #selectPrevDisplay = "";
167
172
  #dragFrom = -1;
173
+ /** 上次已捲入可視的 activeIndex 僅在變動時才捲 避免跟使用者手動捲動打架 */
174
+ #lastActive = -1;
168
175
  #positioner = null;
169
176
  #unsubscribe;
170
177
  #offClose;
@@ -192,6 +199,11 @@ var SelkitDom = class {
192
199
  this.#dropdownParent = resolveParent(cfg.dropdownParent);
193
200
  this.#templateSelection = cfg.templateSelection;
194
201
  this.#templateOption = cfg.templateOption;
202
+ this.#templateArrow = cfg.templateArrow;
203
+ this.#templateClear = cfg.templateClear;
204
+ this.#templateTagRemove = cfg.templateTagRemove;
205
+ this.#templateGroup = cfg.templateGroup;
206
+ this.#templateEmpty = cfg.templateEmpty;
195
207
  this.controller = (0, import_core.createSelkit)(cfg);
196
208
  this.element = document.createElement("div");
197
209
  this.element.className = this.#prefix;
@@ -432,16 +444,50 @@ var SelkitDom = class {
432
444
  this.#syncForm();
433
445
  this.element.classList.toggle(this.#cls("", "open"), s.isOpen);
434
446
  this.element.classList.toggle(this.#cls("", "disabled"), s.disabled);
447
+ this.#scrollActiveIntoView(s);
435
448
  }
436
- /** 套用 templateSelection 到已選容器 字串走 textContent Node 直接掛入 無模板則用 label */
449
+ /**
450
+ * 鍵盤導航/開啟時讓作用中選項保持可見(aria-activedescendant 完整度)
451
+ * 僅在 activeIndex 變動時動作 手動捲動觸發的重繪不會跟著捲
452
+ */
453
+ #scrollActiveIntoView(s) {
454
+ if (!s.isOpen) {
455
+ this.#lastActive = -1;
456
+ return;
457
+ }
458
+ if (s.activeIndex === this.#lastActive || s.activeIndex < 0) return;
459
+ this.#lastActive = s.activeIndex;
460
+ const hasGroups = this.controller.getGroupedView().rows.some((r) => r.type === "group");
461
+ if (this.#virtual && !hasGroups) {
462
+ const next = (0, import_core.computeScrollIntoView)({
463
+ index: s.activeIndex,
464
+ scrollTop: this.#dropdown.scrollTop,
465
+ viewportHeight: this.#dropdown.clientHeight,
466
+ itemHeight: this.#itemHeight
467
+ });
468
+ if (next !== null) {
469
+ this.#dropdown.scrollTop = next;
470
+ this.#renderOptions(s);
471
+ }
472
+ return;
473
+ }
474
+ const active = this.#dropdown.querySelector(
475
+ `.${this.#cls("option", "active")}`
476
+ );
477
+ active?.scrollIntoView?.({ block: "nearest" });
478
+ }
479
+ /** 套用模板輸出:字串走 textContent(防 XSS)Node 直接掛入 */
480
+ #applyTemplate(host, out) {
481
+ if (out instanceof Node) host.append(out);
482
+ else host.textContent = out;
483
+ }
484
+ /** 套用 templateSelection 到已選容器 無模板則用 label */
437
485
  #fillSelection(host, option, meta) {
438
486
  if (!this.#templateSelection) {
439
487
  host.textContent = option.label;
440
488
  return;
441
489
  }
442
- const out = this.#templateSelection(option, meta);
443
- if (out instanceof Node) host.append(out);
444
- else host.textContent = out;
490
+ this.#applyTemplate(host, this.#templateSelection(option, meta));
445
491
  }
446
492
  #renderField(s) {
447
493
  for (const child of Array.from(this.#field.children)) {
@@ -462,7 +508,11 @@ var SelkitDom = class {
462
508
  remove.className = this.#cls("tag-remove");
463
509
  remove.dataset.index = String(i);
464
510
  remove.setAttribute("aria-label", `Remove ${opt.label}`);
465
- remove.textContent = "\xD7";
511
+ if (this.#templateTagRemove) {
512
+ this.#applyTemplate(remove, this.#templateTagRemove(opt, { index: i }));
513
+ } else {
514
+ remove.textContent = "\xD7";
515
+ }
466
516
  tag.append(label, remove);
467
517
  frag.append(tag);
468
518
  });
@@ -492,12 +542,19 @@ var SelkitDom = class {
492
542
  clear.type = "button";
493
543
  clear.className = this.#cls("clear");
494
544
  clear.setAttribute("aria-label", "Clear");
495
- clear.textContent = "\xD7";
545
+ if (this.#templateClear) {
546
+ this.#applyTemplate(clear, this.#templateClear());
547
+ } else {
548
+ clear.textContent = "\xD7";
549
+ }
496
550
  this.#indicators.append(clear);
497
551
  }
498
552
  const arrow = document.createElement("span");
499
553
  arrow.className = this.#cls("arrow");
500
554
  arrow.setAttribute("aria-hidden", "true");
555
+ if (this.#templateArrow) {
556
+ this.#applyTemplate(arrow, this.#templateArrow({ open: s.isOpen }));
557
+ }
501
558
  this.#indicators.append(arrow);
502
559
  }
503
560
  #renderOptions(s) {
@@ -507,7 +564,19 @@ var SelkitDom = class {
507
564
  if (view.rows.length === 0) {
508
565
  const empty = document.createElement("div");
509
566
  empty.className = this.#cls("empty");
510
- empty.textContent = this.controller.getEmptyMessage();
567
+ const message = this.controller.getEmptyMessage();
568
+ if (this.#templateEmpty) {
569
+ this.#applyTemplate(
570
+ empty,
571
+ this.#templateEmpty({
572
+ reason: this.controller.getEmptyReason(),
573
+ message,
574
+ query: s.query
575
+ })
576
+ );
577
+ } else {
578
+ empty.textContent = message;
579
+ }
511
580
  this.#dropdown.append(empty);
512
581
  return;
513
582
  }
@@ -536,7 +605,14 @@ var SelkitDom = class {
536
605
  const group = document.createElement("div");
537
606
  group.className = this.#cls("group");
538
607
  if (row.disabled) group.classList.add(this.#cls("group", "disabled"));
539
- group.textContent = row.label;
608
+ if (this.#templateGroup) {
609
+ this.#applyTemplate(
610
+ group,
611
+ this.#templateGroup({ label: row.label, disabled: !!row.disabled })
612
+ );
613
+ } else {
614
+ group.textContent = row.label;
615
+ }
540
616
  this.#dropdown.append(group);
541
617
  continue;
542
618
  }
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { SelkitController, SelkitConfig, SelkitOption } from '@selkit/core';
1
+ import { SelkitController, SelkitConfig, SelkitOption, SelkitEmptyReason } from '@selkit/core';
2
2
 
3
3
  interface SelkitDomConfig<T = unknown> extends SelkitConfig<T> {
4
4
  /** class 前綴 預設 "selkit" */
@@ -28,6 +28,27 @@ interface SelkitDomConfig<T = unknown> extends SelkitConfig<T> {
28
28
  active: boolean;
29
29
  selected: boolean;
30
30
  }) => string | Node;
31
+ /** 自訂下拉箭頭內容(▾)外殼保留 回傳 string 走 textContent Node 直接掛入 */
32
+ templateArrow?: (meta: {
33
+ open: boolean;
34
+ }) => string | Node;
35
+ /** 自訂清除鈕內容(×)按鈕外殼與 click 行為保留 */
36
+ templateClear?: () => string | Node;
37
+ /** 自訂多選標籤移除鈕內容(×)按鈕外殼與 click 行為保留 */
38
+ templateTagRemove?: (option: SelkitOption<T>, meta: {
39
+ index: number;
40
+ }) => string | Node;
41
+ /** 自訂分組標題內容 外殼保留 */
42
+ templateGroup?: (meta: {
43
+ label: string;
44
+ disabled: boolean;
45
+ }) => string | Node;
46
+ /** 自訂下拉為空/載入中的整塊內容 reason 分流 message 為預設文字 */
47
+ templateEmpty?: (meta: {
48
+ reason: SelkitEmptyReason;
49
+ message: string;
50
+ query: string;
51
+ }) => string | Node;
31
52
  }
32
53
  interface SelkitDomInstance<T = unknown> {
33
54
  readonly controller: SelkitController<T>;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { SelkitController, SelkitConfig, SelkitOption } from '@selkit/core';
1
+ import { SelkitController, SelkitConfig, SelkitOption, SelkitEmptyReason } from '@selkit/core';
2
2
 
3
3
  interface SelkitDomConfig<T = unknown> extends SelkitConfig<T> {
4
4
  /** class 前綴 預設 "selkit" */
@@ -28,6 +28,27 @@ interface SelkitDomConfig<T = unknown> extends SelkitConfig<T> {
28
28
  active: boolean;
29
29
  selected: boolean;
30
30
  }) => string | Node;
31
+ /** 自訂下拉箭頭內容(▾)外殼保留 回傳 string 走 textContent Node 直接掛入 */
32
+ templateArrow?: (meta: {
33
+ open: boolean;
34
+ }) => string | Node;
35
+ /** 自訂清除鈕內容(×)按鈕外殼與 click 行為保留 */
36
+ templateClear?: () => string | Node;
37
+ /** 自訂多選標籤移除鈕內容(×)按鈕外殼與 click 行為保留 */
38
+ templateTagRemove?: (option: SelkitOption<T>, meta: {
39
+ index: number;
40
+ }) => string | Node;
41
+ /** 自訂分組標題內容 外殼保留 */
42
+ templateGroup?: (meta: {
43
+ label: string;
44
+ disabled: boolean;
45
+ }) => string | Node;
46
+ /** 自訂下拉為空/載入中的整塊內容 reason 分流 message 為預設文字 */
47
+ templateEmpty?: (meta: {
48
+ reason: SelkitEmptyReason;
49
+ message: string;
50
+ query: string;
51
+ }) => string | Node;
31
52
  }
32
53
  interface SelkitDomInstance<T = unknown> {
33
54
  readonly controller: SelkitController<T>;
package/dist/index.js CHANGED
@@ -1,5 +1,9 @@
1
1
  // src/dom.ts
2
- import { computeVirtualRange, createSelkit } from "@selkit/core";
2
+ import {
3
+ computeScrollIntoView,
4
+ computeVirtualRange,
5
+ createSelkit
6
+ } from "@selkit/core";
3
7
 
4
8
  // src/positioner.ts
5
9
  function computePosition(triggerRect, dropdownHeight, viewportHeight, gap = 4) {
@@ -124,6 +128,11 @@ var SelkitDom = class {
124
128
  #dropdownParent;
125
129
  #templateSelection;
126
130
  #templateOption;
131
+ #templateArrow;
132
+ #templateClear;
133
+ #templateTagRemove;
134
+ #templateGroup;
135
+ #templateEmpty;
127
136
  #sourceSelect;
128
137
  #control;
129
138
  #field;
@@ -134,6 +143,8 @@ var SelkitDom = class {
134
143
  #hiddenContainer = null;
135
144
  #selectPrevDisplay = "";
136
145
  #dragFrom = -1;
146
+ /** 上次已捲入可視的 activeIndex 僅在變動時才捲 避免跟使用者手動捲動打架 */
147
+ #lastActive = -1;
137
148
  #positioner = null;
138
149
  #unsubscribe;
139
150
  #offClose;
@@ -161,6 +172,11 @@ var SelkitDom = class {
161
172
  this.#dropdownParent = resolveParent(cfg.dropdownParent);
162
173
  this.#templateSelection = cfg.templateSelection;
163
174
  this.#templateOption = cfg.templateOption;
175
+ this.#templateArrow = cfg.templateArrow;
176
+ this.#templateClear = cfg.templateClear;
177
+ this.#templateTagRemove = cfg.templateTagRemove;
178
+ this.#templateGroup = cfg.templateGroup;
179
+ this.#templateEmpty = cfg.templateEmpty;
164
180
  this.controller = createSelkit(cfg);
165
181
  this.element = document.createElement("div");
166
182
  this.element.className = this.#prefix;
@@ -401,16 +417,50 @@ var SelkitDom = class {
401
417
  this.#syncForm();
402
418
  this.element.classList.toggle(this.#cls("", "open"), s.isOpen);
403
419
  this.element.classList.toggle(this.#cls("", "disabled"), s.disabled);
420
+ this.#scrollActiveIntoView(s);
404
421
  }
405
- /** 套用 templateSelection 到已選容器 字串走 textContent Node 直接掛入 無模板則用 label */
422
+ /**
423
+ * 鍵盤導航/開啟時讓作用中選項保持可見(aria-activedescendant 完整度)
424
+ * 僅在 activeIndex 變動時動作 手動捲動觸發的重繪不會跟著捲
425
+ */
426
+ #scrollActiveIntoView(s) {
427
+ if (!s.isOpen) {
428
+ this.#lastActive = -1;
429
+ return;
430
+ }
431
+ if (s.activeIndex === this.#lastActive || s.activeIndex < 0) return;
432
+ this.#lastActive = s.activeIndex;
433
+ const hasGroups = this.controller.getGroupedView().rows.some((r) => r.type === "group");
434
+ if (this.#virtual && !hasGroups) {
435
+ const next = computeScrollIntoView({
436
+ index: s.activeIndex,
437
+ scrollTop: this.#dropdown.scrollTop,
438
+ viewportHeight: this.#dropdown.clientHeight,
439
+ itemHeight: this.#itemHeight
440
+ });
441
+ if (next !== null) {
442
+ this.#dropdown.scrollTop = next;
443
+ this.#renderOptions(s);
444
+ }
445
+ return;
446
+ }
447
+ const active = this.#dropdown.querySelector(
448
+ `.${this.#cls("option", "active")}`
449
+ );
450
+ active?.scrollIntoView?.({ block: "nearest" });
451
+ }
452
+ /** 套用模板輸出:字串走 textContent(防 XSS)Node 直接掛入 */
453
+ #applyTemplate(host, out) {
454
+ if (out instanceof Node) host.append(out);
455
+ else host.textContent = out;
456
+ }
457
+ /** 套用 templateSelection 到已選容器 無模板則用 label */
406
458
  #fillSelection(host, option, meta) {
407
459
  if (!this.#templateSelection) {
408
460
  host.textContent = option.label;
409
461
  return;
410
462
  }
411
- const out = this.#templateSelection(option, meta);
412
- if (out instanceof Node) host.append(out);
413
- else host.textContent = out;
463
+ this.#applyTemplate(host, this.#templateSelection(option, meta));
414
464
  }
415
465
  #renderField(s) {
416
466
  for (const child of Array.from(this.#field.children)) {
@@ -431,7 +481,11 @@ var SelkitDom = class {
431
481
  remove.className = this.#cls("tag-remove");
432
482
  remove.dataset.index = String(i);
433
483
  remove.setAttribute("aria-label", `Remove ${opt.label}`);
434
- remove.textContent = "\xD7";
484
+ if (this.#templateTagRemove) {
485
+ this.#applyTemplate(remove, this.#templateTagRemove(opt, { index: i }));
486
+ } else {
487
+ remove.textContent = "\xD7";
488
+ }
435
489
  tag.append(label, remove);
436
490
  frag.append(tag);
437
491
  });
@@ -461,12 +515,19 @@ var SelkitDom = class {
461
515
  clear.type = "button";
462
516
  clear.className = this.#cls("clear");
463
517
  clear.setAttribute("aria-label", "Clear");
464
- clear.textContent = "\xD7";
518
+ if (this.#templateClear) {
519
+ this.#applyTemplate(clear, this.#templateClear());
520
+ } else {
521
+ clear.textContent = "\xD7";
522
+ }
465
523
  this.#indicators.append(clear);
466
524
  }
467
525
  const arrow = document.createElement("span");
468
526
  arrow.className = this.#cls("arrow");
469
527
  arrow.setAttribute("aria-hidden", "true");
528
+ if (this.#templateArrow) {
529
+ this.#applyTemplate(arrow, this.#templateArrow({ open: s.isOpen }));
530
+ }
470
531
  this.#indicators.append(arrow);
471
532
  }
472
533
  #renderOptions(s) {
@@ -476,7 +537,19 @@ var SelkitDom = class {
476
537
  if (view.rows.length === 0) {
477
538
  const empty = document.createElement("div");
478
539
  empty.className = this.#cls("empty");
479
- empty.textContent = this.controller.getEmptyMessage();
540
+ const message = this.controller.getEmptyMessage();
541
+ if (this.#templateEmpty) {
542
+ this.#applyTemplate(
543
+ empty,
544
+ this.#templateEmpty({
545
+ reason: this.controller.getEmptyReason(),
546
+ message,
547
+ query: s.query
548
+ })
549
+ );
550
+ } else {
551
+ empty.textContent = message;
552
+ }
480
553
  this.#dropdown.append(empty);
481
554
  return;
482
555
  }
@@ -505,7 +578,14 @@ var SelkitDom = class {
505
578
  const group = document.createElement("div");
506
579
  group.className = this.#cls("group");
507
580
  if (row.disabled) group.classList.add(this.#cls("group", "disabled"));
508
- group.textContent = row.label;
581
+ if (this.#templateGroup) {
582
+ this.#applyTemplate(
583
+ group,
584
+ this.#templateGroup({ label: row.label, disabled: !!row.disabled })
585
+ );
586
+ } else {
587
+ group.textContent = row.label;
588
+ }
509
589
  this.#dropdown.append(group);
510
590
  continue;
511
591
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selkit/dom",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Vanilla JS renderer for Selkit — DOM, events, a11y and default positioner.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,7 +19,7 @@
19
19
  ],
20
20
  "sideEffects": false,
21
21
  "dependencies": {
22
- "@selkit/core": "0.1.1"
22
+ "@selkit/core": "0.3.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "axe-core": "^4.12.1",