@selkit/dom 0.3.0 → 0.5.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,7 +72,9 @@ 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";
77
+ var builtinPositioner = (trigger, dropdown, opts) => attachPositioner(trigger, dropdown, opts?.autoWidth ?? false);
76
78
  function resolveParent(parent) {
77
79
  if (!parent) return null;
78
80
  if (typeof parent !== "string") return parent;
@@ -152,7 +154,9 @@ var SelkitDom = class {
152
154
  #name;
153
155
  #virtual;
154
156
  #itemHeight;
157
+ #groupHeight;
155
158
  #dropdownParent;
159
+ #positionerFactory;
156
160
  #templateSelection;
157
161
  #templateOption;
158
162
  #templateArrow;
@@ -196,7 +200,9 @@ var SelkitDom = class {
196
200
  this.#name = cfg.name;
197
201
  this.#virtual = cfg.virtualScroll ?? false;
198
202
  this.#itemHeight = cfg.itemHeight ?? DEFAULT_ITEM_HEIGHT;
203
+ this.#groupHeight = cfg.groupHeight ?? DEFAULT_GROUP_HEIGHT;
199
204
  this.#dropdownParent = resolveParent(cfg.dropdownParent);
205
+ this.#positionerFactory = cfg.positioner ?? builtinPositioner;
200
206
  this.#templateSelection = cfg.templateSelection;
201
207
  this.#templateOption = cfg.templateOption;
202
208
  this.#templateArrow = cfg.templateArrow;
@@ -457,7 +463,8 @@ var SelkitDom = class {
457
463
  }
458
464
  if (s.activeIndex === this.#lastActive || s.activeIndex < 0) return;
459
465
  this.#lastActive = s.activeIndex;
460
- const hasGroups = this.controller.getGroupedView().rows.some((r) => r.type === "group");
466
+ const rows = this.controller.getGroupedView().rows;
467
+ const hasGroups = rows.some((r) => r.type === "group");
461
468
  if (this.#virtual && !hasGroups) {
462
469
  const next = (0, import_core.computeScrollIntoView)({
463
470
  index: s.activeIndex,
@@ -471,6 +478,22 @@ var SelkitDom = class {
471
478
  }
472
479
  return;
473
480
  }
481
+ if (this.#virtual && hasGroups) {
482
+ const rowIndex = rows.findIndex(
483
+ (r) => r.type !== "group" && r.index === s.activeIndex
484
+ );
485
+ const next = (0, import_core.computeScrollIntoViewVariable)({
486
+ heights: this.#rowHeights(rows),
487
+ rowIndex,
488
+ scrollTop: this.#dropdown.scrollTop,
489
+ viewportHeight: this.#dropdown.clientHeight
490
+ });
491
+ if (next !== null) {
492
+ this.#dropdown.scrollTop = next;
493
+ this.#renderOptions(s);
494
+ }
495
+ return;
496
+ }
474
497
  const active = this.#dropdown.querySelector(
475
498
  `.${this.#cls("option", "active")}`
476
499
  );
@@ -588,40 +611,61 @@ var SelkitDom = class {
588
611
  itemHeight: this.#itemHeight,
589
612
  itemCount: view.rows.length
590
613
  });
591
- this.#dropdown.append(this.#spacer(range.paddingTop));
592
- for (let i = range.startIndex; i < range.endIndex; i++) {
593
- const row = view.rows[i];
594
- if (row?.type === "option") {
595
- this.#dropdown.append(this.#buildOption(row, a11y, s.activeIndex));
596
- } else if (row?.type === "create") {
597
- this.#dropdown.append(this.#buildCreateRow(row, a11y, s.activeIndex));
598
- }
599
- }
600
- this.#dropdown.append(this.#spacer(range.paddingBottom));
614
+ this.#renderVirtualSlice(view.rows, range, a11y, s.activeIndex);
601
615
  return;
602
616
  }
603
- for (const row of view.rows) {
604
- if (row.type === "group") {
605
- const group = document.createElement("div");
606
- group.className = this.#cls("group");
607
- if (row.disabled) group.classList.add(this.#cls("group", "disabled"));
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
- }
616
- this.#dropdown.append(group);
617
- continue;
618
- }
619
- if (row.type === "create") {
620
- this.#dropdown.append(this.#buildCreateRow(row, a11y, s.activeIndex));
621
- continue;
622
- }
623
- this.#dropdown.append(this.#buildOption(row, a11y, s.activeIndex));
617
+ if (this.#virtual && hasGroups) {
618
+ const win = (0, import_core.computeVirtualWindow)({
619
+ heights: this.#rowHeights(view.rows),
620
+ scrollTop: this.#dropdown.scrollTop,
621
+ viewportHeight: this.#dropdown.clientHeight
622
+ });
623
+ this.#renderVirtualSlice(view.rows, win, a11y, s.activeIndex);
624
+ return;
625
+ }
626
+ for (const row of view.rows) this.#renderRow(row, a11y, s.activeIndex);
627
+ }
628
+ /** 每列高度:分組標題用 groupHeight 其餘(option/create)用 itemHeight */
629
+ #rowHeights(rows) {
630
+ return rows.map(
631
+ (r) => r.type === "group" ? this.#groupHeight : this.#itemHeight
632
+ );
633
+ }
634
+ /** 渲染虛擬切片:上下佔位 + range 內各列 共用扁平與分組兩路徑 */
635
+ #renderVirtualSlice(rows, range, a11y, activeIndex) {
636
+ this.#dropdown.append(this.#spacer(range.paddingTop));
637
+ for (let i = range.startIndex; i < range.endIndex; i++) {
638
+ const row = rows[i];
639
+ if (row) this.#renderRow(row, a11y, activeIndex);
640
+ }
641
+ this.#dropdown.append(this.#spacer(range.paddingBottom));
642
+ }
643
+ /** 依列型別渲染並掛入下拉 group / create / option */
644
+ #renderRow(row, a11y, activeIndex) {
645
+ if (row.type === "group") {
646
+ this.#dropdown.append(this.#buildGroupRow(row));
647
+ return;
624
648
  }
649
+ if (row.type === "create") {
650
+ this.#dropdown.append(this.#buildCreateRow(row, a11y, activeIndex));
651
+ return;
652
+ }
653
+ this.#dropdown.append(this.#buildOption(row, a11y, activeIndex));
654
+ }
655
+ /** 分組標題列 套用 templateGroup(無則用 label)*/
656
+ #buildGroupRow(row) {
657
+ const group = document.createElement("div");
658
+ group.className = this.#cls("group");
659
+ if (row.disabled) group.classList.add(this.#cls("group", "disabled"));
660
+ if (this.#templateGroup) {
661
+ this.#applyTemplate(
662
+ group,
663
+ this.#templateGroup({ label: row.label, disabled: !!row.disabled })
664
+ );
665
+ } else {
666
+ group.textContent = row.label;
667
+ }
668
+ return group;
625
669
  }
