bits-ui 1.0.0-next.17 → 1.0.0-next.19

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.
Files changed (97) hide show
  1. package/dist/bits/combobox/components/combobox-input.svelte +2 -2
  2. package/dist/bits/combobox/components/combobox-trigger.svelte +2 -2
  3. package/dist/bits/combobox/components/combobox.svelte +9 -7
  4. package/dist/bits/combobox/exports.d.ts +8 -8
  5. package/dist/bits/combobox/exports.js +8 -8
  6. package/dist/bits/combobox/types.d.ts +1 -1
  7. package/dist/bits/dialog/components/dialog-content.svelte +1 -1
  8. package/dist/bits/index.d.ts +0 -1
  9. package/dist/bits/index.js +0 -1
  10. package/dist/bits/menu/menu.svelte.js +2 -2
  11. package/dist/bits/menubar/menubar.svelte.js +1 -1
  12. package/dist/bits/{listbox/components/listbox-content-static.svelte → select/components/select-content-static.svelte} +6 -6
  13. package/dist/bits/{listbox/components/listbox-group-heading.svelte.d.ts → select/components/select-content-static.svelte.d.ts} +4 -4
  14. package/dist/bits/select/components/select-content.svelte +56 -35
  15. package/dist/bits/select/components/select-group-heading.svelte +3 -3
  16. package/dist/bits/select/components/select-group.svelte +4 -3
  17. package/dist/bits/{listbox/components/listbox-hidden-input.svelte → select/components/select-hidden-input.svelte} +4 -4
  18. package/dist/bits/{listbox/components/listbox-hidden-input.svelte.d.ts → select/components/select-hidden-input.svelte.d.ts} +4 -4
  19. package/dist/bits/select/components/select-item.svelte +14 -9
  20. package/dist/bits/select/components/select-scroll-down-button.svelte +20 -9
  21. package/dist/bits/select/components/select-scroll-down-button.svelte.d.ts +2 -2
  22. package/dist/bits/select/components/select-scroll-up-button.svelte +21 -10
  23. package/dist/bits/select/components/select-trigger.svelte +5 -7
  24. package/dist/bits/select/components/select-viewport.svelte +10 -0
  25. package/dist/bits/select/components/select.svelte +54 -56
  26. package/dist/bits/select/exports.d.ts +4 -7
  27. package/dist/bits/select/exports.js +3 -6
  28. package/dist/bits/select/select.svelte.d.ts +272 -258
  29. package/dist/bits/select/select.svelte.js +764 -831
  30. package/dist/bits/select/types.d.ts +128 -79
  31. package/dist/bits/slider/components/slider.svelte +2 -2
  32. package/dist/bits/slider/slider.svelte.d.ts +2 -2
  33. package/dist/bits/slider/slider.svelte.js +4 -4
  34. package/dist/bits/slider/types.d.ts +3 -2
  35. package/dist/bits/utilities/popper-layer/popper-layer.svelte +1 -3
  36. package/dist/internal/arrays.d.ts +23 -0
  37. package/dist/internal/arrays.js +35 -0
  38. package/dist/internal/date-time/calendar-helpers.svelte.js +6 -1
  39. package/dist/internal/use-body-scroll-lock.svelte.js +53 -42
  40. package/dist/internal/use-data-typeahead.svelte.d.ts +12 -0
  41. package/dist/internal/use-data-typeahead.svelte.js +30 -0
  42. package/dist/internal/use-dom-typeahead.svelte.d.ts +11 -0
  43. package/dist/internal/use-dom-typeahead.svelte.js +30 -0
  44. package/dist/types.d.ts +1 -1
  45. package/package.json +2 -2
  46. package/dist/bits/listbox/components/listbox-content-static.svelte.d.ts +0 -19
  47. package/dist/bits/listbox/components/listbox-content.svelte +0 -76
  48. package/dist/bits/listbox/components/listbox-content.svelte.d.ts +0 -19
  49. package/dist/bits/listbox/components/listbox-group-heading.svelte +0 -32
  50. package/dist/bits/listbox/components/listbox-group.svelte +0 -32
  51. package/dist/bits/listbox/components/listbox-group.svelte.d.ts +0 -19
  52. package/dist/bits/listbox/components/listbox-item.svelte +0 -43
  53. package/dist/bits/listbox/components/listbox-item.svelte.d.ts +0 -19
  54. package/dist/bits/listbox/components/listbox-scroll-down-button.svelte +0 -39
  55. package/dist/bits/listbox/components/listbox-scroll-down-button.svelte.d.ts +0 -19
  56. package/dist/bits/listbox/components/listbox-scroll-up-button.svelte +0 -39
  57. package/dist/bits/listbox/components/listbox-scroll-up-button.svelte.d.ts +0 -19
  58. package/dist/bits/listbox/components/listbox-trigger.svelte +0 -35
  59. package/dist/bits/listbox/components/listbox-trigger.svelte.d.ts +0 -19
  60. package/dist/bits/listbox/components/listbox-viewport.svelte +0 -54
  61. package/dist/bits/listbox/components/listbox-viewport.svelte.d.ts +0 -19
  62. package/dist/bits/listbox/components/listbox.svelte +0 -81
  63. package/dist/bits/listbox/components/listbox.svelte.d.ts +0 -18
  64. package/dist/bits/listbox/exports.d.ts +0 -12
  65. package/dist/bits/listbox/exports.js +0 -11
  66. package/dist/bits/listbox/index.d.ts +0 -1
  67. package/dist/bits/listbox/index.js +0 -1
  68. package/dist/bits/listbox/listbox.svelte.d.ts +0 -399
  69. package/dist/bits/listbox/listbox.svelte.js +0 -973
  70. package/dist/bits/listbox/types.d.ts +0 -177
  71. package/dist/bits/listbox/types.js +0 -1
  72. package/dist/bits/select/components/select-arrow.svelte +0 -21
  73. package/dist/bits/select/components/select-arrow.svelte.d.ts +0 -18
  74. package/dist/bits/select/components/select-content-floating.svelte +0 -73
  75. package/dist/bits/select/components/select-content-floating.svelte.d.ts +0 -33
  76. package/dist/bits/select/components/select-content-impl.svelte +0 -90
  77. package/dist/bits/select/components/select-content-impl.svelte.d.ts +0 -90
  78. package/dist/bits/select/components/select-content-item-aligned.svelte +0 -37
  79. package/dist/bits/select/components/select-content-item-aligned.svelte.d.ts +0 -29
  80. package/dist/bits/select/components/select-icon.svelte +0 -36
  81. package/dist/bits/select/components/select-icon.svelte.d.ts +0 -19
  82. package/dist/bits/select/components/select-item-text.svelte +0 -39
  83. package/dist/bits/select/components/select-item-text.svelte.d.ts +0 -19
  84. package/dist/bits/select/components/select-native.svelte +0 -36
  85. package/dist/bits/select/components/select-native.svelte.d.ts +0 -30
  86. package/dist/bits/select/components/select-provider.svelte +0 -10
  87. package/dist/bits/select/components/select-provider.svelte.d.ts +0 -23
  88. package/dist/bits/select/components/select-scroll-down-button-mounted.svelte +0 -33
  89. package/dist/bits/select/components/select-scroll-down-button-mounted.svelte.d.ts +0 -29
  90. package/dist/bits/select/components/select-scroll-up-button-mounted.svelte +0 -33
  91. package/dist/bits/select/components/select-scroll-up-button-mounted.svelte.d.ts +0 -29
  92. package/dist/bits/select/components/select-separator.svelte +0 -32
  93. package/dist/bits/select/components/select-separator.svelte.d.ts +0 -19
  94. package/dist/bits/select/components/select-value.svelte +0 -35
  95. package/dist/bits/select/components/select-value.svelte.d.ts +0 -19
  96. package/dist/internal/use-typeahead.svelte.d.ts +0 -34
  97. package/dist/internal/use-typeahead.svelte.js +0 -64
@@ -1,138 +1,545 @@
1
- /**
2
- * This logic is adapted from Radix UI Select component.
3
- * https://github.com/radix-ui/primitives/blob/main/packages/react/select/src/Select.tsx
4
- * Credit to the Radix UI team for the original implementation.
5
- */
6
- import { afterSleep, afterTick, box, useRefById, } from "svelte-toolbelt";
7
- import { SvelteMap } from "svelte/reactivity";
1
+ import { Previous } from "runed";
8
2
  import { untrack } from "svelte";
9
- import { useId } from "../../internal/use-id.js";
3
+ import { afterTick, srOnlyStyles, styleToString, useRefById } from "svelte-toolbelt";
4
+ import { backward, forward, next, prev } from "../../internal/arrays.js";
5
+ import { getAriaExpanded, getAriaHidden, getDataDisabled, getDataOpenClosed, getDisabled, getRequired, } from "../../internal/attrs.js";
10
6
  import { createContext } from "../../internal/create-context.js";
11
- import { useFormControl } from "../../internal/use-form-control.svelte.js";
12
- import { useTypeahead } from "../../internal/use-typeahead.svelte.js";
13
- import { getAriaDisabled, getAriaExpanded, getAriaHidden, getAriaRequired, getAriaSelected, getDataChecked, getDataDisabled, getDataOpenClosed, } from "../../internal/attrs.js";
14
7
  import { kbd } from "../../internal/kbd.js";
15
- import { clamp } from "../../internal/clamp.js";
16
8
  import { noop } from "../../internal/noop.js";
17
9
  import { addEventListener } from "../../internal/events.js";
18
- export const OPEN_KEYS = [kbd.SPACE, kbd.ENTER, kbd.ARROW_UP, kbd.ARROW_DOWN];
19
- export const SELECTION_KEYS = [" ", kbd.ENTER];
10
+ import { useDOMTypeahead } from "../../internal/use-dom-typeahead.svelte.js";
11
+ import { useDataTypeahead } from "../../internal/use-data-typeahead.svelte.js";
12
+ // prettier-ignore
13
+ export const INTERACTION_KEYS = [kbd.ARROW_LEFT, kbd.ESCAPE, kbd.ARROW_RIGHT, kbd.SHIFT, kbd.CAPS_LOCK, kbd.CONTROL, kbd.ALT, kbd.META, kbd.ENTER, kbd.F1, kbd.F2, kbd.F3, kbd.F4, kbd.F5, kbd.F6, kbd.F7, kbd.F8, kbd.F9, kbd.F10, kbd.F11, kbd.F12];
14
+ export const FIRST_KEYS = [kbd.ARROW_DOWN, kbd.PAGE_UP, kbd.HOME];
15
+ export const LAST_KEYS = [kbd.ARROW_UP, kbd.PAGE_DOWN, kbd.END];
16
+ export const FIRST_LAST_KEYS = [...FIRST_KEYS, ...LAST_KEYS];
17
+ export const SELECTION_KEYS = [kbd.ENTER, kbd.SPACE];
20
18
  export const CONTENT_MARGIN = 10;
