@fluixi-ui/listbox 0.2.0-alpha.18 → 0.2.0-alpha.20

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.
@@ -1,171 +1,223 @@
1
- import { splitProps, createNativeElement, spread, mergeProps, insert, createComponent, templateNode, handleRef, delegateEvents } from "@fluixi/dom";
2
- import { createSignal, createMemo } from "@fluixi/reactive/signal";
3
- import { For } from "@fluixi/core";
4
- import { createId, composeEventHandlers, dataAttr, composeId } from "@fluixi-ui/kit";
5
- import { createSelectionManager, createVirtualizer, createTypeahead, getNextEnabledIndex } from "@fluixi-ui/primitives";
1
+ import { createComponent, createNativeElement, delegateEvents, handleRef, insert, mergeProps, splitProps, spread, templateNode } from "@fluixi/dom";
2
+ import { createMemo, createSignal } from "@fluixi/reactive/signal";
3
+ import { composeEventHandlers, composeId, createId, dataAttr } from "@fluixi-ui/kit";
4
+ import { createSelectionManager, createTypeahead, createVirtualizer, getNextEnabledIndex } from "@fluixi-ui/primitives";
6
5
  import { fxProps, mergeFx } from "@fluixi-ui/fx";
7
- const _fxTmpl$0 = '<span data-select-item-indicator="" aria-hidden="true"></span>';
8
- delegateEvents(["click", "keydown", "focus"]);
6
+ import { For } from "@fluixi/core";
7
+ //#region src/virtual-listbox.tsx
8
+ var _fxTmpl$0 = "<span data-select-item-indicator=\"\" aria-hidden=\"true\"></span>";
9
+ delegateEvents([
10
+ "click",
11
+ "keydown",
12
+ "focus"
13
+ ]);
9
14
  function toKeys(value) {
10
- if (value == null) return [];
11
- return Array.isArray(value) ? value : [value];
15
+ if (value == null) return [];
16
+ return Array.isArray(value) ? value : [value];
12
17
  }