626
670
  /** 「建立新項」列 共用 option 樣式與 a11y 但點擊走 createTag */
627
671
  #buildCreateRow(row, a11y, activeIndex) {
@@ -699,11 +743,9 @@ var SelkitDom = class {
699
743
  this.#dropdown.hidden = false;
700
744
  if (this.#positioner) this.#positioner.update();
701
745
  else
702
- this.#positioner = attachPositioner(
703
- this.#control,
704
- this.#dropdown,
705
- this.#dropdownAutoWidth
706
- );
746
+ this.#positioner = this.#positionerFactory(this.#control, this.#dropdown, {
747
+ autoWidth: this.#dropdownAutoWidth
748
+ });
707
749
  } else {
708
750
  this.#dropdown.hidden = true;
709
751
  this.#positioner?.destroy();
package/dist/index.d.cts CHANGED
@@ -1,5 +1,48 @@
1
1
  import { SelkitController, SelkitConfig, SelkitOption, SelkitEmptyReason } from '@selkit/core';
2
2
 
3
+ /**
4
+ * @selkit/dom — 預設輕量定位器(零依賴)
5
+ *
6
+ * 計算邏輯與 DOM 套用分離:computePosition 為純函式(可單元測)
7
+ * attachPositioner 負責讀取 rect、套 style、監聽 scroll/resize
8
+ */
9
+ type Placement = 'bottom' | 'top';
10
+ interface Rect {
11
+ top: number;
12
+ bottom: number;
13
+ left: number;
14
+ width: number;
15
+ }
16
+ interface PositionResult {
17
+ placement: Placement;
18
+ top: number;
19
+ left: number;
20
+ width: number;
21
+ }
22
+ /**
23
+ * 依觸發元件位置與下拉高度 決定放上方或下方(空間不足才翻轉)
24
+ * 並回傳套用座標 不做水平翻轉(select 下拉慣例對齊左緣、同寬)
25
+ */
26
+ declare function computePosition(triggerRect: Rect, dropdownHeight: number, viewportHeight: number, gap?: number): PositionResult;
27
+ interface Positioner {
28
+ update(): void;
29
+ destroy(): void;
30
+ }
31
+ /** 定位器工廠收到的選項 由 @selkit/dom 在開啟下拉時傳入 */
32
+ interface PositionerOptions {
33
+ /** 下拉寬度貼齊內容(至少與控制項同寬)而非固定等寬 */
34
+ autoWidth?: boolean;
35
+ /** trigger 與下拉之間的間距 px 預設 4 */
36
+ gap?: number;
37
+ }
38
+ /**
39
+ * 定位器工廠 可插拔點:@selkit/dom 預設用內建 attachPositioner
40
+ * 傳入自訂工廠(如 @selkit/floating 的 createFloatingPositioner)即換成進階定位
41
+ */
42
+ type PositionerFactory = (trigger: HTMLElement, dropdown: HTMLElement, opts?: PositionerOptions) => Positioner;
43
+ /** 將 dropdown 定位到 trigger 旁 並隨 scroll/resize 更新 */
44
+ declare function attachPositioner(trigger: HTMLElement, dropdown: HTMLElement, autoWidth?: boolean): Positioner;
45
+
3
46
  interface SelkitDomConfig<T = unknown> extends SelkitConfig<T> {
4
47
  /** class 前綴 預設 "selkit" */
5
48
  classPrefix?: string;
@@ -15,8 +58,12 @@ interface SelkitDomConfig<T = unknown> extends SelkitConfig<T> {
15
58
  virtualScroll?: boolean;
16
59
  /** 虛擬捲動的單列固定高度 px 預設 36 須與實際樣式高度一致 */
17
60
  itemHeight?: number;
61
+ /** 虛擬捲動下分組標題列的固定高度 px 預設 28 須與實際樣式高度一致 */
62
+ groupHeight?: number;
18
63
  /** 把下拉浮層掛到指定容器(元素或選擇器)逃離 overflow/transform 祖先的裁切 常用 document.body */
19
64
  dropdownParent?: HTMLElement | string;
65
+ /** 自訂定位器工廠 預設為內建零依賴定位器(垂直翻轉)傳入 @selkit/floating 的 createFloatingPositioner 即啟用 flip/shift/size 進階定位 */
66
+ positioner?: PositionerFactory;
20
67
  /** 自訂已選顯示內容(tag 或單值) 回傳字串走 textContent 防 XSS 需 markup(icon 等)請回傳 Node */
21
68
  templateSelection?: (option: SelkitOption<T>, meta: {
22
69
  index: number;
@@ -65,35 +112,4 @@ declare class SelkitDom<T> implements SelkitDomInstance<T> {
65
112
  /** 在指定元素或 selector 對應的元素內建立並掛載一個 Selkit 下拉 */
66
113
  declare function createSelkitDom<T = unknown>(host: HTMLElement | string, config?: SelkitDomConfig<T>): SelkitDomInstance<T>;
67
114
 
68
- /**
69
- * @selkit/dom — 預設輕量定位器(零依賴)
70
- *
71
- * 計算邏輯與 DOM 套用分離:computePosition 為純函式(可單元測)
72
- * attachPositioner 負責讀取 rect、套 style、監聽 scroll/resize
73
- */
74
- type Placement = 'bottom' | 'top';
75
- interface Rect {
76
- top: number;
77
- bottom: number;
78
- left: number;
79
- width: number;
80
- }
81
- interface PositionResult {
82
- placement: Placement;
83
- top: number;
84
- left: number;
85
- width: number;
86
- }
87
- /**
88
- * 依觸發元件位置與下拉高度 決定放上方或下方(空間不足才翻轉)
89
- * 並回傳套用座標 不做水平翻轉(select 下拉慣例對齊左緣、同寬)
90
- */
91
- declare function computePosition(triggerRect: Rect, dropdownHeight: number, viewportHeight: number, gap?: number): PositionResult;
92
- interface Positioner {
93
- update(): void;
94
- destroy(): void;
95
- }
96
- /** 將 dropdown 定位到 trigger 旁 並隨 scroll/resize 更新 */
97
- declare function attachPositioner(trigger: HTMLElement, dropdown: HTMLElement, autoWidth?: boolean): Positioner;
98
-
99
- export { type Placement, type PositionResult, type Positioner, type Rect, SelkitDom as Selkit, SelkitDom, type SelkitDomConfig, type SelkitDomInstance, attachPositioner, computePosition, createSelkitDom, createSelkitDom as sk };
115
+ export { type Placement, type PositionResult, type Positioner, type PositionerFactory, type PositionerOptions, type Rect, SelkitDom as Selkit, SelkitDom, type SelkitDomConfig, type SelkitDomInstance, attachPositioner, computePosition, createSelkitDom, createSelkitDom as sk };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,48 @@
1
1
  import { SelkitController, SelkitConfig, SelkitOption, SelkitEmptyReason } from '@selkit/core';
2
2
 
3
+ /**
4
+ * @selkit/dom — 預設輕量定位器(零依賴)
5
+ *
6
+ * 計算邏輯與 DOM 套用分離:computePosition 為純函式(可單元測)
7
+ * attachPositioner 負責讀取 rect、套 style、監聽 scroll/resize
8
+ */
9
+ type Placement = 'bottom' | 'top';
10
+ interface Rect {
11
+ top: number;
12
+ bottom: number;
13
+ left: number;
14
+ width: number;
15
+ }
16
+ interface PositionResult {
17
+ placement: Placement;
18
+ top: number;
19
+ left: number;
20
+ width: number;
21
+ }
22
+ /**
23
+ * 依觸發元件位置與下拉高度 決定放上方或下方(空間不足才翻轉)
24
+ * 並回傳套用座標 不做水平翻轉(select 下拉慣例對齊左緣、同寬)
25
+ */
26
+ declare function computePosition(triggerRect: Rect, dropdownHeight: number, viewportHeight: number, gap?: number): PositionResult;
27
+ interface Positioner {
28
+ update(): void;
29
+ destroy(): void;
30
+ }
31
+ /** 定位器工廠收到的選項 由 @selkit/dom 在開啟下拉時傳入 */
32
+ interface PositionerOptions {
33
+ /** 下拉寬度貼齊內容(至少與控制項同寬)而非固定等寬 */
34
+ autoWidth?: boolean;
35
+ /** trigger 與下拉之間的間距 px 預設 4 */
36
+ gap?: number;
37
+ }
38
+ /**
39
+ * 定位器工廠 可插拔點:@selkit/dom 預設用內建 attachPositioner
40
+ * 傳入自訂工廠(如 @selkit/floating 的 createFloatingPositioner)即換成進階定位
41
+ */
42
+ type PositionerFactory = (trigger: HTMLElement, dropdown: HTMLElement, opts?: PositionerOptions) => Positioner;
43
+ /** 將 dropdown 定位到 trigger 旁 並隨 scroll/resize 更新 */
44
+ declare function attachPositioner(trigger: HTMLElement, dropdown: HTMLElement, autoWidth?: boolean): Positioner;
45
+
3
46
  interface SelkitDomConfig<T = unknown> extends SelkitConfig<T> {
4
47
  /** class 前綴 預設 "selkit" */
5
48
  classPrefix?: string;
@@ -15,8 +58,12 @@ interface SelkitDomConfig<T = unknown> extends SelkitConfig<T> {
15
58
  virtualScroll?: boolean;
16
59
  /** 虛擬捲動的單列固定高度 px 預設 36 須與實際樣式高度一致 */
17
60
  itemHeight?: number;
61
+ /** 虛擬捲動下分組標題列的固定高度 px 預設 28 須與實際樣式高度一致 */
62
+ groupHeight?: number;
18
63
  /** 把下拉浮層掛到指定容器(元素或選擇器)逃離 overflow/transform 祖先的裁切 常用 document.body */
19
64
  dropdownParent?: HTMLElement | string;
65
+ /** 自訂定位器工廠 預設為內建零依賴定位器(垂直翻轉)傳入 @selkit/floating 的 createFloatingPositioner 即啟用 flip/shift/size 進階定位 */
66
+ positioner?: PositionerFactory;
20
67
  /** 自訂已選顯示內容(tag 或單值) 回傳字串走 textContent 防 XSS 需 markup(icon 等)請回傳 Node */
21
68
  templateSelection?: (option: SelkitOption<T>, meta: {
22
69
  index: number;
@@ -65,35 +112,4 @@ declare class SelkitDom<T> implements SelkitDomInstance<T> {
65
112
  /** 在指定元素或 selector 對應的元素內建立並掛載一個 Selkit 下拉 */
66
113
  declare function createSelkitDom<T = unknown>(host: HTMLElement | string, config?: SelkitDomConfig<T>): SelkitDomInstance<T>;
67
114
 
68
- /**
69
- * @selkit/dom — 預設輕量定位器(零依賴)
70
- *
71
- * 計算邏輯與 DOM 套用分離:computePosition 為純函式(可單元測)
72
- * attachPositioner 負責讀取 rect、套 style、監聽 scroll/resize
73
- */
74
- type Placement = 'bottom' | 'top';
75
- interface Rect {
76
- top: number;
77
- bottom: number;
78
- left: number;
79
- width: number;
80
- }
81
- interface PositionResult {
82
- placement: Placement;
83
- top: number;
84
- left: number;
85
- width: number;
86
- }
87
- /**
88
- * 依觸發元件位置與下拉高度 決定放上方或下方(空間不足才翻轉)
89
- * 並回傳套用座標 不做水平翻轉(select 下拉慣例對齊左緣、同寬)
90
- */
91
- declare function computePosition(triggerRect: Rect, dropdownHeight: number, viewportHeight: number, gap?: number): PositionResult;
92
- interface Positioner {
93
- update(): void;
94
- destroy(): void;
95
- }
96
- /** 將 dropdown 定位到 trigger 旁 並隨 scroll/resize 更新 */
97
- declare function attachPositioner(trigger: HTMLElement, dropdown: HTMLElement, autoWidth?: boolean): Positioner;
98
-
99
- export { type Placement, type PositionResult, type Positioner, type Rect, SelkitDom as Selkit, SelkitDom, type SelkitDomConfig, type SelkitDomInstance, attachPositioner, computePosition, createSelkitDom, createSelkitDom as sk };
115
+ export { type Placement, type PositionResult, type Positioner, type PositionerFactory, type PositionerOptions, type Rect, SelkitDom as Selkit, SelkitDom, type SelkitDomConfig, type SelkitDomInstance, attachPositioner, computePosition, createSelkitDom, createSelkitDom as sk };
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  // src/dom.ts
2
2
  import {
3
3
  computeScrollIntoView,
4
+ computeScrollIntoViewVariable,
4
5
  computeVirtualRange,
6
+ computeVirtualWindow,
5
7
  createSelkit
6
8
  } from "@selkit/core";
7
9
 
@@ -45,7 +47,9 @@ function attachPositioner(trigger, dropdown, autoWidth = false) {
45
47
  // src/dom.ts
46
48
  var LOAD_MORE_THRESHOLD = 32;
47
49
  var DEFAULT_ITEM_HEIGHT = 36;
50
+ var DEFAULT_GROUP_HEIGHT = 28;
48
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";
52
+ var builtinPositioner = (trigger, dropdown, opts) => attachPositioner(trigger, dropdown, opts?.autoWidth ?? false);
49
53
  function resolveParent(parent) {
50
54
  if (!parent) return null;
51
55
  if (typeof parent !== "string") return parent;
@@ -125,7 +129,9 @@ var SelkitDom = class {
125
129
  #name;
126
130
  #virtual;
127
131
  #itemHeight;
132
+ #groupHeight;
128
133
  #dropdownParent;
134
+ #positionerFactory;
129
135
  #templateSelection;
130
136
  #templateOption;
131
137
  #templateArrow;
@@ -169,7 +175,9 @@ var SelkitDom = class {
169
175
  this.#name = cfg.name;
170
176
  this.#virtual = cfg.virtualScroll ?? false;
171
177
  this.#itemHeight = cfg.itemHeight ?? DEFAULT_ITEM_HEIGHT;
178
+ this.#groupHeight = cfg.groupHeight ?? DEFAULT_GROUP_HEIGHT;
172
179
  this.#dropdownParent = resolveParent(cfg.dropdownParent);
180
+ this.#positionerFactory = cfg.positioner ?? builtinPositioner;
173
181
  this.#templateSelection = cfg.templateSelection;
174
182
  this.#templateOption = cfg.templateOption;
175
183
  this.#templateArrow = cfg.templateArrow;
@@ -430,7 +438,8 @@ var SelkitDom = class {
430
438
  }
431
439
  if (s.activeIndex === this.#lastActive || s.activeIndex < 0) return;
432
440
  this.#lastActive = s.activeIndex;
433
- const hasGroups = this.controller.getGroupedView().rows.some((r) => r.type === "group");
441
+ const rows = this.controller.getGroupedView().rows;
442
+ const hasGroups = rows.some((r) => r.type === "group");
434
443
  if (this.#virtual && !hasGroups) {
435
444
  const next = computeScrollIntoView({
436
445
  index: s.activeIndex,
@@ -444,6 +453,22 @@ var SelkitDom = class {
444
453
  }
445
454
  return;
446
455
  }
456
+ if (this.#virtual && hasGroups) {
457
+ const rowIndex = rows.findIndex(
458
+ (r) => r.type !== "group" && r.index === s.activeIndex
459
+ );
460
+ const next = computeScrollIntoViewVariable({
461
+ heights: this.#rowHeights(rows),
462
+ rowIndex,
463
+ scrollTop: this.#dropdown.scrollTop,
464
+ viewportHeight: this.#dropdown.clientHeight
465
+ });
466
+ if (next !== null) {
467
+ this.#dropdown.scrollTop = next;
468
+ this.#renderOptions(s);
469
+ }
470
+ return;
471
+ }
447
472
  const active = this.#dropdown.querySelector(
448
473
  `.${this.#cls("option", "active")}`
449
474
  );
@@ -561,40 +586,61 @@ var SelkitDom = class {
561
586
  itemHeight: this.#itemHeight,
562
587
  itemCount: view.rows.length
563
588
  });
564
- this.#dropdown.append(this.#spacer(range.paddingTop));
565
- for (let i = range.startIndex; i < range.endIndex; i++) {
566
- const row = view.rows[i];
567
- if (row?.type === "option") {
568
- this.#dropdown.append(this.#buildOption(row, a11y, s.activeIndex));
569
- } else if (row?.type === "create") {
570
- this.#dropdown.append(this.#buildCreateRow(row, a11y, s.activeIndex));
571
- }
572
- }
573
- this.#dropdown.append(this.#spacer(range.paddingBottom));
589
+ this.#renderVirtualSlice(view.rows, range, a11y, s.activeIndex);
574
590
  return;
575
591
  }
576
- for (const row of view.rows) {
577
- if (row.type === "group") {
578
- const group = document.createElement("div");
579
- group.className = this.#cls("group");
580
- if (row.disabled) group.classList.add(this.#cls("group", "disabled"));
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
- }
589
- this.#dropdown.append(group);
590
- continue;
591
- }
592
- if (row.type === "create") {
593
- this.#dropdown.append(this.#buildCreateRow(row, a11y, s.activeIndex));
594
- continue;
595
- }
596
- this.#dropdown.append(this.#buildOption(row, a11y, s.activeIndex));
592
+ if (this.#virtual && hasGroups) {
593
+ const win = computeVirtualWindow({
594
+ heights: this.#rowHeights(view.rows),
595
+ scrollTop: this.#dropdown.scrollTop,
596
+ viewportHeight: this.#dropdown.clientHeight
597
+ });
598
+ this.#renderVirtualSlice(view.rows, win, a11y, s.activeIndex);
599
+ return;
600
+ }
601
+ for (const row of view.rows) this.#renderRow(row, a11y, s.activeIndex);
602
+ }
603
+ /** 每列高度:分組標題用 groupHeight 其餘(option/create)用 itemHeight */
604
+ #rowHeights(rows) {
605
+ return rows.map(
606
+ (r) => r.type === "group" ? this.#groupHeight : this.#itemHeight
607
+ );
608
+ }
609
+ /** 渲染虛擬切片:上下佔位 + range 內各列 共用扁平與分組兩路徑 */
610
+ #renderVirtualSlice(rows, range, a11y, activeIndex) {
611
+ this.#dropdown.append(this.#spacer(range.paddingTop));
612
+ for (let i = range.startIndex; i < range.endIndex; i++) {
613
+ const row = rows[i];
614
+ if (row) this.#renderRow(row, a11y, activeIndex);
615
+ }
616
+ this.#dropdown.append(this.#spacer(range.paddingBottom));
617
+ }
618
+ /** 依列型別渲染並掛入下拉 group / create / option */
619
+ #renderRow(row, a11y, activeIndex) {
620
+ if (row.type === "group") {
621
+ this.#dropdown.append(this.#buildGroupRow(row));
622
+ return;
597
623
  }
624
+ if (row.type === "create") {
625
+ this.#dropdown.append(this.#buildCreateRow(row, a11y, activeIndex));
626
+ return;
627
+ }
628
+ this.#dropdown.append(this.#buildOption(row, a11y, activeIndex));
629
+ }
630
+ /** 分組標題列 套用 templateGroup(無則用 label)*/
631
+ #buildGroupRow(row) {
632
+ const group = document.createElement("div");
633
+ group.className = this.#cls("group");
634
+ if (row.disabled) group.classList.add(this.#cls("group", "disabled"));
635
+ if (this.#templateGroup) {
636
+ this.#applyTemplate(
637
+ group,
638
+ this.#templateGroup({ label: row.label, disabled: !!row.disabled })
639
+ );
640
+ } else {
641
+ group.textContent = row.label;
642
+ }
643
+ return group;
598
644
  }
599
645
  /** 「建立新項」列 共用 option 樣式與 a11y 但點擊走 createTag */
600
646
  #buildCreateRow(row, a11y, activeIndex) {
@@ -672,11 +718,9 @@ var SelkitDom = class {
672
718
  this.#dropdown.hidden = false;
673
719
  if (this.#positioner) this.#positioner.update();
674
720
  else
675
- this.#positioner = attachPositioner(
676
- this.#control,
677
- this.#dropdown,
678
- this.#dropdownAutoWidth
679
- );
721
+ this.#positioner = this.#positionerFactory(this.#control, this.#dropdown, {
722
+ autoWidth: this.#dropdownAutoWidth
723
+ });
680
724
  } else {
681
725
  this.#dropdown.hidden = true;
682
726
  this.#positioner?.destroy();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selkit/dom",
3
- "version": "0.3.0",
3
+ "version": "0.5.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.3.0"
22
+ "@selkit/core": "0.5.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "axe-core": "^4.12.1",