21
- const TRIGGER_ATTR = "data-select-trigger";
22
- const CONTENT_ATTR = "data-select-content";
23
- const ITEM_ATTR = "data-select-item";
24
- const VIEWPORT_ATTR = "data-select-viewport";
25
- const VALUE_ATTR = "data-select-value";
26
- const ITEM_TEXT_ATTR = "data-select-item-text";
27
- const CONTENT_WRAPPER_ATTR = "data-select-content-wrapper";
28
- const SCROLL_UP_BUTTON_ATTR = "data-select-scroll-up-button";
29
- const SCROLL_DOWN_BUTTON_ATTR = "data-select-scroll-down-button";
30
- const GROUP_ATTR = "data-select-group";
31
- const GROUP_LABEL_ATTR = "data-select-group-label";
32
- const SEPARATOR_ATTR = "data-select-separator";
33
- const ARROW_ATTR = "data-select-arrow";
34
- const ICON_ATTR = "data-select-icon";
35
- export const [setSelectRootContext, getSelectRootContext] = createContext("Select.Root");
36
- export const [setSelectTriggerContext] = createContext("Select.Trigger");
37
- export const [setSelectContentContext, getSelectContentContext] = createContext("Select.Content");
38
- export const [setSelectItemContext, getSelectItemContext] = createContext("Select.Item");
39
- export const [setSelectContentItemAlignedContext, getSelectContentItemAlignedContext] = createContext("Select.ContentItemAligned");
40
- const [setSelectGroupContext, getSelectGroupContext] = createContext("Select.Group");
41
- export class SelectRootState {
42
- open;
43
- value;
44
- dir;
19
+ class SelectBaseRootState {
45
20
  disabled;
46
21
  required;
47
- triggerNode = $state(null);
48
- valueId = box(useId());
49
- valueNodeHasChildren = box(false);
50
- valueNode = $state(null);
22
+ name;
23
+ loop;
24
+ open;
25
+ scrollAlignment;
26
+ items;
27
+ touchedInput = $state(false);
28
+ inputValue = $state("");
29
+ inputNode = $state(null);
51
30
  contentNode = $state(null);
52
- contentId = $state(undefined);
53
- triggerPointerDownPos = box({ x: 0, y: 0 });
54
- contentFragment = $state(null);
55
- // A set of all the native options we'll use to render the native select element under the hood
56
- #nativeOptionsSet = new SvelteMap();
57
- // A key we'll use to rerender the native select when the options change to keep it in sync
58
- nativeSelectKey = $derived.by(() => {
59
- return Array.from(this.#nativeOptionsSet.values())
60
- .map((opt) => opt.current.value)
61
- .join(";");
31
+ triggerNode = $state(null);
32
+ valueId = $state("");
33
+ highlightedNode = $state(null);
34
+ highlightedValue = $derived.by(() => {
35
+ if (!this.highlightedNode)
36
+ return null;
37
+ return this.highlightedNode.getAttribute("data-value");
38
+ });
39
+ highlightedId = $derived.by(() => {
40
+ if (!this.highlightedNode)
41
+ return undefined;
42
+ return this.highlightedNode.id;
62
43
  });
63
- nativeOptionsArr = $derived.by(() => Array.from(this.#nativeOptionsSet.values()));
64
- isFormControl = useFormControl(() => this.triggerNode);
44
+ highlightedLabel = $derived.by(() => {
45
+ if (!this.highlightedNode)
46
+ return null;
47
+ return this.highlightedNode.getAttribute("data-label");
48
+ });
49
+ isUsingKeyboard = $state(false);
50
+ isCombobox = $state(false);
51
+ bitsAttrs;
52
+ triggerPointerDownPos = $state.raw({ x: 0, y: 0 });
65
53
  constructor(props) {
66
- this.open = props.open;
67
- this.value = props.value;
68
- this.dir = props.dir;
69
54
  this.disabled = props.disabled;
70
55
  this.required = props.required;
56
+ this.name = props.name;
57
+ this.loop = props.loop;
58
+ this.open = props.open;
59
+ this.scrollAlignment = props.scrollAlignment;
60
+ this.isCombobox = props.isCombobox;
61
+ this.items = props.items;
62
+ this.bitsAttrs = getSelectBitsAttrs(this);
63
+ $effect.pre(() => {
64
+ if (!this.open.current) {
65
+ this.setHighlightedNode(null);
66
+ }
67
+ });
71
68
  }
72
- handleClose = () => {
73
- this.open.current = false;
74
- this.focusTriggerNode();
69
+ setHighlightedNode = (node) => {
70
+ this.highlightedNode = node;
71
+ if (node) {
72
+ if (this.isUsingKeyboard) {
73
+ node.scrollIntoView({ block: "nearest" });
74
+ }
75
+ }
75
76
  };
76
- focusTriggerNode = (preventScroll = true) => {
77
- const node = this.triggerNode;
77
+ getCandidateNodes = () => {
78
+ const node = this.contentNode;
78
79
  if (!node)
80
+ return [];
81
+ const nodes = Array.from(node.querySelectorAll(`[${this.bitsAttrs.item}]:not([data-disabled])`));
82
+ return nodes;
83
+ };
84
+ setHighlightedToFirstCandidate = () => {
85
+ this.setHighlightedNode(null);
86
+ const candidateNodes = this.getCandidateNodes();
87
+ if (!candidateNodes.length)
79
88
  return;
80
- // this needs to be 10 otherwise Firefox doesn't focus the correct node
81
- afterSleep(10, () => {
82
- node.focus({ preventScroll });
83
- });
89
+ this.setHighlightedNode(candidateNodes[0]);
84
90
  };
85
- onNativeOptionAdd = (option) => {
86
- this.#nativeOptionsSet.set(option.current.value, option);
91
+ getNodeByValue = (value) => {
92
+ const candidateNodes = this.getCandidateNodes();
93
+ return candidateNodes.find((node) => node.dataset.value === value) ?? null;
87
94
  };
88
- onNativeOptionRemove = (option) => {
89
- this.#nativeOptionsSet.delete(option.current.value);
95
+ setOpen = (open) => {
96
+ this.open.current = open;
90
97
  };
91
- getTriggerTypeaheadCandidateNodes = () => {
92
- const node = this.contentFragment;
93
- if (!node)
94
- return [];
95
- return Array.from(node.querySelectorAll(`[${ITEM_ATTR}]:not([data-disabled])`));
98
+ toggleOpen = () => {
99
+ this.open.current = !this.open.current;
96
100
  };
97
- getCandidateNodes = () => {
98
- const node = this.contentNode;
99
- if (!node)
101
+ handleOpen = () => {
102
+ this.setOpen(true);
103
+ };
104
+ handleClose = () => {
105
+ this.setHighlightedNode(null);
106
+ this.setOpen(false);
107
+ };
108
+ toggleMenu = () => {
109
+ this.toggleOpen();
110
+ };
111
+ }
112
+ class SelectSingleRootState extends SelectBaseRootState {
113
+ value;
114
+ isMulti = false;
115
+ hasValue = $derived.by(() => this.value.current !== "");
116
+ currentLabel = $derived.by(() => {
117
+ if (!this.items.current.length)
118
+ return "";
119
+ const match = this.items.current.find((item) => item.value === this.value.current)?.label;
120
+ return match ?? "";
121
+ });
122
+ candidateLabels = $derived.by(() => {
123
+ if (!this.items.current.length)
100
124
  return [];
101
- return Array.from(node.querySelectorAll(`[${ITEM_ATTR}]:not([data-disabled])`));
125
+ const filteredItems = this.items.current.filter((item) => !item.disabled);
126
+ return filteredItems.map((item) => item.label);
127
+ });
128
+ dataTypeaheadEnabled = $derived.by(() => {
129
+ if (this.isMulti)
130
+ return false;
131
+ if (this.items.current.length === 0)
132
+ return false;
133
+ return true;
134
+ });
135
+ constructor(props) {
136
+ super(props);
137
+ this.value = props.value;
138
+ $effect(() => {
139
+ if (!this.open.current && this.highlightedNode) {
140
+ this.setHighlightedNode(null);
141
+ }
142
+ });
143
+ $effect(() => {
144
+ if (!this.open.current)
145
+ return;
146
+ afterTick(() => {
147
+ this.#setInitialHighlightedNode();
148
+ });
149
+ });
150
+ }
151
+ includesItem = (itemValue) => {
152
+ return this.value.current === itemValue;
153
+ };
154
+ toggleItem = (itemValue, itemLabel = itemValue) => {
155
+ this.value.current = this.includesItem(itemValue) ? "" : itemValue;
156
+ this.inputValue = itemLabel;
157
+ };
158
+ #setInitialHighlightedNode = () => {
159
+ if (this.highlightedNode)
160
+ return;
161
+ if (this.value.current !== "") {
162
+ const node = this.getNodeByValue(this.value.current);
163
+ if (node) {
164
+ this.setHighlightedNode(node);
165
+ return;
166
+ }
167
+ }
168
+ // if no value is set, we want to highlight the first item
169
+ const firstCandidate = this.getCandidateNodes()[0];
170
+ if (!firstCandidate)
171
+ return;
172
+ this.setHighlightedNode(firstCandidate);
173
+ };
174
+ }
175
+ class SelectMultipleRootState extends SelectBaseRootState {
176
+ value;
177
+ isMulti = true;
178
+ hasValue = $derived.by(() => this.value.current.length > 0);
179
+ constructor(props) {
180
+ super(props);
181
+ this.value = props.value;
182
+ $effect(() => {
183
+ if (!this.open.current)
184
+ return;
185
+ afterTick(() => {
186
+ if (!this.highlightedNode) {
187
+ this.#setInitialHighlightedNode();
188
+ }
189
+ });
190
+ });
191
+ }
192
+ includesItem = (itemValue) => {
193
+ return this.value.current.includes(itemValue);
194
+ };
195
+ toggleItem = (itemValue, itemLabel = itemValue) => {
196
+ if (this.includesItem(itemValue)) {
197
+ this.value.current = this.value.current.filter((v) => v !== itemValue);
198
+ }
199
+ else {
200
+ this.value.current = [...this.value.current, itemValue];
201
+ }
202
+ this.inputValue = itemLabel;
203
+ };
204
+ #setInitialHighlightedNode = () => {
205
+ if (this.highlightedNode)
206
+ return;
207
+ if (this.value.current.length && this.value.current[0] !== "") {
208
+ const node = this.getNodeByValue(this.value.current[0]);
209
+ if (node) {
210
+ this.setHighlightedNode(node);
211
+ return;
212
+ }
213
+ }
214
+ // if no value is set, we want to highlight the first item
215
+ const firstCandidate = this.getCandidateNodes()[0];
216
+ if (!firstCandidate)
217
+ return;
218
+ this.setHighlightedNode(firstCandidate);
102
219
  };
103
220
  }
221
+ class SelectInputState {
222
+ #id;
223
+ #ref;
224
+ root;
225
+ constructor(props, root) {
226
+ this.root = root;
227
+ this.#id = props.id;
228
+ this.#ref = props.ref;
229
+ useRefById({
230
+ id: this.#id,
231
+ ref: this.#ref,
232
+ onRefChange: (node) => {
233
+ this.root.inputNode = node;
234
+ },
235
+ });
236
+ }
237
+ #onkeydown = async (e) => {
238
+ this.root.isUsingKeyboard = true;
239
+ if (e.key === kbd.ESCAPE)
240
+ return;
241
+ const open = this.root.open.current;
242
+ const inputValue = this.root.inputValue;
243
+ // prevent arrow up/down from moving the position of the cursor in the input
244
+ if (e.key === kbd.ARROW_UP || e.key === kbd.ARROW_DOWN)
245
+ e.preventDefault();
246
+ if (!open) {
247
+ if (INTERACTION_KEYS.includes(e.key))
248
+ return;
249
+ if (e.key === kbd.TAB)
250
+ return;
251
+ if (e.key === kbd.BACKSPACE && inputValue === "")
252
+ return;
253
+ this.root.handleOpen();
254
+ // we need to wait for a tick after the menu opens to ensure the highlighted nodes are
255
+ // set correctly.
256
+ afterTick(() => {
257
+ if (this.root.hasValue)
258
+ return;
259
+ const candidateNodes = this.root.getCandidateNodes();
260
+ if (!candidateNodes.length)
261
+ return;
262
+ if (e.key === kbd.ARROW_DOWN) {
263
+ const firstCandidate = candidateNodes[0];
264
+ this.root.setHighlightedNode(firstCandidate);
265
+ }
266
+ else if (e.key === kbd.ARROW_UP) {
267
+ const lastCandidate = candidateNodes[candidateNodes.length - 1];
268
+ this.root.setHighlightedNode(lastCandidate);
269
+ }
270
+ });
271
+ return;
272
+ }
273
+ if (e.key === kbd.TAB) {
274
+ this.root.handleClose();
275
+ return;
276
+ }
277
+ if (e.key === kbd.ENTER && !e.isComposing) {
278
+ e.preventDefault();
279
+ const highlightedValue = this.root.highlightedValue;
280
+ if (highlightedValue) {
281
+ this.root.toggleItem(highlightedValue, this.root.highlightedLabel ?? undefined);
282
+ }
283
+ if (!this.root.isMulti) {
284
+ this.root.handleClose();
285
+ }
286
+ }
287
+ if (e.key === kbd.ARROW_UP && e.altKey) {
288
+ this.root.handleClose();
289
+ }
290
+ if (FIRST_LAST_KEYS.includes(e.key)) {
291
+ e.preventDefault();
292
+ const candidateNodes = this.root.getCandidateNodes();
293
+ const currHighlightedNode = this.root.highlightedNode;
294
+ const currIndex = currHighlightedNode
295
+ ? candidateNodes.indexOf(currHighlightedNode)
296
+ : -1;
297
+ const loop = this.root.loop.current;
298
+ let nextItem;
299
+ if (e.key === kbd.ARROW_DOWN) {
300
+ nextItem = next(candidateNodes, currIndex, loop);
301
+ }
302
+ else if (e.key === kbd.ARROW_UP) {
303
+ nextItem = prev(candidateNodes, currIndex, loop);
304
+ }
305
+ else if (e.key === kbd.PAGE_DOWN) {
306
+ nextItem = forward(candidateNodes, currIndex, 10, loop);
307
+ }
308
+ else if (e.key === kbd.PAGE_UP) {
309
+ nextItem = backward(candidateNodes, currIndex, 10, loop);
310
+ }
311
+ else if (e.key === kbd.HOME) {
312
+ nextItem = candidateNodes[0];
313
+ }
314
+ else if (e.key === kbd.END) {
315
+ nextItem = candidateNodes[candidateNodes.length - 1];
316
+ }
317
+ if (!nextItem)
318
+ return;
319
+ this.root.setHighlightedNode(nextItem);
320
+ return;
321
+ }
322
+ if (INTERACTION_KEYS.includes(e.key))
323
+ return;
324
+ if (!this.root.highlightedNode) {
325
+ this.root.setHighlightedToFirstCandidate();
326
+ }
327
+ // this.root.setHighlightedToFirstCandidate();
328
+ };
329
+ #oninput = (e) => {
330
+ this.root.inputValue = e.currentTarget.value;
331
+ this.root.setHighlightedToFirstCandidate();
332
+ };
333
+ props = $derived.by(() => ({
334
+ id: this.#id.current,
335
+ role: "combobox",
336
+ disabled: this.root.disabled.current ? true : undefined,
337
+ "aria-activedescendant": this.root.highlightedId,
338
+ "aria-autocomplete": "list",
339
+ "aria-expanded": getAriaExpanded(this.root.open.current),
340
+ "data-state": getDataOpenClosed(this.root.open.current),
341
+ "data-disabled": getDataDisabled(this.root.disabled.current),
342
+ onkeydown: this.#onkeydown,
343
+ oninput: this.#oninput,
344
+ [this.root.bitsAttrs.input]: "",
345
+ }));
346
+ }
347
+ class SelectComboTriggerState {
348
+ #id;
349
+ #ref;
350
+ root;
351
+ constructor(props, root) {
352
+ this.root = root;
353
+ this.#id = props.id;
354
+ this.#ref = props.ref;
355
+ useRefById({
356
+ id: this.#id,
357
+ ref: this.#ref,
358
+ });
359
+ }
360
+ #onkeydown = (e) => {
361
+ if (e.key === kbd.ENTER || e.key === kbd.SPACE) {
362
+ e.preventDefault();
363
+ if (document.activeElement !== this.root.inputNode) {
364
+ this.root.inputNode?.focus();
365
+ }
366
+ this.root.toggleMenu();
367
+ }
368
+ };
369
+ /**
370
+ * `pointerdown` fires before the `focus` event, so we can prevent the default
371
+ * behavior of focusing the button and keep focus on the input.
372
+ */
373
+ #onpointerdown = (e) => {
374
+ if (this.root.disabled.current)
375
+ return;
376
+ e.preventDefault();
377
+ if (document.activeElement !== this.root.inputNode) {
378
+ this.root.inputNode?.focus();
379
+ }
380
+ this.root.toggleMenu();
381
+ };
382
+ props = $derived.by(() => ({
383
+ id: this.#id.current,
384
+ disabled: this.root.disabled.current ? true : undefined,
385
+ "aria-haspopup": "listbox",
386
+ "data-state": getDataOpenClosed(this.root.open.current),
387
+ "data-disabled": getDataDisabled(this.root.disabled.current),
388
+ [this.root.bitsAttrs.trigger]: "",
389
+ onpointerdown: this.#onpointerdown,
390
+ onkeydown: this.#onkeydown,
391
+ }));
392
+ }
104
393
  class SelectTriggerState {
105
- #root;
106
394
  #id;
107
395
  #ref;
108
- #disabled;
109
- #typeahead;
110
- #isDisabled = $derived.by(() => {
111
- return this.#root.disabled.current || this.#disabled.current;
112
- });
396
+ root;
397
+ #domTypeahead;
398
+ #dataTypeahead;
113
399
  constructor(props, root) {
400
+ this.root = root;
114
401
  this.#id = props.id;
115
402
  this.#ref = props.ref;
116
- this.#root = root;
117
- this.#disabled = props.disabled;
118
403
  useRefById({
119
404
  id: this.#id,
120
405
  ref: this.#ref,
121
406
  onRefChange: (node) => {
122
- this.#root.triggerNode = node;
407
+ this.root.triggerNode = node;
408
+ },
409
+ });
410
+ this.#domTypeahead = useDOMTypeahead({
411
+ getCurrentItem: () => this.root.highlightedNode,
412
+ onMatch: (node) => {
413
+ this.root.setHighlightedNode(node);
123
414
  },
124
415
  });
125
- this.#typeahead = useTypeahead();
416
+ this.#dataTypeahead = useDataTypeahead({
417
+ getCurrentItem: () => {
418
+ if (this.root.isMulti)
419
+ return "";
420
+ return this.root.currentLabel;
421
+ },
422
+ onMatch: (label) => {
423
+ if (this.root.isMulti)
424
+ return;
425
+ if (!this.root.items.current)
426
+ return;
427
+ const matchedItem = this.root.items.current.find((item) => item.label === label);
428
+ if (!matchedItem)
429
+ return;
430
+ this.root.value.current = matchedItem.value;
431
+ },
432
+ enabled: !this.root.isMulti && this.root.dataTypeaheadEnabled,
433
+ });
126
434
  }
127
- #handleOpen = () => {
128
- if (this.#isDisabled)
435
+ #onkeydown = (e) => {
436
+ this.root.isUsingKeyboard = true;
437
+ if (e.key === kbd.ARROW_UP || e.key === kbd.ARROW_DOWN)
438
+ e.preventDefault();
439
+ if (!this.root.open.current) {
440
+ if (e.key === kbd.ENTER) {
441
+ return;
442
+ }
443
+ else if (e.key === kbd.SPACE || e.key === kbd.ARROW_DOWN || e.key === kbd.ARROW_UP) {
444
+ e.preventDefault();
445
+ this.root.handleOpen();
446
+ }
447
+ else if (!this.root.isMulti && this.root.dataTypeaheadEnabled) {
448
+ this.#dataTypeahead.handleTypeaheadSearch(e.key, this.root.candidateLabels);
449
+ return;
450
+ }
451
+ // we need to wait for a tick after the menu opens to ensure
452
+ // the highlighted nodes are set correctly
453
+ afterTick(() => {
454
+ if (this.root.hasValue)
455
+ return;
456
+ const candidateNodes = this.root.getCandidateNodes();
457
+ if (!candidateNodes.length)
458
+ return;
459
+ if (e.key === kbd.ARROW_DOWN) {
460
+ const firstCandidate = candidateNodes[0];
461
+ this.root.setHighlightedNode(firstCandidate);
462
+ }
463
+ else if (e.key === kbd.ARROW_UP) {
464
+ const lastCandidate = candidateNodes[candidateNodes.length - 1];
465
+ this.root.setHighlightedNode(lastCandidate);
466
+ }
467
+ });
468
+ return;
469
+ }
470
+ if (e.key === kbd.TAB) {
471
+ this.root.handleClose();
472
+ return;
473
+ }
474
+ if ((e.key === kbd.ENTER || e.key === kbd.SPACE) && !e.isComposing) {
475
+ e.preventDefault();
476
+ const highlightedValue = this.root.highlightedValue;
477
+ if (highlightedValue) {
478
+ this.root.toggleItem(highlightedValue, this.root.highlightedLabel ?? undefined);
479
+ }
480
+ if (!this.root.isMulti) {
481
+ this.root.handleClose();
482
+ }
483
+ }
484
+ if (e.key === kbd.ARROW_UP && e.altKey) {
485
+ this.root.handleClose();
486
+ }
487
+ if (FIRST_LAST_KEYS.includes(e.key)) {
488
+ e.preventDefault();
489
+ const candidateNodes = this.root.getCandidateNodes();
490
+ const currHighlightedNode = this.root.highlightedNode;
491
+ const currIndex = currHighlightedNode
492
+ ? candidateNodes.indexOf(currHighlightedNode)
493
+ : -1;
494
+ const loop = this.root.loop.current;
495
+ let nextItem;
496
+ if (e.key === kbd.ARROW_DOWN) {
497
+ nextItem = next(candidateNodes, currIndex, loop);
498
+ }
499
+ else if (e.key === kbd.ARROW_UP) {
500
+ nextItem = prev(candidateNodes, currIndex, loop);
501
+ }
502
+ else if (e.key === kbd.PAGE_DOWN) {
503
+ nextItem = forward(candidateNodes, currIndex, 10, loop);
504
+ }
505
+ else if (e.key === kbd.PAGE_UP) {
506
+ nextItem = backward(candidateNodes, currIndex, 10, loop);
507
+ }
508
+ else if (e.key === kbd.HOME) {
509
+ nextItem = candidateNodes[0];
510
+ }
511
+ else if (e.key === kbd.END) {
512
+ nextItem = candidateNodes[candidateNodes.length - 1];
513
+ }
514
+ if (!nextItem)
515
+ return;
516
+ this.root.setHighlightedNode(nextItem);
517
+ return;
518
+ }
519
+ const isModifierKey = e.ctrlKey || e.altKey || e.metaKey;
520
+ const isCharacterKey = e.key.length === 1;
521
+ // prevent space from being considered with typeahead
522
+ if (e.code === "Space")
523
+ return;
524
+ const candidateNodes = this.root.getCandidateNodes();
525
+ if (e.key === kbd.TAB)
129
526
  return;
130
- this.#root.open.current = true;
131
- this.#typeahead.resetTypeahead();
527
+ if (!isModifierKey && isCharacterKey) {
528
+ this.#domTypeahead.handleTypeaheadSearch(e.key, candidateNodes);
529
+ return;
530
+ }
531
+ if (!this.root.highlightedNode) {
532
+ this.root.setHighlightedToFirstCandidate();
533
+ }
534
+ };
535
+ #handleOpen = () => {
536
+ this.root.open.current = true;
537
+ this.#dataTypeahead.resetTypeahead();
538
+ this.#domTypeahead.resetTypeahead();
132
539
  };