18
+ /**
19
+ * A listbox that virtualizes a large `options` array — only the rows in view are
20
+ * in the DOM, while keyboard navigation, typeahead and active-descendant operate
21
+ * over the full data (the active row is scrolled into view). Single or multiple
22
+ * selection. Uniform `rowHeight` is required so offsets are computable.
23
+ *
24
+ * @example
25
+ * ```tsx
26
+ * <VirtualListbox
27
+ * options={items} // [{ value, label }, …] — thousands is fine
28
+ * selectionMode="multiple"
29
+ * onValuesChange={setSelected}
30
+ * />
31
+ * ```
32
+ */
13
33
  function VirtualListbox(props) {
14
- const [local, others] = splitProps(props, [
15
- "options",
16
- "selectionMode",
17
- "value",
18
- "defaultValue",
19
- "onChange",
20
- "onValuesChange",
21
- "disabled",
22
- "rowHeight",
23
- "height",
24
- "overscan",
25
- "children",
26
- "ref",
27
- "onKeyDown",
28
- "style",
29
- "fx",
30
- "style"
31
- ]);
32
- const baseId = createId("vlistbox");
33
- const selectionMode = local.selectionMode ?? "single";
34
- const multiple = selectionMode === "multiple";
35
- const rowHeight = () => local.rowHeight ?? 32;
36
- const isDisabled = () => local.disabled ?? false;
37
- const options = () => local.options;
38
- const optionId = (index) => composeId(baseId, `option-${index}`);
39
- const selection = createSelectionManager({
40
- mode: selectionMode,
41
- selectedKeys: local.value !== void 0 ? () => toKeys(local.value) : void 0,
42
- defaultSelectedKeys: toKeys(local.defaultValue),
43
- disallowEmptySelection: !multiple,
44
- onSelectionChange: (keys) => {
45
- const list = [...keys];
46
- if (multiple) local.onValuesChange?.(list);
47
- else local.onChange?.(list[0] ?? "");
48
- }
49
- });
50
- const [activeIndex, setActiveIndex] = createSignal(-1);
51
- const [scrollEl, setScrollEl] = createSignal(void 0);
52
- const virtualizer = createVirtualizer({
53
- count: () => options().length,
54
- estimateSize: () => rowHeight(),
55
- overscan: local.overscan ?? 6
56
- });
57
- const scrollIntoView = (index) => {
58
- const el = scrollEl();
59
- if (!el || index < 0) return;
60
- const top = index * rowHeight();
61
- const bottom = top + rowHeight();
62
- if (top < el.scrollTop) el.scrollTop = top;
63
- else if (bottom > el.scrollTop + el.clientHeight) el.scrollTop = bottom - el.clientHeight;
64
- };
65
- const move = (action) => {
66
- const next = getNextEnabledIndex(options(), activeIndex(), action, true);
67
- if (next !== -1) {
68
- setActiveIndex(next);
69
- scrollIntoView(next);
70
- }
71
- };
72
- const typeahead = createTypeahead({
73
- getItems: () => options().map((option) => ({ textValue: option.label ?? option.value, disabled: option.disabled })),
74
- getActiveIndex: () => activeIndex(),
75
- onMatch: (index) => {
76
- setActiveIndex(index);
77
- scrollIntoView(index);
78
- }
79
- });
80
- const pressOption = (index) => {
81
- const option = options()[index];
82
- if (!option || option.disabled || isDisabled()) return;
83
- setActiveIndex(index);
84
- if (multiple) selection.toggle(option.value);
85
- else selection.select(option.value);
86
- };
87
- const onKeyDown = (event) => {
88
- if (isDisabled()) return;
89
- switch (event.key) {
90
- case "ArrowDown":
91
- event.preventDefault();
92
- move(activeIndex() === -1 ? "first" : "next");
93
- break;
94
- case "ArrowUp":
95
- event.preventDefault();
96
- move(activeIndex() === -1 ? "last" : "previous");
97
- break;
98
- case "Home":
99
- event.preventDefault();
100
- move("first");
101
- break;
102
- case "End":
103
- event.preventDefault();
104
- move("last");
105
- break;
106
- case "Enter":
107
- case " ": {
108
- if (event.key === " " && typeahead.buffer() !== "") {
109
- typeahead.onKeyDown(event);
110
- break;
111
- }
112
- event.preventDefault();
113
- if (activeIndex() !== -1) pressOption(activeIndex());
114
- break;
115
- }
116
- default:
117
- typeahead.onKeyDown(event);
118
- }
119
- };
120
- const onFocus = () => {
121
- if (activeIndex() === -1) move("first");
122
- };
123
- const setRef = (node) => {
124
- virtualizer.scrollRef(node);
125
- setScrollEl(node);
126
- handleRef(local.ref, node);
127
- };
128
- const activeId = () => activeIndex() !== -1 ? optionId(activeIndex()) : void 0;
129
- return (() => {
130
- const _el$ = createNativeElement("div");
131
- spread({ element: _el$, props: mergeProps(fxProps(
132
- () => mergeFx({ overflow: "auto", height: local.height ?? 240 }, local.fx),
133
- () => local.style
134
- ), others, { ref: setRef, role: "listbox", id: baseId, tabindex: () => isDisabled() ? -1 : 0, "aria-multiselectable": multiple ? "true" : void 0, "aria-disabled": () => isDisabled() ? "true" : void 0, "aria-activedescendant": activeId, "data-virtual-listbox": "", "data-mode": selectionMode, onKeyDown: composeEventHandlers([
135
- local.onKeyDown,
136
- onKeyDown
137
- ]), onFocus }) });
138
- insert(_el$, (() => {
139
- const _el$2 = createNativeElement("div");
140
- spread({ element: _el$2, props: { style: () => `position:relative;height:${virtualizer.getTotalSize()}px` } });
141
- insert(_el$2, createMemo(() => createComponent(For, { get each() {
142
- return virtualizer.getVirtualItems;
143
- }, get children() {
144
- return (item) => {
145
- const option = () => options()[item.index];
146
- const active = () => activeIndex() === item.index;
147
- const selected = () => selection.isSelected(option().value);
148
- const disabled = () => option().disabled ?? false;
149
- return (() => {
150
- const _el$3 = createNativeElement("div");
151
- spread({ element: _el$3, props: { role: "option", id: () => optionId(item.index), "aria-selected": () => selected() ? "true" : "false", "aria-disabled": () => disabled() ? "true" : void 0, "data-selected": () => dataAttr(selected()), "data-active": () => dataAttr(active()), "data-disabled": () => dataAttr(disabled()), style: () => `position:absolute;top:${item.start}px;left:0;width:100%;height:${item.size}px;box-sizing:border-box`, onClick: () => pressOption(item.index), onPointerMove: () => {
152
- if (!disabled()) setActiveIndex(item.index);
153
- } } });
154
- insert(_el$3, templateNode(_fxTmpl$0, "span"));
155
- insert(_el$3, () => local.children ? local.children(option(), {
156
- active: active(),
157
- selected: selected(),
158
- disabled: disabled()
159
- }) : option().label ?? option().value, null);
160
- return _el$3;
161
- })();
162
- };
163
- } })), null);
164
- return _el$2;
165
- })());
166
- return _el$;
167
- })();
34
+ const [local, others] = splitProps(props, [
35
+ "options",
36
+ "selectionMode",
37
+ "value",
38
+ "defaultValue",
39
+ "onChange",
40
+ "onValuesChange",
41
+ "disabled",
42
+ "rowHeight",
43
+ "height",
44
+ "overscan",
45
+ "children",
46
+ "ref",
47
+ "onKeyDown",
48
+ "style",
49
+ "fx",
50
+ "style"
51
+ ]);
52
+ const baseId = createId("vlistbox");
53
+ const selectionMode = local.selectionMode ?? "single";
54
+ const multiple = selectionMode === "multiple";
55
+ const rowHeight = () => local.rowHeight ?? 32;
56
+ const isDisabled = () => local.disabled ?? false;
57
+ const options = () => local.options;
58
+ const optionId = (index) => composeId(baseId, `option-${index}`);
59
+ const selection = createSelectionManager({
60
+ mode: selectionMode,
61
+ selectedKeys: local.value !== void 0 ? () => toKeys(local.value) : void 0,
62
+ defaultSelectedKeys: toKeys(local.defaultValue),
63
+ disallowEmptySelection: !multiple,
64
+ onSelectionChange: (keys) => {
65
+ const list = [...keys];
66
+ if (multiple) local.onValuesChange?.(list);
67
+ else local.onChange?.(list[0] ?? "");
68
+ }
69
+ });
70
+ const [activeIndex, setActiveIndex] = createSignal(-1);
71
+ const [scrollEl, setScrollEl] = createSignal(void 0);
72
+ const virtualizer = createVirtualizer({
73
+ count: () => options().length,
74
+ estimateSize: () => rowHeight(),
75
+ overscan: local.overscan ?? 6
76
+ });
77
+ const scrollIntoView = (index) => {
78
+ const el = scrollEl();
79
+ if (!el || index < 0) return;
80
+ const top = index * rowHeight();
81
+ const bottom = top + rowHeight();
82
+ if (top < el.scrollTop) el.scrollTop = top;
83
+ else if (bottom > el.scrollTop + el.clientHeight) el.scrollTop = bottom - el.clientHeight;
84
+ };
85
+ const move = (action) => {
86
+ const next = getNextEnabledIndex(options(), activeIndex(), action, true);
87
+ if (next !== -1) {
88
+ setActiveIndex(next);
89
+ scrollIntoView(next);
90
+ }
91
+ };
92
+ const typeahead = createTypeahead({
93
+ getItems: () => options().map((option) => ({
94
+ textValue: option.label ?? option.value,
95
+ disabled: option.disabled
96
+ })),
97
+ getActiveIndex: () => activeIndex(),
98
+ onMatch: (index) => {
99
+ setActiveIndex(index);
100
+ scrollIntoView(index);
101
+ }
102
+ });
103
+ const pressOption = (index) => {
104
+ const option = options()[index];
105
+ if (!option || option.disabled || isDisabled()) return;
106
+ setActiveIndex(index);
107
+ if (multiple) selection.toggle(option.value);
108
+ else selection.select(option.value);
109
+ };
110
+ const onKeyDown = (event) => {
111
+ if (isDisabled()) return;
112
+ switch (event.key) {
113
+ case "ArrowDown":
114
+ event.preventDefault();
115
+ move(activeIndex() === -1 ? "first" : "next");
116
+ break;
117
+ case "ArrowUp":
118
+ event.preventDefault();
119
+ move(activeIndex() === -1 ? "last" : "previous");
120
+ break;
121
+ case "Home":
122
+ event.preventDefault();
123
+ move("first");
124
+ break;
125
+ case "End":
126
+ event.preventDefault();
127
+ move("last");
128
+ break;
129
+ case "Enter":
130
+ case " ":
131
+ if (event.key === " " && typeahead.buffer() !== "") {
132
+ typeahead.onKeyDown(event);
133
+ break;
134
+ }
135
+ event.preventDefault();
136
+ if (activeIndex() !== -1) pressOption(activeIndex());
137
+ break;
138
+ default: typeahead.onKeyDown(event);
139
+ }
140
+ };
141
+ const onFocus = () => {
142
+ if (activeIndex() === -1) move("first");
143
+ };
144
+ const setRef = (node) => {
145
+ virtualizer.scrollRef(node);
146
+ setScrollEl(node);
147
+ handleRef(local.ref, node);
148
+ };
149
+ const activeId = () => activeIndex() !== -1 ? optionId(activeIndex()) : void 0;
150
+ return (() => {
151
+ const _el$ = createNativeElement("div");
152
+ spread({
153
+ element: _el$,
154
+ props: mergeProps(fxProps(() => mergeFx({
155
+ overflow: "auto",
156
+ height: local.height ?? 240
157
+ }, local.fx), () => local.style), others, {
158
+ ref: setRef,
159
+ role: "listbox",
160
+ id: baseId,
161
+ tabindex: () => isDisabled() ? -1 : 0,
162
+ "aria-multiselectable": multiple ? "true" : void 0,
163
+ "aria-disabled": () => isDisabled() ? "true" : void 0,
164
+ "aria-activedescendant": activeId,
165
+ "data-virtual-listbox": "",
166
+ "data-mode": selectionMode,
167
+ onKeyDown: composeEventHandlers([local.onKeyDown, onKeyDown]),
168
+ onFocus
169
+ })
170
+ });
171
+ insert(_el$, (() => {
172
+ const _el$ = createNativeElement("div");
173
+ spread({
174
+ element: _el$,
175
+ props: { style: () => `position:relative;height:${virtualizer.getTotalSize()}px` }
176
+ });
177
+ insert(_el$, createMemo(() => createComponent(For, {
178
+ get each() {
179
+ return virtualizer.getVirtualItems;
180
+ },
181
+ get children() {
182
+ return (item) => {
183
+ const option = () => options()[item.index];
184
+ const active = () => activeIndex() === item.index;
185
+ const selected = () => selection.isSelected(option().value);
186
+ const disabled = () => option().disabled ?? false;
187
+ return (() => {
188
+ const _el$ = createNativeElement("div");
189
+ spread({
190
+ element: _el$,
191
+ props: {
192
+ role: "option",
193
+ id: () => optionId(item.index),
194
+ "aria-selected": () => selected() ? "true" : "false",
195
+ "aria-disabled": () => disabled() ? "true" : void 0,
196
+ "data-selected": () => dataAttr(selected()),
197
+ "data-active": () => dataAttr(active()),
198
+ "data-disabled": () => dataAttr(disabled()),
199
+ style: () => `position:absolute;top:${item.start}px;left:0;width:100%;height:${item.size}px;box-sizing:border-box`,
200
+ onClick: () => pressOption(item.index),
201
+ onPointerMove: () => {
202
+ if (!disabled()) setActiveIndex(item.index);
203
+ }
204
+ }
205
+ });
206
+ insert(_el$, templateNode(_fxTmpl$0, "span"));
207
+ insert(_el$, () => local.children ? local.children(option(), {
208
+ active: active(),
209
+ selected: selected(),
210
+ disabled: disabled()
211
+ }) : option().label ?? option().value, null);
212
+ return _el$;
213
+ })();
214
+ };
215
+ }
216
+ })), null);
217
+ return _el$;
218
+ })());
219
+ return _el$;
220
+ })();
168
221
  }
