@selkit/dom 0.2.0 → 0.4.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
@@ -72,6 +72,7 @@ function attachPositioner(trigger, dropdown, autoWidth = false) {
72
72
  // src/dom.ts
73
73
  var LOAD_MORE_THRESHOLD = 32;
74
74
  var DEFAULT_ITEM_HEIGHT = 36;
75
+ var DEFAULT_GROUP_HEIGHT = 28;
75
76
  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";
76
77
  function resolveParent(parent) {
77
78
  if (!parent) return null;
@@ -152,6 +153,7 @@ var SelkitDom = class {
152
153
  #name;
153
154
  #virtual;
154
155
  #itemHeight;
156
+ #groupHeight;
155
157
  #dropdownParent;
156
158
  #templateSelection;
157
159
  #templateOption;
@@ -170,6 +172,8 @@ var SelkitDom = class {
170
172
  #hiddenContainer = null;
171
173
  #selectPrevDisplay = "";
172
174
  #dragFrom = -1;
175
+ /** 上次已捲入可視的 activeIndex 僅在變動時才捲 避免跟使用者手動捲動打架 */
176
+ #lastActive = -1;
173
177
  #positioner = null;
174
178
  #unsubscribe;
175
179
  #offClose;
@@ -194,6 +198,7 @@ var SelkitDom = class {
194
198
  this.#name = cfg.name;
195
199
  this.#virtual = cfg.virtualScroll ?? false;
196
200
  this.#itemHeight = cfg.itemHeight ?? DEFAULT_ITEM_HEIGHT;
201
+ this.#groupHeight = cfg.groupHeight ?? DEFAULT_GROUP_HEIGHT;
197
202
  this.#dropdownParent = resolveParent(cfg.dropdownParent);
198
203
  this.#templateSelection = cfg.templateSelection;
199
204
  this.#templateOption = cfg.templateOption;
@@ -442,6 +447,54 @@ var SelkitDom = class {
442
447
  this.#syncForm();
443
448
  this.element.classList.toggle(this.#cls("", "open"), s.isOpen);
444
449
  this.element.classList.toggle(this.#cls("", "disabled"), s.disabled);
450
+ this.#scrollActiveIntoView(s);
451
+ }
452
+ /**
453
+ * 鍵盤導航/開啟時讓作用中選項保持可見(aria-activedescendant 完整度)
454
+ * 僅在 activeIndex 變動時動作 手動捲動觸發的重繪不會跟著捲
455
+ */
456
+ #scrollActiveIntoView(s) {
457
+ if (!s.isOpen) {
458
+ this.#lastActive = -1;
459
+ return;
460
+ }
461
+ if (s.activeIndex === this.#lastActive || s.activeIndex < 0) return;
462
+ this.#lastActive = s.activeIndex;
463
+ const rows = this.controller.getGroupedView().rows;
464
+ const hasGroups = rows.some((r) => r.type === "group");
465
+ if (this.#virtual && !hasGroups) {
466
+ const next = (0, import_core.computeScrollIntoView)({
467
+ index: s.activeIndex,
468
+ scrollTop: this.#dropdown.scrollTop,
469
+ viewportHeight: this.#dropdown.clientHeight,
470
+ itemHeight: this.#itemHeight
471
+ });
472
+ if (next !== null) {
473
+ this.#dropdown.scrollTop = next;
474
+ this.#renderOptions(s);
475
+ }
476
+ return;
477
+ }
478
+ if (this.#virtual && hasGroups) {
479
+ const rowIndex = rows.findIndex(
480
+ (r) => r.type !== "group" && r.index === s.activeIndex
481
+ );
482
+ const next = (0, import_core.computeScrollIntoViewVariable)({
483
+ heights: this.#rowHeights(rows),
484
+ rowIndex,
485
+ scrollTop: this.#dropdown.scrollTop,
486
+ viewportHeight: this.#dropdown.clientHeight
487
+ });
488
+ if (next !== null) {
489
+ this.#dropdown.scrollTop = next;
490
+ this.#renderOptions(s);
491
+ }
492
+ return;
493
+ }
494
+ const active = this.#dropdown.querySelector(
495
+ `.${this.#cls("option", "active")}`
496
+ );
497
+ active?.scrollIntoView?.({ block: "nearest" });
445
498
  }
446
499
  /** 套用模板輸出:字串走 textContent(防 XSS)Node 直接掛入 */
447
500
  #applyTemplate(host, out) {
@@ -555,40 +608,61 @@ var SelkitDom = class {
555
608
  itemHeight: this.#itemHeight,
556
609
  itemCount: view.rows.length
557
610
  });
558
- this.#dropdown.append(this.#spacer(range.paddingTop));
559
- for (let i = range.startIndex; i < range.endIndex; i++) {
560
- const row = view.rows[i];
561
- if (row?.type === "option") {
562
- this.#dropdown.append(this.#buildOption(row, a11y, s.activeIndex));
563
- } else if (row?.type === "create") {
564
- this.#dropdown.append(this.#buildCreateRow(row, a11y, s.activeIndex));
565
- }
566
- }
567
- this.#dropdown.append(this.#spacer(range.paddingBottom));
611
+ this.#renderVirtualSlice(view.rows, range, a11y, s.activeIndex);
568
612
  return;
569
613
  }
570
- for (const row of view.rows) {
571
- if (row.type === "group") {
572
- const group = document.createElement("div");
573
- group.className = this.#cls("group");
574
- if (row.disabled) group.classList.add(this.#cls("group", "disabled"));
575
- if (this.#templateGroup) {
576
- this.#applyTemplate(
577
- group,
578
- this.#templateGroup({ label: row.label, disabled: !!row.disabled })
579
- );
580
- } else {
581
- group.textContent = row.label;
582
- }
583
- this.#dropdown.append(group);
584
- continue;
585
- }
586
- if (row.type === "create") {
587
- this.#dropdown.append(this.#buildCreateRow(row, a11y, s.activeIndex));
588
- continue;
589
- }
590
- this.#dropdown.append(this.#buildOption(row, a11y, s.activeIndex));
614
+ if (this.#virtual && hasGroups) {
615
+ const win = (0, import_core.computeVirtualWindow)({
616
+ heights: this.#rowHeights(view.rows),
617
+ scrollTop: this.#dropdown.scrollTop,
618
+ viewportHeight: this.#dropdown.clientHeight
619
+ });
620
+ this.#renderVirtualSlice(view.rows, win, a11y, s.activeIndex);
621
+ return;
622
+ }
623
+ for (const row of view.rows) this.#renderRow(row, a11y, s.activeIndex);
624
+ }
625
+ /** 每列高度:分組標題用 groupHeight 其餘(option/create)用 itemHeight */
626
+ #rowHeights(rows) {
627
+ return rows.map(
628
+ (r) => r.type === "group" ? this.#groupHeight : this.#itemHeight
629
+ );
630
+ }
631
+ /** 渲染虛擬切片:上下佔位 + range 內各列 共用扁平與分組兩路徑 */
632
+ #renderVirtualSlice(rows, range, a11y, activeIndex) {
633
+ this.#dropdown.append(this.#spacer(range.paddingTop));
634
+ for (let i = range.startIndex; i < range.endIndex; i++) {
635
+ const row = rows[i];
636
+ if (row) this.#renderRow(row, a11y, activeIndex);
637
+ }
638
+ this.#dropdown.append(this.#spacer(range.paddingBottom));
639
+ }
640
+ /** 依列型別渲染並掛入下拉 group / create / option */
641
+ #renderRow(row, a11y, activeIndex) {
642
+ if (row.type === "group") {
643
+ this.#dropdown.append(this.#buildGroupRow(row));
644
+ return;
645
+ }
646
+ if (row.type === "create") {
647
+ this.#dropdown.append(this.#buildCreateRow(row, a11y, activeIndex));
648
+ return;
649
+ }
650
+ this.#dropdown.append(this.#buildOption(row, a11y, activeIndex));
651
+ }
652
+ /** 分組標題列 套用 templateGroup(無則用 label)*/
653
+ #buildGroupRow(row) {
654
+ const group = document.createElement("div");
655
+ group.className = this.#cls("group");
656
+ if (row.disabled) group.classList.add(this.#cls("group", "disabled"));
657
+ if (this.#templateGroup) {
658
+ this.#applyTemplate(
659
+ group,
660
+ this.#templateGroup({ label: row.label, disabled: !!row.disabled })
661
+ );
662
+ } else {
663
+ group.textContent = row.label;
591
664
  }
665
+ return group;
592
666
  }