133
540
  #handlePointerOpen = (e) => {
134
541
  this.#handleOpen();
135
- this.#root.triggerPointerDownPos.current = {
542
+ this.root.triggerPointerDownPos = {
136
543
  x: Math.round(e.pageX),
137
544
  y: Math.round(e.pageY),
138
545
  };
@@ -146,9 +553,14 @@ class SelectTriggerState {
146
553
  const currTarget = e.currentTarget;
147
554
  currTarget.focus();
148
555
  };
556
+ /**
557
+ * `pointerdown` fires before the `focus` event, so we can prevent the default
558
+ * behavior of focusing the button and keep focus on the input.
559
+ */
149
560
  #onpointerdown = (e) => {
150
- // prevent opening on touch down which can be triggered
151
- // when scrolling on touch devices (unexpected)
561
+ if (this.root.disabled.current)
562
+ return;
563
+ // prevent opening on touch down which can be triggered when scrolling on touch devices
152
564
  if (e.pointerType === "touch")
153
565
  return e.preventDefault();
154
566
  // prevent implicit pointer capture
@@ -159,12 +571,12 @@ class SelectTriggerState {
159
571
  // only call the handle if it's a left click, since pointerdown is triggered
160
572
  // by right clicks as well, but not when ctrl is pressed
161
573
  if (e.button === 0 && e.ctrlKey === false) {
162
- if (this.#root.open.current === false) {
574
+ if (this.root.open.current === false) {
163
575
  this.#handlePointerOpen(e);
164
576
  e.preventDefault();
165
577
  }
166
578
  else {
167
- this.#root.handleClose();
579
+ this.root.handleClose();
168
580
  }
169
581
  }
170
582
  };
@@ -174,661 +586,269 @@ class SelectTriggerState {
174
586
  this.#handlePointerOpen(e);
175
587
  }
176
588
  };