169
- export {
170
- VirtualListbox
171
- };
222
+ //#endregion
223
+ export { VirtualListbox };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fluixi-ui/listbox",
3
- "version": "0.2.0-alpha.18",
3
+ "version": "0.2.0-alpha.20",
4
4
  "description": "Headless listbox for the Fluixi UI ecosystem",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -41,17 +41,17 @@
41
41
  "@fluixi/reactive": ">=1.0.0-alpha.41"
42
42
  },
43
43
  "dependencies": {
44
- "@fluixi-ui/fx": "0.1.0-alpha.7",
44
+ "@fluixi-ui/fx": "0.1.0-alpha.9",
45
45
  "@fluixi-ui/kit": "0.2.0-alpha.3",
46
46
  "@fluixi-ui/primitives": "0.2.0-alpha.9",
47
- "@fluixi-ui/tokens": "0.4.0-alpha.17"
47
+ "@fluixi-ui/tokens": "0.4.0-alpha.19"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@fluixi/core": "alpha",
51
51
  "@fluixi/jsx": "alpha",
52
52
  "@fluixi/dom": "alpha",
53
53
  "@fluixi/vite-plugin": "alpha",
54
- "vite": "^6.0.0",
54
+ "vite": "^8.3.2",
55
55
  "@fluixi/reactive": "alpha"
56
56
  },
57
57
  "scripts": {