593
667
  /** 「建立新項」列 共用 option 樣式與 a11y 但點擊走 createTag */
594
668
  #buildCreateRow(row, a11y, activeIndex) {
package/dist/index.d.cts CHANGED
@@ -15,6 +15,8 @@ interface SelkitDomConfig<T = unknown> extends SelkitConfig<T> {
15
15
  virtualScroll?: boolean;
16
16
  /** 虛擬捲動的單列固定高度 px 預設 36 須與實際樣式高度一致 */
17
17
  itemHeight?: number;
18
+ /** 虛擬捲動下分組標題列的固定高度 px 預設 28 須與實際樣式高度一致 */
19
+ groupHeight?: number;
18
20
  /** 把下拉浮層掛到指定容器(元素或選擇器)逃離 overflow/transform 祖先的裁切 常用 document.body */
19
21
  dropdownParent?: HTMLElement | string;
20
22
  /** 自訂已選顯示內容(tag 或單值) 回傳字串走 textContent 防 XSS 需 markup(icon 等)請回傳 Node */
package/dist/index.d.ts CHANGED
@@ -15,6 +15,8 @@ interface SelkitDomConfig<T = unknown> extends SelkitConfig<T> {
15
15
  virtualScroll?: boolean;
16
16
  /** 虛擬捲動的單列固定高度 px 預設 36 須與實際樣式高度一致 */
17
17
  itemHeight?: number;
18
+ /** 虛擬捲動下分組標題列的固定高度 px 預設 28 須與實際樣式高度一致 */
19
+ groupHeight?: number;
18
20
  /** 把下拉浮層掛到指定容器(元素或選擇器)逃離 overflow/transform 祖先的裁切 常用 document.body */
19
21
  dropdownParent?: HTMLElement | string;
20
22
  /** 自訂已選顯示內容(tag 或單值) 回傳字串走 textContent 防 XSS 需 markup(icon 等)請回傳 Node */
package/dist/index.js CHANGED
@@ -1,5 +1,11 @@
1
1
  // src/dom.ts
2
- import { computeVirtualRange, createSelkit } from "@selkit/core";
2
+ import {
3
+ computeScrollIntoView,
4
+ computeScrollIntoViewVariable,
5
+ computeVirtualRange,
6
+ computeVirtualWindow,
7
+ createSelkit
8
+ } from "@selkit/core";
3
9
 
4
10
  // src/positioner.ts
5
11
  function computePosition(triggerRect, dropdownHeight, viewportHeight, gap = 4) {
@@ -41,6 +47,7 @@ function attachPositioner(trigger, dropdown, autoWidth = false) {
41
47
  // src/dom.ts
42
48
  var LOAD_MORE_THRESHOLD = 32;
43
49
  var DEFAULT_ITEM_HEIGHT = 36;
50
+ var DEFAULT_GROUP_HEIGHT = 28;
44
51
  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
52
  function resolveParent(parent) {
46
53
  if (!parent) return null;
@@ -121,6 +128,7 @@ var SelkitDom = class {
121
128
  #name;
122
129
  #virtual;
123
130
  #itemHeight;
131
+ #groupHeight;
124
132
  #dropdownParent;
125
133
  #templateSelection;
126
134
  #templateOption;
@@ -139,6 +147,8 @@ var SelkitDom = class {
139
147
  #hiddenContainer = null;
140
148
  #selectPrevDisplay = "";
141
149
  #dragFrom = -1;
150
+ /** 上次已捲入可視的 activeIndex 僅在變動時才捲 避免跟使用者手動捲動打架 */
151
+ #lastActive = -1;
142
152
  #positioner = null;
143
153
  #unsubscribe;
144
154
  #offClose;
@@ -163,6 +173,7 @@ var SelkitDom = class {
163
173
  this.#name = cfg.name;
164
174
  this.#virtual = cfg.virtualScroll ?? false;
165
175
  this.#itemHeight = cfg.itemHeight ?? DEFAULT_ITEM_HEIGHT;
176
+ this.#groupHeight = cfg.groupHeight ?? DEFAULT_GROUP_HEIGHT;
166
177
  this.#dropdownParent = resolveParent(cfg.dropdownParent);
167
178
  this.#templateSelection = cfg.templateSelection;
168
179
  this.#templateOption = cfg.templateOption;
@@ -411,6 +422,54 @@ var SelkitDom = class {
411
422
  this.#syncForm();
412
423
  this.element.classList.toggle(this.#cls("", "open"), s.isOpen);
413
424
  this.element.classList.toggle(this.#cls("", "disabled"), s.disabled);
425
+ this.#scrollActiveIntoView(s);
426
+ }
427
+ /**
428
+ * 鍵盤導航/開啟時讓作用中選項保持可見(aria-activedescendant 完整度)
429
+ * 僅在 activeIndex 變動時動作 手動捲動觸發的重繪不會跟著捲
430
+ */
431
+ #scrollActiveIntoView(s) {
432
+ if (!s.isOpen) {
433
+ this.#lastActive = -1;
434
+ return;
435
+ }
436
+ if (s.activeIndex === this.#lastActive || s.activeIndex < 0) return;
437
+ this.#lastActive = s.activeIndex;
438
+ const rows = this.controller.getGroupedView().rows;
439
+ const hasGroups = rows.some((r) => r.type === "group");
440
+ if (this.#virtual && !hasGroups) {
441
+ const next = computeScrollIntoView({
442
+ index: s.activeIndex,
443
+ scrollTop: this.#dropdown.scrollTop,
444
+ viewportHeight: this.#dropdown.clientHeight,
445
+ itemHeight: this.#itemHeight
446
+ });
447
+ if (next !== null) {
448
+ this.#dropdown.scrollTop = next;
449
+ this.#renderOptions(s);
450
+ }
451
+ return;
452
+ }
453
+ if (this.#virtual && hasGroups) {
454
+ const rowIndex = rows.findIndex(
455
+ (r) => r.type !== "group" && r.index === s.activeIndex
456
+ );
457
+ const next = computeScrollIntoViewVariable({
458
+ heights: this.#rowHeights(rows),
459
+ rowIndex,
460
+ scrollTop: this.#dropdown.scrollTop,
461
+ viewportHeight: this.#dropdown.clientHeight
462
+ });
463
+ if (next !== null) {
464
+ this.#dropdown.scrollTop = next;
465
+ this.#renderOptions(s);
466
+ }
467
+ return;
468
+ }
469
+ const active = this.#dropdown.querySelector(
470
+ `.${this.#cls("option", "active")}`
471
+ );
472
+ active?.scrollIntoView?.({ block: "nearest" });
414
473
  }
415
474
  /** 套用模板輸出:字串走 textContent(防 XSS)Node 直接掛入 */
416
475
  #applyTemplate(host, out) {
@@ -524,40 +583,61 @@ var SelkitDom = class {
524
583
  itemHeight: this.#itemHeight,
525
584
  itemCount: view.rows.length
526
585
  });
527
- this.#dropdown.append(this.#spacer(range.paddingTop));
528
- for (let i = range.startIndex; i < range.endIndex; i++) {
529
- const row = view.rows[i];
530
- if (row?.type === "option") {
531
- this.#dropdown.append(this.#buildOption(row, a11y, s.activeIndex));
532
- } else if (row?.type === "create") {
533
- this.#dropdown.append(this.#buildCreateRow(row, a11y, s.activeIndex));
534
- }
535
- }
536
- this.#dropdown.append(this.#spacer(range.paddingBottom));
586
+ this.#renderVirtualSlice(view.rows, range, a11y, s.activeIndex);
537
587
  return;
538
588
  }
539
- for (const row of view.rows) {
540
- if (row.type === "group") {
541
- const group = document.createElement("div");
542
- group.className = this.#cls("group");
543
- if (row.disabled) group.classList.add(this.#cls("group", "disabled"));
544
- if (this.#templateGroup) {
545
- this.#applyTemplate(
546
- group,
547
- this.#templateGroup({ label: row.label, disabled: !!row.disabled })
548
- );
549
- } else {
550
- group.textContent = row.label;
551
- }
552
- this.#dropdown.append(group);
553
- continue;
554
- }
555
- if (row.type === "create") {
556
- this.#dropdown.append(this.#buildCreateRow(row, a11y, s.activeIndex));
557
- continue;
558
- }
559
- this.#dropdown.append(this.#buildOption(row, a11y, s.activeIndex));
589
+ if (this.#virtual && hasGroups) {
590
+ const win = computeVirtualWindow({
591
+ heights: this.#rowHeights(view.rows),
592
+ scrollTop: this.#dropdown.scrollTop,
593
+ viewportHeight: this.#dropdown.clientHeight
594
+ });
595
+ this.#renderVirtualSlice(view.rows, win, a11y, s.activeIndex);
596
+ return;
597
+ }
598
+ for (const row of view.rows) this.#renderRow(row, a11y, s.activeIndex);
599
+ }
600
+ /** 每列高度:分組標題用 groupHeight 其餘(option/create)用 itemHeight */
601
+ #rowHeights(rows) {
602
+ return rows.map(
603
+ (r) => r.type === "group" ? this.#groupHeight : this.#itemHeight
604
+ );
605
+ }
606
+ /** 渲染虛擬切片:上下佔位 + range 內各列 共用扁平與分組兩路徑 */
607
+ #renderVirtualSlice(rows, range, a11y, activeIndex) {
608
+ this.#dropdown.append(this.#spacer(range.paddingTop));
609
+ for (let i = range.startIndex; i < range.endIndex; i++) {
610
+ const row = rows[i];
611
+ if (row) this.#renderRow(row, a11y, activeIndex);
612
+ }
613
+ this.#dropdown.append(this.#spacer(range.paddingBottom));
614
+ }
615
+ /** 依列型別渲染並掛入下拉 group / create / option */
616
+ #renderRow(row, a11y, activeIndex) {
617
+ if (row.type === "group") {
618
+ this.#dropdown.append(this.#buildGroupRow(row));
619
+ return;
620
+ }
621
+ if (row.type === "create") {
622
+ this.#dropdown.append(this.#buildCreateRow(row, a11y, activeIndex));
623
+ return;
624
+ }
625
+ this.#dropdown.append(this.#buildOption(row, a11y, activeIndex));
626
+ }
627
+ /** 分組標題列 套用 templateGroup(無則用 label)*/
628
+ #buildGroupRow(row) {
629
+ const group = document.createElement("div");
630
+ group.className = this.#cls("group");
631
+ if (row.disabled) group.classList.add(this.#cls("group", "disabled"));
632
+ if (this.#templateGroup) {
633
+ this.#applyTemplate(
634
+ group,
635
+ this.#templateGroup({ label: row.label, disabled: !!row.disabled })
636
+ );
637
+ } else {
638
+ group.textContent = row.label;
560
639
  }
640
+ return group;
561
641
  }
562
642
  /** 「建立新項」列 共用 option 樣式與 a11y 但點擊走 createTag */
563
643
  #buildCreateRow(row, a11y, activeIndex) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selkit/dom",
3
- "version": "0.2.0",
3
+ "version": "0.4.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.2.0"
22
+ "@selkit/core": "0.4.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "axe-core": "^4.12.1",