177
- #onkeydown = (e) => {
178
- const isTypingAhead = this.#typeahead.search.current !== "";
179
- const isModifierKey = e.ctrlKey || e.altKey || e.metaKey;
180
- if (!isModifierKey && e.key.length === 1) {
181
- if (isTypingAhead && e.key === " ")
182
- return;
183
- }
184
- const newItem = this.#typeahead.handleTypeaheadSearch(e.key, this.#root.getTriggerTypeaheadCandidateNodes());
185
- if (newItem && newItem.dataset.value) {
186
- this.#root.value.current = newItem.dataset.value;
187
- }
188
- if (OPEN_KEYS.includes(e.key)) {
189
- this.#handleOpen();
190
- e.preventDefault();
191
- }
192
- };
193
589
  props = $derived.by(() => ({
194
590
  id: this.#id.current,
195
- disabled: this.#isDisabled,
196
- role: "combobox",
197
- type: "button",
198
- "aria-controls": this.#root.contentId,
199
- "aria-expanded": getAriaExpanded(this.#root.open.current),
200
- "aria-required": getAriaRequired(this.#root.required.current),
201
- "aria-autocomplete": "none",
202
- dir: this.#root.dir.current,
203
- "data-state": getDataOpenClosed(this.#root.open.current),
204
- "data-disabled": getDataDisabled(this.#isDisabled),
205
- "data-placeholder": shouldShowPlaceholder(this.#root.value.current)
206
- ? ""
207
- : undefined,
208
- [TRIGGER_ATTR]: "",
209
- onclick: this.#onclick,
210
- onpointerdown: this.#onpointerdown,
211
- onpointerup: this.#onpointerup,
212
- onkeydown: this.#onkeydown,
213
- }));
214
- }
215
- class SelectValueState {
216
- root;
217
- showPlaceholder = $derived.by(() => shouldShowPlaceholder(this.root.value.current));
218
- ref = box(null);
219
- constructor(root) {
220
- this.root = root;
221
- useRefById({
222
- id: this.root.valueId,
223
- ref: this.ref,
224
- onRefChange: (node) => {
225
- this.root.valueNode = node;
226
- },
227
- });
228
- }
229
- props = $derived.by(() => ({
230
- id: this.root.valueId.current,
591
+ disabled: this.root.disabled.current ? true : undefined,
592
+ "aria-haspopup": "listbox",
231
593
  "data-state": getDataOpenClosed(this.root.open.current),
232
594
  "data-disabled": getDataDisabled(this.root.disabled.current),
233
- [VALUE_ATTR]: "",
234
- style: {
235
- pointerEvents: "none",
236
- },
595
+ [this.root.bitsAttrs.trigger]: "",
596
+ onpointerdown: this.#onpointerdown,
597
+ onkeydown: this.#onkeydown,
598
+ onclick: this.#onclick,
599
+ onpointerup: this.#onpointerup,
600
+ // onclick: this.#onclick,
237
601
  }));
238
602
  }
239
- class SelectContentFragState {
240
- root;
241
- constructor(root) {
242
- this.root = root;
243
- $effect(() => {
244
- this.root.contentFragment = new DocumentFragment();
245
- });
246
- }
247
- }
248
- export class SelectContentState {
603
+ class SelectContentState {
249
604
  id;
250
605
  ref;
251
- root;
252
606
  viewportNode = $state(null);
253
- selectedItemId = box(useId());
254
- selectedItemTextId = box(useId());
255
- selectedItemText = box(null);
256
- position;
257
- isPositioned = box(false);
258
- firstValidItemFound = box(false);
259
- typeahead;
260
- alignedPositionState = null;
607
+ root;
608
+ isPositioned = $state(false);
261
609
  constructor(props, root) {
262
- this.position = props.position;
610
+ this.root = root;
263
611
  this.id = props.id;
264
612
  this.ref = props.ref;
265
- this.root = root;
266
- this.typeahead = useTypeahead();
267
613
  useRefById({
268
614
  id: this.id,
269
615
  ref: this.ref,
270
- deps: () => this.root.open.current,
271
616
  onRefChange: (node) => {
272
617
  this.root.contentNode = node;
273
- this.root.contentId = node?.id;
274
618
  },
619
+ deps: () => this.root.open.current,
275
620
  });
276
621
  $effect(() => {
277
- this.root.open.current;
278
- return untrack(() => {
279
- let cleanup = [noop];
280
- afterTick(() => {
281
- const node = document.getElementById(this.id.current);
282
- if (!node)
283
- return;
284
- let pointerMoveDelta = { x: 0, y: 0 };
285
- const handlePointerMove = (e) => {
286
- pointerMoveDelta = {
287
- x: Math.abs(Math.round(e.pageX) -
288
- (this.root.triggerPointerDownPos.current?.x ?? 0)),
289
- y: Math.abs(Math.round(e.pageY) -
290
- (this.root.triggerPointerDownPos.current?.y ?? 0)),
291
- };
292
- };
293
- const handlePointerUp = (e) => {
294
- if (e.pointerType === "touch")
295
- return;
296
- if (pointerMoveDelta.x <= 10 && pointerMoveDelta.y <= 10) {
297
- e.preventDefault();
298
- }
299
- else {
300
- if (!this.root.contentNode?.contains(e.target)) {
301
- this.root.handleClose();
302
- }
303
- }
304
- document.removeEventListener("pointermove", handlePointerMove);
305
- this.root.triggerPointerDownPos.current = null;
306
- };
307
- if (this.root.triggerPointerDownPos.current !== null) {
308
- const pointerMove = addEventListener(document, "pointermove", handlePointerMove);
309
- const pointerUp = addEventListener(document, "pointerup", handlePointerUp, {
310
- capture: true,
311
- once: true,
312
- });
313
- for (const cleanupFn of cleanup)
314
- cleanupFn();
315
- cleanup = [pointerMove, pointerUp];
316
- }
317
- return () => {
318
- for (const cleanupFn of cleanup)
319
- cleanupFn();
320
- };
321
- });
322
- });
323
- });
324
- $effect(() => {
325
- if (this.isPositioned.current) {
326
- this.focusSelectedItem();
327
- }
622
+ return () => {
623
+ this.root.contentNode = null;
624
+ };
328
625
  });
329
626
  $effect(() => {
330
627
  if (this.root.open.current === false) {
331
- this.isPositioned.current = false;
628
+ this.isPositioned = false;
332
629
  }
333
630
  });
334
631
  }
335
- focusFirst = (candidates) => {
336
- const [firstItem, ...restItems] = this.root.getCandidateNodes();
337
- const [lastItem] = restItems.slice(-1);
338
- const PREV_FOCUSED_ELEMENT = document.activeElement;
339
- for (const candidate of candidates) {
340
- if (candidate === PREV_FOCUSED_ELEMENT)
341
- return;
342
- candidate?.scrollIntoView({ block: "nearest" });
343
- // viewport might have padding so scroll to the edge when focusing first/last
344
- const viewport = this.viewportNode;
345
- if (candidate === firstItem && viewport) {
346
- viewport.scrollTop = 0;
347
- }
348
- if (candidate === lastItem && viewport) {
349
- viewport.scrollTop = viewport.scrollHeight;
350
- }
351
- candidate?.focus();
352
- if (document.activeElement !== PREV_FOCUSED_ELEMENT)
353
- return;
354
- }
355
- };
356
- onItemLeave = () => {
357
- this.root.contentNode?.focus();
632
+ #onpointermove = () => {
633
+ this.root.isUsingKeyboard = false;
358
634
  };
359
- getSelectedItem = () => {
360
- const candidates = this.root.getCandidateNodes();
361
- const selectedItemNode = candidates.find((node) => node?.dataset.value === this.root.value.current) ?? null;
362
- const first = candidates[0] ?? null;
363
- if (selectedItemNode) {
364
- const selectedItemTextNode = selectedItemNode.querySelector(`[${ITEM_TEXT_ATTR}]`);
635
+ #styles = $derived.by(() => {
636
+ if (this.root.isCombobox) {
365
637
  return {
366
- selectedItemNode,
367
- selectedItemTextNode,
638
+ "--bits-combobox-content-transform-origin": "var(--bits-floating-transform-origin)",
639
+ "--bits-combobox-content-available-width": "var(--bits-floating-available-width)",
640
+ "--bits-combobox-content-available-height": "var(--bits-floating-available-height)",
641
+ "--bits-combobox-anchor-width": "var(--bits-floating-anchor-width)",
642
+ "--bits-combobox-anchor-height": "var(--bits-floating-anchor-height)",
368
643
  };
369
644
  }
370
645
  else {
371
- if (first) {
372
- const firstItemText = first.querySelector(`[${ITEM_TEXT_ATTR}]`);
373
- return {
374
- selectedItemNode: first,
375
- selectedItemTextNode: firstItemText,
376
- };
377
- }
378
- }
379
- return {
380
- selectedItemNode: null,
381
- selectedItemTextNode: null,
382
- };
383
- };
384
- focusSelectedItem = () => {
385
- afterTick(() => {
386
- const candidates = this.root.getCandidateNodes();
387
- const selected = candidates.find((node) => node?.dataset.value === this.root.value.current) ?? null;
388
- const first = candidates[0] ?? null;
389
- this.focusFirst([selected, first]);
390
- });
391
- };
392
- itemRegister = (value, disabled) => {
393
- const isFirstValidItem = !this.firstValidItemFound.current && !disabled;
394
- const isSelectedItem = this.root.value.current !== undefined && this.root.value.current === value;
395
- if (isSelectedItem || isFirstValidItem) {
396
- if (isFirstValidItem) {
397
- this.firstValidItemFound.current = true;
398
- }
399
- }
400
- };
401
- itemTextRegister = (node, value, disabled) => {
402
- const isFirstValidItem = !this.firstValidItemFound.current && !disabled;
403
- const isSelectedItem = this.root.value.current !== undefined && this.root.value.current === value;
404
- if (isSelectedItem || isFirstValidItem) {
405
- this.selectedItemText.current = node;
406
- }
407
- };
408
- #onkeydown = (e) => {
409
- const isModifierKey = e.ctrlKey || e.altKey || e.metaKey;
410
- if (e.key === "Tab")
411
- e.preventDefault();
412
- if (!isModifierKey && e.key.length === 1) {
413
- this.typeahead.handleTypeaheadSearch(e.key, this.root.getCandidateNodes());
646
+ return {
647
+ "--bits-select-content-transform-origin": "var(--bits-floating-transform-origin)",
648
+ "--bits-select-content-available-width": "var(--bits-floating-available-width)",
649
+ "--bits-select-content-available-height": "var(--bits-floating-available-height)",
650
+ "--bits-select-anchor-width": "var(--bits-floating-anchor-width)",
651
+ "--bits-select-anchor-height": "var(--bits-floating-anchor-height)",
652
+ };
414
653
  }
415
- if ([kbd.ARROW_UP, kbd.ARROW_DOWN, kbd.HOME, kbd.END].includes(e.key)) {
416
- let candidateNodes = this.root.getCandidateNodes();
417
- if (e.key === kbd.ARROW_UP || e.key === kbd.END) {
418
- candidateNodes = candidateNodes.slice().reverse();
419
- }
420
- if (e.key === kbd.ARROW_UP || e.key === kbd.ARROW_DOWN) {
421
- const currElement = e.target;
422
- const currIndex = candidateNodes.indexOf(currElement);
423
- candidateNodes = candidateNodes.slice(currIndex + 1);
424
- }
425
- setTimeout(() => this.focusFirst(candidateNodes));
654
+ });
655
+ handleInteractOutside = (e) => {
656
+ if (e.target === this.root.triggerNode || e.target === this.root.inputNode) {
426
657
  e.preventDefault();
427
658
  }
428
659
  };
429
- #oncontextmenu = (e) => {
430
- e.preventDefault();
431
- };
660
+ snippetProps = $derived.by(() => ({ open: this.root.open.current }));
432
661
  props = $derived.by(() => ({
433
662
  id: this.id.current,
434
663
  role: "listbox",
435
664
  "data-state": getDataOpenClosed(this.root.open.current),
665
+ [this.root.bitsAttrs.content]: "",
436
666
  style: {
437
667
  display: "flex",
438
668
  flexDirection: "column",
439
669
  outline: "none",
670
+ boxSizing: "border-box",
671
+ ...this.#styles,
440
672
  },
441
- oncontextmenu: this.#oncontextmenu,
442
- onkeydown: this.#onkeydown,
443
- tabIndex: -1,
444
- [CONTENT_ATTR]: "",
673
+ onpointermove: this.#onpointermove,
445
674
  }));
446
675
  }
447
676
  class SelectItemState {
448
677
  #id;
449
678
  #ref;
450
679
  root;
451
- content;
452
- textId = box(undefined);
453
680
  value;
681
+ label;
682
+ onHighlight;
683
+ onUnhighlight;
454
684
  disabled;
455
- textValue;
456
- isSelected = $derived.by(() => this.root.value.current === this.value.current);
457
- isFocused = box(false);
458
- node = box(null);
459
- trueTextValue = box("");
460
- constructor(props, content) {
461
- this.#id = props.id;
462
- this.#ref = props.ref;
463
- this.root = content.root;
464
- this.content = content;
685
+ isSelected = $derived.by(() => this.root.includesItem(this.value.current));
686
+ isHighlighted = $derived.by(() => this.root.highlightedValue === this.value.current);
687
+ prevHighlighted = new Previous(() => this.isHighlighted);
688
+ textId = $state("");
689
+ constructor(props, root) {
690
+ this.root = root;
465
691
  this.value = props.value;
466
692
  this.disabled = props.disabled;
467
- this.textValue = props.textValue;
468
- useRefById({
469
- id: this.#id,
470
- ref: this.#ref,
471
- });
693
+ this.label = props.label;
694
+ this.onHighlight = props.onHighlight;
695
+ this.onUnhighlight = props.onUnhighlight;
696
+ this.#id = props.id;
697
+ this.#ref = props.ref;
472
698
  $effect(() => {
473
- const node = this.#ref.current;
474
- if (!node)
475
- return;
476
- this.content.itemRegister(this.value.current, this.disabled.current);
477
- });
478
- }
479
- onItemTextChange = (node) => {
480
- this.trueTextValue.current = ((this.textValue?.current || node?.textContent) ?? "").trim();
481
- };
482
- setTextId = (id) => {
483
- this.textId.current = id;
484
- };
485
- handleSelect = async (e) => {
486
- if (e?.defaultPrevented)
487
- return;
488
- if (!this.disabled.current) {
489
- this.root.value.current = this.value.current;
490
- this.root.handleClose();
491
- }
492
- };
493
- #onpointermove = async (e) => {
494
- if (e.defaultPrevented)
495
- return;
496
- if (this.disabled.current) {
497
- this.content.onItemLeave();
498
- }
499
- else {
500
- e.currentTarget.focus({ preventScroll: true });
501
- }
502
- };
503
- #onpointerleave = async (e) => {
504
- if (e.defaultPrevented)
505
- return;
506
- if (e.currentTarget === document.activeElement) {
507
- this.content.onItemLeave();
508
- }
509
- };
699
+ if (this.isHighlighted) {
700
+ this.onHighlight.current();
701
+ }
702
+ else if (this.prevHighlighted.current) {
703
+ this.onUnhighlight.current();
704
+ }
705
+ });
706
+ useRefById({
707
+ id: this.#id,
708
+ ref: this.#ref,
709
+ });
710
+ }
711
+ snippetProps = $derived.by(() => ({
712
+ selected: this.isSelected,
713
+ highlighted: this.isHighlighted,
714
+ }));
510
715
  #onpointerdown = (e) => {
511
- e.currentTarget.focus({ preventScroll: true });
512
- };
513
- #onpointerup = async (e) => {
514
- await this.handleSelect(e);
716
+ // prevent focus from leaving the combobox
717
+ e.preventDefault();
515
718
  };
516
- #onkeydown = async (e) => {
719
+ /**
720
+ * Using `pointerup` instead of `click` allows power users to pointerdown
721
+ * the trigger, then release pointerup on an item to select it vs having to do
722
+ * multiple clicks.
723
+ */
724
+ #onpointerup = (e) => {
517
725
  if (e.defaultPrevented)
518
726
  return;
519
- const isTypingAhead = this.content.typeahead.search.current !== "";
520
- if (isTypingAhead && e.key === kbd.SPACE)
727
+ // prevent any default behavior
728
+ e.preventDefault();
729
+ if (this.disabled.current)
521
730
  return;
522
- if (SELECTION_KEYS.includes(e.key)) {
523
- this.handleSelect();
731
+ const isCurrentSelectedValue = this.value.current === this.root.value.current;
732
+ this.root.toggleItem(this.value.current, this.label.current);
733
+ if (!this.root.isMulti && !isCurrentSelectedValue) {
734
+ this.root.handleClose();
524
735
  }
525
- // prevent page scroll on space
526
- if (e.key === kbd.SPACE)
527
- e.preventDefault();
528
736
  };
529
- #onfocus = () => {
530
- this.isFocused.current = true;
531
- };
532
- #onblur = () => {
533
- this.isFocused.current = false;
737
+ #onpointermove = (_) => {
738
+ if (this.root.highlightedNode !== this.#ref.current) {
739
+ this.root.setHighlightedNode(this.#ref.current);
740
+ }
534
741
  };
535
- #ontouchend = (e) => {
536
- e.preventDefault();
537
- e.stopPropagation();
742
+ setTextId = (id) => {
743
+ this.textId = id;
538
744
  };
539
745
  props = $derived.by(() => ({
540
746
  id: this.#id.current,
541
- role: "option",
542
- "aria-labelledby": this.textId.current ?? undefined,
543
- "data-highlighted": this.isFocused.current ? "" : undefined,
544
- "aria-selected": getAriaSelected(this.isSelected),
545
- "data-state": getDataChecked(this.isSelected),
546
- "aria-disabled": getAriaDisabled(this.disabled.current),
547
- "data-disabled": getDataDisabled(this.disabled.current),
548
- "data-selected": this.isSelected ? "" : undefined,
747
+ "aria-selected": this.root.includesItem(this.value.current) ? "true" : undefined,
549
748
  "data-value": this.value.current,
550
- tabindex: this.disabled.current ? undefined : -1,
551
- [ITEM_ATTR]: "",
552
- //
553
- onfocus: this.#onfocus,
749
+ "data-disabled": getDataDisabled(this.disabled.current),
750
+ "data-highlighted": this.root.highlightedValue === this.value.current ? "" : undefined,
751
+ "data-selected": this.root.includesItem(this.value.current) ? "" : undefined,
752
+ "data-label": this.label.current,
753
+ [this.root.bitsAttrs.item]: "",
554
754
  onpointermove: this.#onpointermove,
555
- onpointerleave: this.#onpointerleave,
556
755
  onpointerdown: this.#onpointerdown,
557
756
  onpointerup: this.#onpointerup,
558
- onkeydown: this.#onkeydown,
559
- onblur: this.#onblur,
560
- ontouchend: this.#ontouchend,
561
757
  }));
562
758
  }
563
- class SelectItemTextState {
564
- item;
759
+ class SelectGroupState {
565
760
  #id;
566
761
  #ref;
567
- node = box(null);
568
- nativeOption = box.with(() => ({
569
- key: this.item.value.current,
570
- value: this.item.value.current,
571
- disabled: this.item.disabled.current,
572
- innerHTML: this.node.current?.textContent,
573
- }));
574
- constructor(props, item) {
762
+ root;
763
+ labelNode = $state(null);
764
+ constructor(props, root) {
575
765
  this.#id = props.id;
576
766
  this.#ref = props.ref;
577
- this.item = item;
578
- this.item.setTextId(this.#id.current);
767
+ this.root = root;
579
768
  useRefById({
580
769
  id: this.#id,
581
770
  ref: this.#ref,
582
771
  });
583
- $effect(() => {
584
- this.item.setTextId(this.#id.current);
585
- });
586
- $effect(() => {
587
- untrack(() => {
588
- const textNode = this.item.root.contentFragment?.getElementById(this.#id.current);
589
- if (!textNode)
590
- return;
591
- this.item.onItemTextChange(textNode);
592
- this.item.content.itemTextRegister(textNode, this.item.value.current, this.item.disabled.current);
593
- this.item.root.onNativeOptionAdd(box.with(() => ({
594
- key: this.item.value.current,
595
- value: this.item.value.current,
596
- disabled: this.item.disabled.current,
597
- innerHTML: textNode?.textContent,
598
- })));
599
- });
600
- });
601
- $effect(() => {
602
- return () => {
603
- this.item.root.onNativeOptionRemove(this.nativeOption);
604
- };
605
- });
606
772
  }
607
773
  props = $derived.by(() => ({
608
774
  id: this.#id.current,
609
- [ITEM_TEXT_ATTR]: "",
775
+ role: "group",
776
+ [this.root.bitsAttrs.group]: "",
777
+ "aria-labelledby": this.labelNode?.id ?? undefined,
610
778
  }));
611
779
  }
612
- class SelectItemAlignedPositionState {
613
- root;
614
- content;
615
- shouldExpandOnScroll = $state(false);
616
- shouldReposition = $state(false);
617
- contentWrapperId = $state(useId());
618
- onPlaced;
619
- contentZIndex = $state("");
620
- constructor(props, content) {
621
- this.root = content.root;
622
- this.content = content;
623
- this.onPlaced = props.onPlaced;
624
- $effect(() => {
625
- afterTick(() => {
626
- this.position();
627
- const contentNode = document.getElementById(this.content.id.current);
628
- if (contentNode) {
629
- this.contentZIndex = window.getComputedStyle(contentNode).zIndex;
630
- }
631
- });
780
+ class SelectGroupHeadingState {
781
+ #id;
782
+ #ref;
783
+ group;
784
+ constructor(props, group) {
785
+ this.#id = props.id;
786
+ this.#ref = props.ref;
787
+ this.group = group;
788
+ useRefById({
789
+ id: this.#id,
790
+ ref: this.#ref,
791
+ onRefChange: (node) => {
792
+ group.labelNode = node;
793
+ },
632
794
  });
633
795
  }
634
- position = () => {
635
- afterTick(() => {
636
- const { selectedItemNode, selectedItemTextNode } = this.content.getSelectedItem();
637
- const contentNode = this.root.contentNode;
638
- const contentWrapperNode = document.getElementById(this.contentWrapperId);
639
- const viewportNode = this.content.viewportNode;
640
- const triggerNode = this.root.triggerNode;
641
- const valueNode = document.getElementById(this.root.valueId.current);
642
- if (!contentNode ||
643
- !contentWrapperNode ||
644
- !viewportNode ||
645
- !selectedItemNode ||
646
- !selectedItemTextNode ||
647
- !triggerNode ||
648
- !valueNode) {
649
- return;
650
- }
651
- const triggerRect = triggerNode.getBoundingClientRect();
652
- // horizontal positioning
653
- const contentRect = contentNode.getBoundingClientRect();
654
- const valueRect = valueNode.getBoundingClientRect();
655
- const itemTextRect = selectedItemTextNode.getBoundingClientRect();
656
- if (this.root.dir.current === "rtl") {
657
- const itemTextOffset = itemTextRect.left - contentRect.left;
658
- const left = valueRect.left - itemTextOffset;
659
- const leftDelta = triggerRect.left - left;
660
- const minContentWidth = triggerRect.width + leftDelta;
661
- const contentWidth = Math.max(minContentWidth, contentRect.width);
662
- const rightEdge = window.innerWidth - CONTENT_MARGIN;
663
- const clampedLeft = clamp(left, CONTENT_MARGIN, rightEdge - contentWidth);
664
- contentWrapperNode.style.minWidth = `${minContentWidth}px`;
665
- contentWrapperNode.style.left = `${clampedLeft}px`;
666
- }
667
- else {
668
- const itemTextOffset = contentRect.right - itemTextRect.right;
669
- const right = window.innerWidth - valueRect.right - itemTextOffset;
670
- const rightDelta = window.innerWidth - triggerRect.right - right;
671
- const minContentWidth = triggerRect.width + rightDelta;
672
- const contentWidth = Math.max(minContentWidth, contentRect.width);
673
- const leftEdge = window.innerWidth - CONTENT_MARGIN;
674
- const clampedRight = clamp(right, CONTENT_MARGIN, leftEdge - contentWidth);
675
- contentWrapperNode.style.minWidth = `${minContentWidth}px`;
676
- contentWrapperNode.style.right = `${clampedRight}px`;
677
- }
678
- // vertical positioning
679
- const items = this.root.getCandidateNodes();
680
- const availableHeight = window.innerHeight - CONTENT_MARGIN * 2;
681
- const itemsHeight = viewportNode.scrollHeight;
682
- const contentStyles = window.getComputedStyle(contentNode);
683
- const contentBorderTopWidth = Number.parseInt(contentStyles.borderTopWidth, 10);
684
- const contentPaddingTop = Number.parseInt(contentStyles.paddingTop, 10);
685
- const contentBorderBottomWidth = Number.parseInt(contentStyles.borderBottomWidth, 10);
686
- const contentPaddingBottom = Number.parseInt(contentStyles.paddingBottom, 10);
687
- const fullContentHeight = contentBorderTopWidth +
688
- contentPaddingTop +
689
- itemsHeight +
690
- contentPaddingBottom +
691
- contentBorderBottomWidth;
692
- const minContentHeight = Math.min(selectedItemNode.offsetHeight * 5, fullContentHeight);
693
- const viewportStyles = window.getComputedStyle(viewportNode);
694
- const viewportPaddingTop = Number.parseInt(viewportStyles.paddingTop, 10);
695
- const viewportPaddingBottom = Number.parseInt(viewportStyles.paddingBottom, 10);
696
- const topEdgeToTriggerMiddle = triggerRect.top + triggerRect.height / 2 - CONTENT_MARGIN;
697
- const triggerMiddleToBottomEdge = availableHeight - topEdgeToTriggerMiddle;
698
- const selectedItemHalfHeight = selectedItemNode.offsetHeight / 2;
699
- const itemOffsetMiddle = selectedItemNode.offsetTop + selectedItemHalfHeight;
700
- const contentTopToItemMiddle = contentBorderTopWidth + contentPaddingTop + itemOffsetMiddle;
701
- const itemMiddleToContentBottom = fullContentHeight - contentTopToItemMiddle;
702
- const willAlignWithoutTopOverflow = contentTopToItemMiddle <= topEdgeToTriggerMiddle;
703
- if (willAlignWithoutTopOverflow) {
704
- const isLastItem = selectedItemNode === items[items.length - 1];
705
- contentWrapperNode.style.bottom = `${0}px`;
706
- const viewportOffsetBottom = contentNode.clientHeight - viewportNode.offsetTop - viewportNode.offsetHeight;
707
- const clampedTriggerMiddleToBottomEdge = Math.max(triggerMiddleToBottomEdge, selectedItemHalfHeight +
708
- // viewport might have padding bottom, include it to avoid a scrollable viewport
709
- (isLastItem ? viewportPaddingBottom : 0) +
710
- viewportOffsetBottom +
711
- contentBorderBottomWidth);
712
- const height = contentTopToItemMiddle + clampedTriggerMiddleToBottomEdge;
713
- contentWrapperNode.style.height = `${height}px`;
714
- }
715
- else {
716
- const isFirstItem = selectedItemNode === items[0];
717
- contentWrapperNode.style.top = `${0}px`;
718
- const clampedTopEdgeToTriggerMiddle = Math.max(topEdgeToTriggerMiddle, contentBorderTopWidth +
719
- viewportNode.offsetTop +
720
- // viewport might have padding top, include it to avoid a scrollable viewport
721
- (isFirstItem ? viewportPaddingTop : 0) +
722
- selectedItemHalfHeight);
723
- const height = clampedTopEdgeToTriggerMiddle + itemMiddleToContentBottom;
724
- contentWrapperNode.style.height = `${height}px`;
725
- viewportNode.scrollTop =
726
- contentTopToItemMiddle - topEdgeToTriggerMiddle + viewportNode.offsetTop;
727
- }
728
- contentWrapperNode.style.margin = `${CONTENT_MARGIN}px 0`;
729
- contentWrapperNode.style.minHeight = `${minContentHeight}px`;
730
- contentWrapperNode.style.maxHeight = `${availableHeight}px`;
731
- this.onPlaced.current();
732
- });
733
- requestAnimationFrame(() => (this.shouldExpandOnScroll = true));
734
- };
735
- handleScrollButtonChange = (id) => {
736
- afterTick(() => {
737
- const node = document.getElementById(id);
738
- if (!node)
739
- return;
740
- if (!this.shouldReposition)
741
- return;
742
- this.position();
743
- this.content.focusSelectedItem();
744
- this.shouldReposition = false;
745
- });
746
- };
747
- wrapperProps = $derived.by(() => ({
748
- id: this.contentWrapperId,
749
- style: {
750
- display: "flex",
751
- flexDirection: "column",
752
- position: "fixed",
753
- zIndex: this.contentZIndex,
754
- },
755
- [CONTENT_WRAPPER_ATTR]: "",
756
- }));
757
796
  props = $derived.by(() => ({
758
- id: this.content.id.current,
759
- style: {
760
- boxSizing: "border-box",
761
- maxHeight: "100%",
762
- },
797
+ id: this.#id.current,
798
+ [this.group.root.bitsAttrs["group-label"]]: "",
763
799
  }));
764
800
  }
765
- class SelectFloatingPositionState {
801
+ class SelectHiddenInputState {
802
+ #value;
766
803
  root;
767
- content;
768
- constructor(content) {
769
- this.root = content.root;
770
- this.content = content;
804
+ shouldRender = $derived.by(() => this.root.name.current !== "");
805
+ constructor(props, root) {
806
+ this.root = root;
807
+ this.#value = props.value;
771
808
  }
772
- props = {
773
- style: {
774
- boxSizing: "border-box",
775
- "--bits-select-content-transform-origin": "var(--bits-floating-transform-origin)",
776
- "--bits-select-content-available-width": "var(--bits-floating-available-width)",
777
- "--bits-select-content-available-height": "var(--bits-floating-available-height)",
778
- "--bits-select-anchor-width": "var(--bits-floating-anchor-width)",
779
- "--bits-select-anchor-height": "var(--bits-floating-anchor-height)",
780
- },
809
+ #onfocus = (e) => {
810
+ e.preventDefault();
811
+ if (!this.root.isCombobox) {
812
+ this.root.triggerNode?.focus();
813
+ }
814
+ else {
815
+ this.root.inputNode?.focus();
816
+ }
781
817
  };
818
+ props = $derived.by(() => ({
819
+ disabled: getDisabled(this.root.disabled.current),
820
+ required: getRequired(this.root.required.current),
821
+ name: this.root.name.current,
822
+ value: this.#value.current,
823
+ style: styleToString(srOnlyStyles),
824
+ tabindex: -1,
825
+ onfocus: this.#onfocus,
826
+ }));
782
827
  }
783
828
  class SelectViewportState {
784
- id;
785
- ref;
829
+ #id;
830
+ #ref;
831
+ root;
786
832
  content;
787
833
  prevScrollTop = $state(0);
788
834
  constructor(props, content) {
789
- this.id = props.id;
835
+ this.#id = props.id;
836
+ this.#ref = props.ref;
790
837
  this.content = content;
791
- this.ref = props.ref;
838
+ this.root = content.root;
792
839
  useRefById({
793
- id: this.id,
794
- ref: this.ref,
840
+ id: this.#id,
841
+ ref: this.#ref,
795
842
  onRefChange: (node) => {
796
843
  this.content.viewportNode = node;
797
844
  },
798
- deps: () => this.content.root.open.current,
845
+ deps: () => this.root.open.current,
799
846
  });
800
847
  }
801
- #onscroll = (e) => {
802
- afterTick(() => {
803
- const viewport = e.currentTarget;
804
- const shouldExpandOnScroll = this.content.alignedPositionState?.shouldExpandOnScroll ?? undefined;
805
- const contentWrapper = document.getElementById(this.content.alignedPositionState?.contentWrapperId ?? "");
806
- if (shouldExpandOnScroll && contentWrapper) {
807
- const scrolledBy = Math.abs(this.prevScrollTop - viewport.scrollTop);
808
- if (scrolledBy > 0) {
809
- const availableHeight = window.innerHeight - CONTENT_MARGIN * 2;
810
- const cssMinHeight = Number.parseFloat(contentWrapper.style.minHeight);
811
- const cssHeight = Number.parseFloat(contentWrapper.style.height);
812
- const prevHeight = Math.max(cssMinHeight, cssHeight);
813
- if (prevHeight < availableHeight) {
814
- const nextHeight = prevHeight + scrolledBy;
815
- const clampedNextHeight = Math.min(availableHeight, nextHeight);
816
- const heightDiff = nextHeight - clampedNextHeight;
817
- contentWrapper.style.height = `${clampedNextHeight}px`;
818
- if (contentWrapper.style.bottom === "0px") {
819
- viewport.scrollTop = heightDiff > 0 ? heightDiff : 0;
820
- contentWrapper.style.justifyContent = "flex-end";
821
- }
822
- }
823
- }
824
- }
825
- this.prevScrollTop = viewport.scrollTop;
826
- });
827
- };
828
848
  props = $derived.by(() => ({
829
- id: this.id.current,
849
+ id: this.#id.current,
830
850
  role: "presentation",
831
- [VIEWPORT_ATTR]: "",
851
+ [this.root.bitsAttrs.viewport]: "",
832
852
  style: {
833
853
  // we use position: 'relative' here on the `viewport` so that when we call
834
854
  // `selectedItem.offsetTop` in calculations, the offset is relative to the viewport
@@ -837,47 +857,39 @@ class SelectViewportState {
837
857
  flex: 1,
838
858
  overflow: "auto",
839
859
  },
840
- onscroll: this.#onscroll,
841
860
  }));
842
861
  }
843
862
  class SelectScrollButtonImplState {
844
863
  id;
845
864
  ref;
846
865
  content;
847
- alignedPositionState;
866
+ root;
848
867
  autoScrollTimer = $state(null);
849
868
  onAutoScroll = noop;
850
869
  mounted;
851
870
  constructor(props, content) {
852
- this.content = content;
853
871
  this.ref = props.ref;
854
- this.alignedPositionState = content.alignedPositionState;
855
872
  this.id = props.id;
856
873
  this.mounted = props.mounted;
874
+ this.content = content;
875
+ this.root = content.root;
857
876
  useRefById({
858
877
  id: this.id,
859
878
  ref: this.ref,
860
879
  deps: () => this.mounted.current,
861
880
  });
862
881
  $effect(() => {
863
- if (this.mounted.current) {
864
- const activeItem = this.content.root
865
- .getCandidateNodes()
866
- .find((node) => node === document.activeElement);
867
- activeItem?.scrollIntoView({ block: "nearest" });
868
- }
869
- });
870
- $effect(() => {
871
- return () => {
872
- this.clearAutoScrollTimer();
873
- };
882
+ if (!this.mounted.current)
883
+ return;
884
+ const activeItem = untrack(() => this.root.highlightedNode);
885
+ activeItem?.scrollIntoView({ block: "nearest" });
874
886
  });
875
887
  }
876
888
  clearAutoScrollTimer = () => {
877
- if (this.autoScrollTimer !== null) {
878
- window.clearInterval(this.autoScrollTimer);
879
- this.autoScrollTimer = null;
880
- }
889
+ if (this.autoScrollTimer === null)
890
+ return;
891
+ window.clearInterval(this.autoScrollTimer);
892
+ this.autoScrollTimer = null;
881
893
  };
882
894
  #onpointerdown = () => {
883
895
  if (this.autoScrollTimer !== null)
@@ -887,7 +899,6 @@ class SelectScrollButtonImplState {
887
899
  }, 50);
888
900
  };
889
901
  #onpointermove = () => {
890
- this.content.onItemLeave?.();
891
902
  if (this.autoScrollTimer !== null)
892
903
  return;
893
904
  this.autoScrollTimer = window.setInterval(() => {
@@ -899,7 +910,7 @@ class SelectScrollButtonImplState {
899
910
  };
900
911
  props = $derived.by(() => ({
901
912
  id: this.id.current,
902
- "aria-hidden": "true",
913
+ "aria-hidden": getAriaHidden(true),
903
914
  style: {
904
915
  flexShrink: 0,
905
916
  },
@@ -911,246 +922,168 @@ class SelectScrollButtonImplState {
911
922
  class SelectScrollDownButtonState {
912
923
  state;
913
924
  content;
925
+ root;
914
926
  canScrollDown = $state(false);
915
927
  constructor(state) {
916
928
  this.state = state;
917
929
  this.content = state.content;
930
+ this.root = state.root;
918
931
  this.state.onAutoScroll = this.handleAutoScroll;
919
932
  $effect(() => {
920
933
  const viewport = this.content.viewportNode;
921
- const isPositioned = this.content.isPositioned.current;
934
+ const isPositioned = this.content.isPositioned;
922
935
  if (!viewport || !isPositioned)
923
936
  return;
924
937
  let cleanup = noop;
925
938
  untrack(() => {
926
939
  const handleScroll = () => {
927
- const maxScroll = viewport.scrollHeight - viewport.clientHeight;
928
- this.canScrollDown = Math.ceil(viewport.scrollTop) < maxScroll;
940
+ afterTick(() => {
941
+ const maxScroll = viewport.scrollHeight - viewport.clientHeight;
942
+ const paddingTop = Number.parseInt(getComputedStyle(viewport).paddingTop, 10);
943
+ this.canScrollDown = Math.ceil(viewport.scrollTop) < maxScroll - paddingTop;
944
+ });
929
945
  };
930
946
  handleScroll();
931
947
  cleanup = addEventListener(viewport, "scroll", handleScroll);
932
948
  });
933
- return () => {
934
- cleanup();
935
- };
949
+ return cleanup;
936
950
  });
937
951
  $effect(() => {
938
- if (this.state.mounted.current) {
939
- this.state.alignedPositionState?.handleScrollButtonChange(this.state.id.current);
940
- }
941
- });
942
- $effect(() => {
943
- if (!this.state.mounted.current) {
944
- this.state.clearAutoScrollTimer();
945
- }
952
+ if (this.state.mounted.current)
953
+ return;
954
+ this.state.clearAutoScrollTimer();
946
955
  });
947
956
  }
948
957
  handleAutoScroll = () => {
949
958
  afterTick(() => {
950
959
  const viewport = this.content.viewportNode;
951
- const selectedItem = this.content.getSelectedItem().selectedItemNode;
952
- if (!viewport || !selectedItem) {
960
+ const selectedItem = this.root.highlightedNode;
961
+ if (!viewport || !selectedItem)
953
962
  return;
954
- }
955
963
  viewport.scrollTop = viewport.scrollTop + selectedItem.offsetHeight;
956
964
  });
957
965
  };
958
- props = $derived.by(() => ({ ...this.state.props, [SCROLL_DOWN_BUTTON_ATTR]: "" }));
966
+ props = $derived.by(() => ({ ...this.state.props, [this.root.bitsAttrs["scroll-down-button"]]: "" }));
959
967
  }
960
968
  class SelectScrollUpButtonState {
961
969
  state;
962
970
  content;
971
+ root;
963
972
  canScrollUp = $state(false);
964
973
  constructor(state) {
965
974
  this.state = state;
966
975
  this.content = state.content;
976
+ this.root = state.root;
967
977
  this.state.onAutoScroll = this.handleAutoScroll;
968
978
  $effect(() => {
969
- let cleanup = noop;
970
- cleanup();
971
979
  const viewport = this.content.viewportNode;
972
- const isPositioned = this.content.isPositioned.current;
980
+ const isPositioned = this.content.isPositioned;
973
981
  if (!viewport || !isPositioned)
974
982
  return;
975
- const handleScroll = () => {
976
- this.canScrollUp = viewport.scrollTop > 0;
977
- };
978
- handleScroll();
979
- cleanup = addEventListener(viewport, "scroll", handleScroll);
980
- return () => {
981
- cleanup();
982
- };
983
- });
984
- $effect(() => {
985
- if (this.state.mounted.current) {
986
- this.state.alignedPositionState?.handleScrollButtonChange(this.state.id.current);
987
- }
983
+ let cleanup = noop;
984
+ untrack(() => {
985
+ const handleScroll = () => {
986
+ const paddingTop = Number.parseInt(getComputedStyle(viewport).paddingTop, 10);
987
+ this.canScrollUp = viewport.scrollTop - paddingTop > 0;
988
+ };
989
+ handleScroll();
990
+ cleanup = addEventListener(viewport, "scroll", handleScroll);
991
+ });
992
+ return cleanup;
988
993
  });
989
994
  $effect(() => {
990
- if (!this.state.mounted.current) {
991
- this.state.clearAutoScrollTimer();
992
- }
995
+ if (this.state.mounted.current)
996
+ return;
997
+ this.state.clearAutoScrollTimer();
993
998
  });
994
999
  }
995
1000
  handleAutoScroll = () => {
996
1001
  afterTick(() => {
997
1002
  const viewport = this.content.viewportNode;
998
- const selectedItem = this.content.getSelectedItem().selectedItemNode;
1003
+ const selectedItem = this.root.highlightedNode;
999
1004
  if (!viewport || !selectedItem)
1000
1005
  return;
1001
1006
  viewport.scrollTop = viewport.scrollTop - selectedItem.offsetHeight;
1002
1007
  });
1003
1008
  };
1004
- props = $derived.by(() => ({ ...this.state.props, [SCROLL_UP_BUTTON_ATTR]: "" }));
1005
- }
1006
- class SelectGroupState {
1007
- #id;
1008
- #ref;
1009
- labelNode = $state(null);
1010
- constructor(props) {
1011
- this.#id = props.id;
1012
- this.#ref = props.ref;
1013
- useRefById({
1014
- id: this.#id,
1015
- ref: this.#ref,
1016
- });
1017
- }
1018
- props = $derived.by(() => ({
1019
- id: this.#id.current,
1020
- role: "group",
1021
- "aria-labelledby": this.labelNode?.id ?? undefined,
1022
- [GROUP_ATTR]: "",
1023
- }));
1024
- }
1025
- class SelectGroupHeadingState {
1026
- #id;
1027
- #ref;
1028
- group;
1029
- constructor(props, group) {
1030
- this.#ref = props.ref;
1031
- this.#id = props.id;
1032
- this.group = group;
1033
- useRefById({
1034
- id: this.#id,
1035
- ref: this.#ref,
1036
- onRefChange: (node) => {
1037
- this.group.labelNode = node;
1038
- },
1039
- });
1040
- }
1041
- props = $derived.by(() => ({
1042
- id: this.#id.current,
1043
- [GROUP_LABEL_ATTR]: "",
1044
- }));
1045
- }
1046
- class SelectSeparatorState {
1047
- #id;
1048
- #ref;
1049
- constructor(props) {
1050
- this.#id = props.id;
1051
- this.#ref = props.ref;
1052
- useRefById({
1053
- id: this.#id,
1054
- ref: this.#ref,
1055
- });
1056
- }
1057
- props = $derived.by(() => ({
1058
- id: this.#id.current,
1059
- [SEPARATOR_ATTR]: "",
1060
- "aria-hidden": getAriaHidden(true),
1061
- }));
1062
- }
1063
- class SelectArrowState {
1064
- #id;
1065
- #ref;
1066
- constructor(props) {
1067
- this.#id = props.id;
1068
- this.#ref = props.ref;
1069
- useRefById({
1070
- id: this.#id,
1071
- ref: this.#ref,
1072
- });
1073
- }
1074
- props = $derived.by(() => ({
1075
- id: this.#id.current,
1076
- [ARROW_ATTR]: "",
1077
- "aria-hidden": getAriaHidden(true),
1078
- }));
1079
- }
1080
- class SelectIconState {
1081
- #id;
1082
- #ref;
1083
- constructor(props) {
1084
- this.#id = props.id;
1085
- this.#ref = props.ref;
1086
- useRefById({
1087
- id: this.#id,
1088
- ref: this.#ref,
1089
- });
1090
- }
1091
- props = $derived.by(() => ({
1092
- id: this.#id.current,
1093
- [ICON_ATTR]: "",
1094
- "aria-hidden": getAriaHidden(true),
1095
- }));
1009
+ props = $derived.by(() => ({ ...this.state.props, [this.root.bitsAttrs["scroll-up-button"]]: "" }));
1096
1010
  }
1011
+ const [setSelectRootContext, getSelectRootContext] = createContext([
1012
+ "Select.Root",
1013
+ "Combobox.Root",
1014
+ ]);
1015
+ const [setSelectGroupContext, getSelectGroupContext] = createContext([
1016
+ "Select.Group",
1017
+ "Combobox.Group",
1018
+ ]);
1019
+ const [setSelectContentContext, getSelectContentContext] = createContext([
1020
+ "Select.Content",
1021
+ "Combobox.Content",
1022
+ ]);
1097
1023
  export function useSelectRoot(props) {
1098
- return setSelectRootContext(new SelectRootState(props));
1024
+ const { type, ...rest } = props;
1025
+ const rootState = type === "single"
1026
+ ? new SelectSingleRootState(rest)
1027
+ : new SelectMultipleRootState(rest);
1028
+ return setSelectRootContext(rootState);
1099
1029
  }
1100
- export function useSelectContentFrag() {
1101
- return new SelectContentFragState(getSelectRootContext());
1030
+ export function useSelectInput(props) {
1031
+ return new SelectInputState(props, getSelectRootContext());
1102
1032
  }
1103
1033
  export function useSelectContent(props) {
1104
1034
  return setSelectContentContext(new SelectContentState(props, getSelectRootContext()));
1105
1035
  }
1106
- export function useSelectItemAlignedPosition(props) {
1107
- const contentContext = getSelectContentContext();
1108
- const alignedPositionState = new SelectItemAlignedPositionState(props, contentContext);
1109
- contentContext.alignedPositionState = alignedPositionState;
1110
- return setSelectContentItemAlignedContext(alignedPositionState);
1111
- }
1112
- export function useSelectFloatingPosition() {
1113
- return new SelectFloatingPositionState(getSelectContentContext());
1114
- }
1115
1036
  export function useSelectTrigger(props) {
1116
1037
  return new SelectTriggerState(props, getSelectRootContext());
1117
1038
  }
1118
- export function useSelectValue() {
1119
- return new SelectValueState(getSelectRootContext());
1039
+ export function useSelectComboTrigger(props) {
1040
+ return new SelectComboTriggerState(props, getSelectRootContext());
1120
1041
  }
1121
1042
  export function useSelectItem(props) {
1122
- return setSelectItemContext(new SelectItemState(props, getSelectContentContext()));
1123
- }
1124
- export function useSelectItemText(props) {
1125
- return new SelectItemTextState(props, getSelectItemContext());
1043
+ return new SelectItemState(props, getSelectRootContext());
1126
1044
  }
1127
1045
  export function useSelectViewport(props) {
1128
1046
  return new SelectViewportState(props, getSelectContentContext());
1129
1047
  }
1130
1048
  export function useSelectScrollUpButton(props) {
1131
- const state = new SelectScrollButtonImplState(props, getSelectContentContext());
1132
- return new SelectScrollUpButtonState(state);
1049
+ return new SelectScrollUpButtonState(new SelectScrollButtonImplState(props, getSelectContentContext()));
1133
1050
  }
1134
1051
  export function useSelectScrollDownButton(props) {
1135
- const state = new SelectScrollButtonImplState(props, getSelectContentContext());
1136
- return new SelectScrollDownButtonState(state);
1052
+ return new SelectScrollDownButtonState(new SelectScrollButtonImplState(props, getSelectContentContext()));
1137
1053
  }
1138
1054
  export function useSelectGroup(props) {
1139
- return setSelectGroupContext(new SelectGroupState(props));
1055
+ return setSelectGroupContext(new SelectGroupState(props, getSelectRootContext()));
1140
1056
  }
1141
1057
  export function useSelectGroupHeading(props) {
1142
1058
  return new SelectGroupHeadingState(props, getSelectGroupContext());
1143
1059
  }
1144
- export function useSelectArrow(props) {
1145
- return new SelectArrowState(props);
1146
- }
1147
- export function useSelectSeparator(props) {
1148
- return new SelectSeparatorState(props);
1060
+ export function useSelectHiddenInput(props) {
1061
+ return new SelectHiddenInputState(props, getSelectRootContext());
1149
1062
  }
1150
- export function useSelectIcon(props) {
1151
- return new SelectIconState(props);
1152
- }
1153
- //
1154
- export function shouldShowPlaceholder(value) {
1155
- return value === "" || value === undefined;
1063
+ ////////////////////////////////////
1064
+ // Helpers
1065
+ ////////////////////////////////////
1066
+ const selectParts = [
1067
+ "trigger",
1068
+ "content",
1069
+ "item",
1070
+ "viewport",
1071
+ "scroll-up-button",
1072
+ "scroll-down-button",
1073
+ "group",
1074
+ "group-label",
1075
+ "separator",
1076
+ "arrow",
1077
+ "input",
1078
+ "content-wrapper",
1079
+ "item-text",
1080
+ "value",
1081
+ ];
1082
+ export function getSelectBitsAttrs(root) {
1083
+ const isCombobox = root.isCombobox;
1084
+ const attrObj = {};
1085
+ for (const part of selectParts) {
1086
+ attrObj[part] = isCombobox ? `data-combobox-${part}` : `data-select-${part}`;
1087
+ }
1088
+ return attrObj;
1156
1089
  }