bits-ui 1.0.0-next.82 → 1.0.0-next.83

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/app.d.ts CHANGED
@@ -15,4 +15,5 @@ declare global {
15
15
  readonly map: SvelteMap<string, boolean>;
16
16
  resetBodyStyle: () => void;
17
17
  };
18
+ var bitsAnimationsDisabled: boolean;
18
19
  }
@@ -22,8 +22,7 @@ declare class PinInputRootState {
22
22
  #private;
23
23
  value: PinInputRootStateProps["value"];
24
24
  constructor(props: PinInputRootStateProps);
25
- keysToIgnore: string[];
26
- onkeydown(e: BitsKeyboardEvent): void;
25
+ onkeydown: (e: BitsKeyboardEvent) => void;
27
26
  rootProps: {
28
27
  readonly id: string;
29
28
  readonly "data-pin-input-root": "";
@@ -42,7 +41,7 @@ declare class PinInputRootState {
42
41
  readonly pointerEvents: "none";
43
42
  };
44
43
  };
45
- oninput(e: BitsEvent<Event, HTMLInputElement>): void;
44
+ oninput: (e: BitsEvent<Event, HTMLInputElement>) => void;
46
45
  onfocus: (_: BitsFocusEvent<HTMLInputElement>) => void;
47
46
  onpaste: (e: BitsEvent<ClipboardEvent>) => void;
48
47
  onmouseover: (_: BitsMouseEvent) => void;
@@ -77,7 +76,6 @@ declare class PinInputRootState {
77
76
  "data-pin-input-input-mss": number | null;
78
77
  "data-pin-input-input-mse": number | null;
79
78
  inputmode: "none" | "search" | "text" | "email" | "tel" | "url" | "numeric" | "decimal" | null | undefined;
80
- pattern: any;
81
79
  maxlength: number;
82
80
  value: string;
83
81
  disabled: true | undefined;
@@ -10,6 +10,22 @@ export const REGEXP_ONLY_CHARS = "^[a-zA-Z]+$";
10
10
  export const REGEXP_ONLY_DIGITS_AND_CHARS = "^[a-zA-Z0-9]+$";
11
11
  const ROOT_ATTR = "data-pin-input-root";
12
12
  const CELL_ATTR = "data-pin-input-cell";
13
+ const KEYS_TO_IGNORE = [
14
+ "Backspace",
15
+ "Delete",
16
+ "ArrowLeft",
17
+ "ArrowRight",
18
+ "ArrowUp",
19
+ "ArrowDown",
20
+ "Home",
21
+ "End",
22
+ "Escape",
23
+ "Enter",
24
+ "Tab",
25
+ "Shift",
26
+ "Control",
27
+ "Meta",
28
+ ];
13
29
  class PinInputRootState {
14
30
  #id;
15
31
  #ref;
@@ -146,38 +162,19 @@ class PinInputRootState {
146
162
  onComplete(value);
147
163
  }
148
164
  });
149
- this.onkeydown = this.onkeydown.bind(this);
150
- this.oninput = this.oninput.bind(this);
151
- this.onfocus = this.onfocus.bind(this);
152
- this.onmouseover = this.onmouseover.bind(this);
153
- this.onmouseleave = this.onmouseleave.bind(this);
154
- this.onblur = this.onblur.bind(this);
155
- this.onpaste = this.onpaste.bind(this);
156
165
  }
157
- keysToIgnore = [
158
- "Backspace",
159
- "Delete",
160
- "ArrowLeft",
161
- "ArrowRight",
162
- "ArrowUp",
163
- "ArrowDown",
164
- "Home",
165
- "End",
166
- "Escape",
167
- "Enter",
168
- "Tab",
169
- "Shift",
170
- "Control",
171
- "Meta",
172
- ];
173
- onkeydown(e) {
166
+ onkeydown = (e) => {
174
167
  const key = e.key;
175
- if (this.keysToIgnore.includes(key))
168
+ if (KEYS_TO_IGNORE.includes(key))
169
+ return;
170
+ // if ctrl or cmd is pressed, they are likely to be shortcuts and should not be tested
171
+ // against the regex
172
+ if (e.ctrlKey || e.metaKey)
176
173
  return;
177
174
  if (key && this.#regexPattern && !this.#regexPattern.test(key)) {
178
175
  e.preventDefault();
179
176
  }
180
- }
177
+ };
181
178
  #rootStyles = $derived.by(() => ({
182
179
  position: "relative",
183
180
  cursor: this.#disabled.current ? "default" : "text",
@@ -297,7 +294,7 @@ class PinInputRootState {
297
294
  this.#mirrorSelectionEnd = e;
298
295
  this.#prevInputMetadata.prev = [s, e, dir];
299
296
  };
300
- oninput(e) {
297
+ oninput = (e) => {
301
298
  const newValue = e.currentTarget.value.slice(0, this.#maxLength.current);
302
299
  if (newValue.length > 0 && this.#regexPattern && !this.#regexPattern.test(newValue)) {
303
300
  e.preventDefault();
@@ -313,7 +310,7 @@ class PinInputRootState {
313
310
  document.dispatchEvent(new Event("selectionchange"));
314
311
  }
315
312
  this.value.current = newValue;
316
- }
313
+ };
317
314
  onfocus = (_) => {
318
315
  const input = this.#inputRef.current;
319
316
  if (input) {
@@ -389,7 +386,7 @@ class PinInputRootState {
389
386
  "data-pin-input-input-mss": this.#mirrorSelectionStart,
390
387
  "data-pin-input-input-mse": this.#mirrorSelectionEnd,
391
388
  inputmode: this.#inputmode.current,
392
- pattern: this.#regexPattern?.source,
389
+ // pattern: this.#regexPattern?.source,
393
390
  maxlength: this.#maxLength.current,
394
391
  value: this.value.current,
395
392
  disabled: getDisabled(this.#disabled.current),
@@ -13,22 +13,19 @@
13
13
  ...restProps
14
14
  }: SelectScrollDownButtonProps = $props();
15
15
 
16
- let mounted = $state(false);
17
-
18
- const scrollDownButtonState = useSelectScrollDownButton({
16
+ const scrollButtonState = useSelectScrollDownButton({
19
17
  id: box.with(() => id),
20
- mounted: box.with(() => mounted),
21
18
  ref: box.with(
22
19
  () => ref,
23
20
  (v) => (ref = v)
24
21
  ),
25
22
  });
26
23
 
27
- const mergedProps = $derived(mergeProps(restProps, scrollDownButtonState.props));
24
+ const mergedProps = $derived(mergeProps(restProps, scrollButtonState.props));
28
25
  </script>
29
26
 
30
- {#if scrollDownButtonState.canScrollDown}
31
- <Mounted onMountedChange={(m) => (mounted = m)} />
27
+ {#if scrollButtonState.canScrollDown}
28
+ <Mounted onMountedChange={(v) => (scrollButtonState.state.mounted = v)} />
32
29
  {#if child}
33
30
  {@render child({ props: restProps })}
34
31
  {:else}
@@ -13,22 +13,19 @@
13
13
  ...restProps
14
14
  }: SelectScrollUpButtonProps = $props();
15
15
 
16
- let mounted = $state(false);
17
-
18
- const scrollDownButtonState = useSelectScrollUpButton({
16
+ const scrollButtonState = useSelectScrollUpButton({
19
17
  id: box.with(() => id),
20
- mounted: box.with(() => mounted),
21
18
  ref: box.with(
22
19
  () => ref,
23
20
  (v) => (ref = v)
24
21
  ),
25
22
  });
26
23
 
27
- const mergedProps = $derived(mergeProps(restProps, scrollDownButtonState.props));
24
+ const mergedProps = $derived(mergeProps(restProps, scrollButtonState.props));
28
25
  </script>
29
26
 
30
- {#if scrollDownButtonState.canScrollUp}
31
- <Mounted onMountedChange={(m) => (mounted = m)} />
27
+ {#if scrollButtonState.canScrollUp}
28
+ <Mounted onMountedChange={(v) => (scrollButtonState.state.mounted = v)} />
32
29
  {#if child}
33
30
  {@render child({ props: restProps })}
34
31
  {:else}
@@ -46,12 +46,8 @@ declare class SelectBaseRootState {
46
46
  isUsingKeyboard: boolean;
47
47
  isCombobox: boolean;
48
48
  bitsAttrs: SelectBitsAttrs;
49
- triggerPointerDownPos: {
50
- x: number;
51
- y: number;
52
- } | null;
53
49
  constructor(props: SelectBaseRootStateProps);
54
- setHighlightedNode(node: HTMLElement | null): void;
50
+ setHighlightedNode(node: HTMLElement | null, initial?: boolean): void;
55
51
  getCandidateNodes(): HTMLElement[];
56
52
  setHighlightedToFirstCandidate(): void;
57
53
  getNodeByValue(value: string): HTMLElement | null;
@@ -143,8 +139,8 @@ declare class SelectTriggerState {
143
139
  * `pointerdown` fires before the `focus` event, so we can prevent the default
144
140
  * behavior of focusing the button and keep focus on the input.
145
141
  */
146
- onpointerdown: (e: BitsPointerEvent) => void;
147
- onpointerup: (e: BitsPointerEvent) => void;
142
+ onpointerdown(e: BitsPointerEvent): void;
143
+ onpointerup(e: BitsPointerEvent): void;
148
144
  props: {
149
145
  readonly [x: string]: string | true | ((e: BitsKeyboardEvent) => void) | ((e: BitsPointerEvent) => void) | undefined;
150
146
  readonly id: string;
@@ -264,7 +260,6 @@ declare class SelectItemState {
264
260
  isSelected: boolean;
265
261
  isHighlighted: boolean;
266
262
  prevHighlighted: Previous<boolean>;
267
- textId: string;
268
263
  mounted: boolean;
269
264
  constructor(props: SelectItemStateProps, root: SelectRootState);
270
265
  snippetProps: {
@@ -358,19 +353,20 @@ declare class SelectViewportState {
358
353
  };
359
354
  };
360
355
  }
361
- type SelectScrollButtonImplStateProps = WithRefProps<ReadableBoxedValues<{
362
- mounted: boolean;
363
- }>>;
356
+ type SelectScrollButtonImplStateProps = WithRefProps;
364
357
  declare class SelectScrollButtonImplState {
365
358
  id: SelectScrollButtonImplStateProps["id"];
366
359
  ref: SelectScrollButtonImplStateProps["ref"];
367
360
  content: SelectContentState;
368
361
  root: SelectBaseRootState;
369
- autoScrollTimer: number | null;
362
+ autoScrollInterval: number | null;
363
+ userScrollTimer: number;
364
+ isUserScrolling: boolean;
370
365
  onAutoScroll: () => void;
371
- mounted: SelectScrollButtonImplStateProps["mounted"];
366
+ mounted: boolean;
372
367
  constructor(props: SelectScrollButtonImplStateProps, content: SelectContentState);
373
- clearAutoScrollTimer(): void;
368
+ handleUserScroll(): void;
369
+ clearAutoScrollInterval(): void;
374
370
  onpointerdown(_: BitsPointerEvent): void;
375
371
  onpointermove(_: BitsPointerEvent): void;
376
372
  onpointerleave(_: BitsPointerEvent): void;
@@ -391,6 +387,11 @@ declare class SelectScrollDownButtonState {
391
387
  root: SelectBaseRootState;
392
388
  canScrollDown: boolean;
393
389
  constructor(state: SelectScrollButtonImplState);
390
+ /**
391
+ * @param manual - if true, it means the function was invoked manually outside of an event
392
+ * listener, so we don't call `handleUserScroll` to prevent the auto scroll from kicking in.
393
+ */
394
+ handleScroll: (manual?: boolean) => void;
394
395
  handleAutoScroll: () => void;
395
396
  props: {
396
397
  readonly id: string;
@@ -409,6 +410,11 @@ declare class SelectScrollUpButtonState {
409
410
  root: SelectBaseRootState;
410
411
  canScrollUp: boolean;
411
412
  constructor(state: SelectScrollButtonImplState);
413
+ /**
414
+ * @param manual - if true, it means the function was invoked manually outside of an event
415
+ * listener, so we don't call `handleUserScroll` to prevent the auto scroll from kicking in.
416
+ */
417
+ handleScroll: (manual?: boolean) => void;
412
418
  handleAutoScroll: () => void;
413
419
  props: {
414
420
  readonly id: string;
@@ -45,10 +45,9 @@ class SelectBaseRootState {
45
45
  return null;
46
46
  return this.highlightedNode.getAttribute("data-label");
47
47
  });
48
- isUsingKeyboard = $state(false);
49
- isCombobox = $state(false);
48
+ isUsingKeyboard = false;
49
+ isCombobox = false;
50
50
  bitsAttrs;
51
- triggerPointerDownPos = $state.raw({ x: 0, y: 0 });
52
51
  constructor(props) {
53
52
  this.disabled = props.disabled;
54
53
  this.required = props.required;
@@ -66,12 +65,10 @@ class SelectBaseRootState {
66
65
  }
67
66
  });
68
67
  }
69
- setHighlightedNode(node) {
68
+ setHighlightedNode(node, initial = false) {
70
69
  this.highlightedNode = node;
71
- if (node) {
72
- if (this.isUsingKeyboard) {
73
- node.scrollIntoView({ block: "nearest" });
74
- }
70
+ if (node && (this.isUsingKeyboard || initial)) {
71
+ node.scrollIntoView({ block: "nearest" });
75
72
  }
76
73
  }
77
74
  getCandidateNodes() {
@@ -140,12 +137,10 @@ class SelectSingleRootState extends SelectBaseRootState {
140
137
  this.setHighlightedNode(null);
141
138
  }
142
139
  });
143
- watch(() => this.open.current, (isOpen) => {
144
- if (!isOpen)
140
+ watch(() => this.open.current, () => {
141
+ if (!this.open.current)
145
142
  return;
146
- afterTick(() => {
147
- this.setInitialHighlightedNode();
148
- });
143
+ this.setInitialHighlightedNode();
149
144
  });
150
145
  }
151
146
  includesItem(itemValue) {
@@ -156,20 +151,22 @@ class SelectSingleRootState extends SelectBaseRootState {
156
151
  this.inputValue = itemLabel;
157
152
  }
158
153
  setInitialHighlightedNode() {
159
- if (this.highlightedNode && document.contains(this.highlightedNode))
160
- return;
161
- if (this.value.current !== "") {
162
- const node = this.getNodeByValue(this.value.current);
163
- if (node) {
164
- this.setHighlightedNode(node);
154
+ afterTick(() => {
155
+ if (this.highlightedNode && document.contains(this.highlightedNode))
165
156
  return;
157
+ if (this.value.current !== "") {
158
+ const node = this.getNodeByValue(this.value.current);
159
+ if (node) {
160
+ this.setHighlightedNode(node, true);
161
+ return;
162
+ }
166
163
  }
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);
164
+ // if no value is set, we want to highlight the first item
165
+ const firstCandidate = this.getCandidateNodes()[0];
166
+ if (!firstCandidate)
167
+ return;
168
+ this.setHighlightedNode(firstCandidate, true);
169
+ });
173
170
  }
174
171
  }
175
172
  class SelectMultipleRootState extends SelectBaseRootState {
@@ -179,14 +176,10 @@ class SelectMultipleRootState extends SelectBaseRootState {
179
176
  constructor(props) {
180
177
  super(props);
181
178
  this.value = props.value;
182
- watch(() => this.open.current, (isOpen) => {
183
- if (!isOpen)
179
+ watch(() => this.open.current, () => {
180
+ if (!this.open.current)
184
181
  return;
185
- afterTick(() => {
186
- if (!this.highlightedNode) {
187
- this.setInitialHighlightedNode();
188
- }
189
- });
182
+ this.setInitialHighlightedNode();
190
183
  });
191
184
  }
192
185
  includesItem(itemValue) {
@@ -202,20 +195,22 @@ class SelectMultipleRootState extends SelectBaseRootState {
202
195
  this.inputValue = itemLabel;
203
196
  }
204
197
  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);
198
+ afterTick(() => {
199
+ if (this.highlightedNode && document.contains(this.highlightedNode))
211
200
  return;
201
+ if (this.value.current.length && this.value.current[0] !== "") {
202
+ const node = this.getNodeByValue(this.value.current[0]);
203
+ if (node) {
204
+ this.setHighlightedNode(node, true);
205
+ return;
206
+ }
212
207
  }
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);
208
+ // if no value is set, we want to highlight the first item
209
+ const firstCandidate = this.getCandidateNodes()[0];
210
+ if (!firstCandidate)
211
+ return;
212
+ this.setHighlightedNode(firstCandidate, true);
213
+ });
219
214
  }
220
215
  }
221
216
  class SelectInputState {
@@ -240,36 +235,32 @@ class SelectInputState {
240
235
  this.root.isUsingKeyboard = true;
241
236
  if (e.key === kbd.ESCAPE)
242
237
  return;
243
- const open = this.root.open.current;
244
- const inputValue = this.root.inputValue;
245
238
  // prevent arrow up/down from moving the position of the cursor in the input
246
239
  if (e.key === kbd.ARROW_UP || e.key === kbd.ARROW_DOWN)
247
240
  e.preventDefault();
248
- if (!open) {
241
+ if (!this.root.open.current) {
249
242
  if (INTERACTION_KEYS.includes(e.key))
250
243
  return;
251
244
  if (e.key === kbd.TAB)
252
245
  return;
253
- if (e.key === kbd.BACKSPACE && inputValue === "")
246
+ if (e.key === kbd.BACKSPACE && this.root.inputValue === "")
254
247
  return;
255
248
  this.root.handleOpen();
256
249
  // we need to wait for a tick after the menu opens to ensure the highlighted nodes are
257
250
  // set correctly.
258
- afterTick(() => {
259
- if (this.root.hasValue)
260
- return;
261
- const candidateNodes = this.root.getCandidateNodes();
262
- if (!candidateNodes.length)
263
- return;
264
- if (e.key === kbd.ARROW_DOWN) {
265
- const firstCandidate = candidateNodes[0];
266
- this.root.setHighlightedNode(firstCandidate);
267
- }
268
- else if (e.key === kbd.ARROW_UP) {
269
- const lastCandidate = candidateNodes[candidateNodes.length - 1];
270
- this.root.setHighlightedNode(lastCandidate);
271
- }
272
- });
251
+ if (this.root.hasValue)
252
+ return;
253
+ const candidateNodes = this.root.getCandidateNodes();
254
+ if (!candidateNodes.length)
255
+ return;
256
+ if (e.key === kbd.ARROW_DOWN) {
257
+ const firstCandidate = candidateNodes[0];
258
+ this.root.setHighlightedNode(firstCandidate);
259
+ }
260
+ else if (e.key === kbd.ARROW_UP) {
261
+ const lastCandidate = candidateNodes[candidateNodes.length - 1];
262
+ this.root.setHighlightedNode(lastCandidate);
263
+ }
273
264
  return;
274
265
  }
275
266
  if (e.key === kbd.TAB) {
@@ -278,14 +269,13 @@ class SelectInputState {
278
269
  }
279
270
  if (e.key === kbd.ENTER && !e.isComposing) {
280
271
  e.preventDefault();
281
- const highlightedValue = this.root.highlightedValue;
282
- const isCurrentSelectedValue = highlightedValue === this.root.value.current;
272
+ const isCurrentSelectedValue = this.root.highlightedValue === this.root.value.current;
283
273
  if (!this.root.allowDeselect.current && isCurrentSelectedValue && !this.root.isMulti) {
284
274
  this.root.handleClose();
285
275
  return;
286
276
  }
287
- if (highlightedValue) {
288
- this.root.toggleItem(highlightedValue, this.root.highlightedLabel ?? undefined);
277
+ if (this.root.highlightedValue) {
278
+ this.root.toggleItem(this.root.highlightedValue, this.root.highlightedLabel ?? undefined);
289
279
  }
290
280
  if (!this.root.isMulti && !isCurrentSelectedValue) {
291
281
  this.root.handleClose();
@@ -331,13 +321,10 @@ class SelectInputState {
331
321
  if (!this.root.highlightedNode) {
332
322
  this.root.setHighlightedToFirstCandidate();
333
323
  }
334
- // this.root.setHighlightedToFirstCandidate();
335
324
  }
336
325
  oninput(e) {
337
326
  this.root.inputValue = e.currentTarget.value;
338
- afterTick(() => {
339
- this.root.setHighlightedToFirstCandidate();
340
- });
327
+ this.root.setHighlightedToFirstCandidate();
341
328
  }
342
329
  props = $derived.by(() => ({
343
330
  id: this.#id.current,
@@ -452,12 +439,8 @@ class SelectTriggerState {
452
439
  this.#dataTypeahead.resetTypeahead();
453
440
  this.#domTypeahead.resetTypeahead();
454
441
  }
455
- #handlePointerOpen(e) {
442
+ #handlePointerOpen(_) {
456
443
  this.#handleOpen();
457
- this.root.triggerPointerDownPos = {
458
- x: Math.round(e.pageX),
459
- y: Math.round(e.pageY),
460
- };
461
444
  }
462
445
  onkeydown(e) {
463
446
  this.root.isUsingKeyboard = true;
@@ -477,21 +460,19 @@ class SelectTriggerState {
477
460
  }
478
461
  // we need to wait for a tick after the menu opens to ensure
479
462
  // the highlighted nodes are set correctly
480
- afterTick(() => {
481
- if (this.root.hasValue)
482
- return;
483
- const candidateNodes = this.root.getCandidateNodes();
484
- if (!candidateNodes.length)
485
- return;
486
- if (e.key === kbd.ARROW_DOWN) {
487
- const firstCandidate = candidateNodes[0];
488
- this.root.setHighlightedNode(firstCandidate);
489
- }
490
- else if (e.key === kbd.ARROW_UP) {
491
- const lastCandidate = candidateNodes[candidateNodes.length - 1];
492
- this.root.setHighlightedNode(lastCandidate);
493
- }
494
- });
463
+ if (this.root.hasValue)
464
+ return;
465
+ const candidateNodes = this.root.getCandidateNodes();
466
+ if (!candidateNodes.length)
467
+ return;
468
+ if (e.key === kbd.ARROW_DOWN) {
469
+ const firstCandidate = candidateNodes[0];
470
+ this.root.setHighlightedNode(firstCandidate);
471
+ }
472
+ else if (e.key === kbd.ARROW_UP) {
473
+ const lastCandidate = candidateNodes[candidateNodes.length - 1];
474
+ this.root.setHighlightedNode(lastCandidate);
475
+ }
495
476
  return;
496
477
  }
497
478
  if (e.key === kbd.TAB) {
@@ -500,15 +481,14 @@ class SelectTriggerState {
500
481
  }
501
482
  if ((e.key === kbd.ENTER || e.key === kbd.SPACE) && !e.isComposing) {
502
483
  e.preventDefault();
503
- const highlightedValue = this.root.highlightedValue;
504
- const isCurrentSelectedValue = highlightedValue === this.root.value.current;
484
+ const isCurrentSelectedValue = this.root.highlightedValue === this.root.value.current;
505
485
  if (!this.root.allowDeselect.current && isCurrentSelectedValue && !this.root.isMulti) {
506
486
  this.root.handleClose();
507
487
  return;
508
488
  }
509
489
  //"" is a valid value for a select item so we need to check for that
510
- if (highlightedValue !== null) {
511
- this.root.toggleItem(highlightedValue, this.root.highlightedLabel ?? undefined);
490
+ if (this.root.highlightedValue !== null) {
491
+ this.root.toggleItem(this.root.highlightedValue, this.root.highlightedLabel ?? undefined);
512
492
  }
513
493
  if (!this.root.isMulti && !isCurrentSelectedValue) {
514
494
  this.root.handleClose();
@@ -578,7 +558,7 @@ class SelectTriggerState {
578
558
  * `pointerdown` fires before the `focus` event, so we can prevent the default
579
559
  * behavior of focusing the button and keep focus on the input.
580
560
  */
581
- onpointerdown = (e) => {
561
+ onpointerdown(e) {
582
562
  if (this.root.disabled.current)
583
563
  return;
584
564
  // prevent opening on touch down which can be triggered when scrolling on touch devices
@@ -600,13 +580,13 @@ class SelectTriggerState {
600
580
  this.root.handleClose();
601
581
  }
602
582
  }
603
- };
604
- onpointerup = (e) => {
583
+ }
584
+ onpointerup(e) {
605
585
  e.preventDefault();
606
586
  if (e.pointerType === "touch") {
607
587
  this.#handlePointerOpen(e);
608
588
  }
609
- };
589
+ }
610
590
  props = $derived.by(() => ({
611
591
  id: this.#id.current,
612
592
  disabled: this.root.disabled.current ? true : undefined,
@@ -639,9 +619,12 @@ class SelectContentState {
639
619
  },
640
620
  deps: () => this.root.open.current,
641
621
  });
642
- onDestroyEffect(() => (this.root.contentNode = null));
643
- watch(() => this.root.open.current, (isOpen) => {
644
- if (isOpen)
622
+ onDestroyEffect(() => {
623
+ this.root.contentNode = null;
624
+ this.isPositioned = false;
625
+ });
626
+ watch(() => this.root.open.current, () => {
627
+ if (this.root.open.current)
645
628
  return;
646
629
  this.isPositioned = false;
647
630
  });
@@ -705,7 +688,6 @@ class SelectItemState {
705
688
  isSelected = $derived.by(() => this.root.includesItem(this.value.current));
706
689
  isHighlighted = $derived.by(() => this.root.highlightedValue === this.value.current);
707
690
  prevHighlighted = new Previous(() => this.isHighlighted);
708
- textId = $state("");
709
691
  mounted = $state(false);
710
692
  constructor(props, root) {
711
693
  this.root = root;
@@ -716,7 +698,12 @@ class SelectItemState {
716
698
  this.onUnhighlight = props.onUnhighlight;
717
699
  this.#id = props.id;
718
700
  this.#ref = props.ref;
719
- $effect(() => {
701
+ useRefById({
702
+ id: this.#id,
703
+ ref: this.#ref,
704
+ deps: () => this.mounted,
705
+ });
706
+ watch([() => this.isHighlighted, () => this.prevHighlighted.current], () => {
720
707
  if (this.isHighlighted) {
721
708
  this.onHighlight.current();
722
709
  }
@@ -724,12 +711,8 @@ class SelectItemState {
724
711
  this.onUnhighlight.current();
725
712
  }
726
713
  });
727
- useRefById({
728
- id: this.#id,
729
- ref: this.#ref,
730
- });
731
- watch(() => this.mounted, (isMounted) => {
732
- if (!isMounted)
714
+ watch(() => this.mounted, () => {
715
+ if (!this.mounted)
733
716
  return;
734
717
  this.root.setInitialHighlightedNode();
735
718
  });
@@ -899,52 +882,69 @@ class SelectScrollButtonImplState {
899
882
  ref;
900
883
  content;
901
884
  root;
902
- autoScrollTimer = $state(null);
885
+ autoScrollInterval = null;
886
+ userScrollTimer = -1;
887
+ isUserScrolling = false;
903
888
  onAutoScroll = noop;
904
- mounted;
889
+ mounted = $state(false);
905
890
  constructor(props, content) {
906
891
  this.ref = props.ref;
907
892
  this.id = props.id;
908
- this.mounted = props.mounted;
909
893
  this.content = content;
910
894
  this.root = content.root;
911
895
  useRefById({
912
896
  id: this.id,
913
897
  ref: this.ref,
914
- deps: () => this.mounted.current,
898
+ deps: () => this.mounted,
915
899
  });
916
- watch(() => this.mounted.current, (isMounted) => {
917
- if (!isMounted)
900
+ watch(() => this.mounted, () => {
901
+ if (!this.mounted) {
902
+ this.isUserScrolling = false;
903
+ return;
904
+ }
905
+ if (this.isUserScrolling)
918
906
  return;
919
907
  const activeItem = this.root.highlightedNode;
920
908
  activeItem?.scrollIntoView({ block: "nearest" });
921
909
  });
910
+ $effect(() => {
911
+ if (this.mounted)
912
+ return;
913
+ this.clearAutoScrollInterval();
914
+ });
922
915
  this.onpointerdown = this.onpointerdown.bind(this);
923
916
  this.onpointermove = this.onpointermove.bind(this);
924
917
  this.onpointerleave = this.onpointerleave.bind(this);
925
918
  }
926
- clearAutoScrollTimer() {
927
- if (this.autoScrollTimer === null)
919
+ handleUserScroll() {
920
+ window.clearTimeout(this.userScrollTimer);
921
+ this.isUserScrolling = true;
922
+ this.userScrollTimer = window.setTimeout(() => {
923
+ this.isUserScrolling = false;
924
+ }, 200);
925
+ }
926
+ clearAutoScrollInterval() {
927
+ if (this.autoScrollInterval === null)
928
928
  return;
929
- window.clearInterval(this.autoScrollTimer);
930
- this.autoScrollTimer = null;
929
+ window.clearInterval(this.autoScrollInterval);
930
+ this.autoScrollInterval = null;
931
931
  }
932
932
  onpointerdown(_) {
933
- if (this.autoScrollTimer !== null)
933
+ if (this.autoScrollInterval !== null)
934
934
  return;
935
- this.autoScrollTimer = window.setInterval(() => {
935
+ this.autoScrollInterval = window.setInterval(() => {
936
936
  this.onAutoScroll();
937
937
  }, 50);
938
938
  }
939
939
  onpointermove(_) {
940
- if (this.autoScrollTimer !== null)
940
+ if (this.autoScrollInterval !== null)
941
941
  return;
942
- this.autoScrollTimer = window.setInterval(() => {
942
+ this.autoScrollInterval = window.setInterval(() => {
943
943
  this.onAutoScroll();
944
944
  }, 50);
945
945
  }
946
946
  onpointerleave(_) {
947
- this.clearAutoScrollTimer();
947
+ this.clearAutoScrollInterval();
948
948
  }
949
949
  props = $derived.by(() => ({
950
950
  id: this.id.current,
@@ -967,34 +967,41 @@ class SelectScrollDownButtonState {
967
967
  this.content = state.content;
968
968
  this.root = state.root;
969
969
  this.state.onAutoScroll = this.handleAutoScroll;
970
- watch([() => this.content.viewportNode, () => this.content.isPositioned], ([viewportNode, isPositioned]) => {
971
- if (!viewportNode || !isPositioned)
972
- return;
973
- const handleScroll = () => {
974
- afterTick(() => {
975
- const maxScroll = viewportNode.scrollHeight - viewportNode.clientHeight;
976
- const paddingTop = Number.parseInt(getComputedStyle(viewportNode).paddingTop, 10);
977
- this.canScrollDown =
978
- Math.ceil(viewportNode.scrollTop) < maxScroll - paddingTop;
979
- });
980
- };
981
- handleScroll();
982
- return on(viewportNode, "scroll", handleScroll);
983
- });
984
- $effect(() => {
985
- if (this.state.mounted.current)
970
+ watch([
971
+ () => this.content.viewportNode,
972
+ () => this.content.isPositioned,
973
+ () => this.root.open.current,
974
+ ], () => {
975
+ if (!this.content.viewportNode ||
976
+ !this.content.isPositioned ||
977
+ !this.root.open.current) {
986
978
  return;
987
- this.state.clearAutoScrollTimer();
979
+ }
980
+ this.handleScroll(true);
981
+ return on(this.content.viewportNode, "scroll", () => this.handleScroll());
988
982
  });
989
983
  }
984
+ /**
985
+ * @param manual - if true, it means the function was invoked manually outside of an event
986
+ * listener, so we don't call `handleUserScroll` to prevent the auto scroll from kicking in.
987
+ */
988
+ handleScroll = (manual = false) => {
989
+ if (!manual) {
990
+ this.state.handleUserScroll();
991
+ }
992
+ if (!this.content.viewportNode)
993
+ return;
994
+ const maxScroll = this.content.viewportNode.scrollHeight - this.content.viewportNode.clientHeight;
995
+ const paddingTop = Number.parseInt(getComputedStyle(this.content.viewportNode).paddingTop, 10);
996
+ this.canScrollDown =
997
+ Math.ceil(this.content.viewportNode.scrollTop) < maxScroll - paddingTop;
998
+ };
990
999
  handleAutoScroll = () => {
991
- afterTick(() => {
992
- const viewport = this.content.viewportNode;
993
- const selectedItem = this.root.highlightedNode;
994
- if (!viewport || !selectedItem)
995
- return;
996
- viewport.scrollTop = viewport.scrollTop + selectedItem.offsetHeight;
997
- });
1000
+ const viewport = this.content.viewportNode;
1001
+ const selectedItem = this.root.highlightedNode;
1002
+ if (!viewport || !selectedItem)
1003
+ return;
1004
+ viewport.scrollTop = viewport.scrollTop + selectedItem.offsetHeight;
998
1005
  };
999
1006
  props = $derived.by(() => ({ ...this.state.props, [this.root.bitsAttrs["scroll-down-button"]]: "" }));
1000
1007
  }
@@ -1008,30 +1015,31 @@ class SelectScrollUpButtonState {
1008
1015
  this.content = state.content;
1009
1016
  this.root = state.root;
1010
1017
  this.state.onAutoScroll = this.handleAutoScroll;
1011
- watch([() => this.content.viewportNode, () => this.content.isPositioned], ([viewportNode, isPositioned]) => {
1012
- if (!viewportNode || !isPositioned)
1018
+ watch([() => this.content.viewportNode, () => this.content.isPositioned], () => {
1019
+ if (!this.content.viewportNode || !this.content.isPositioned)
1013
1020
  return;
1014
- const handleScroll = () => {
1015
- const paddingTop = Number.parseInt(getComputedStyle(viewportNode).paddingTop, 10);
1016
- this.canScrollUp = viewportNode.scrollTop - paddingTop > 0;
1017
- };
1018
- handleScroll();
1019
- return on(viewportNode, "scroll", handleScroll);
1020
- });
1021
- $effect(() => {
1022
- if (this.state.mounted.current)
1023
- return;
1024
- this.state.clearAutoScrollTimer();
1021
+ this.handleScroll(true);
1022
+ return on(this.content.viewportNode, "scroll", () => this.handleScroll());
1025
1023
  });
1026
1024
  }
1025
+ /**
1026
+ * @param manual - if true, it means the function was invoked manually outside of an event
1027
+ * listener, so we don't call `handleUserScroll` to prevent the auto scroll from kicking in.
1028
+ */
1029
+ handleScroll = (manual = false) => {
1030
+ if (!manual) {
1031
+ this.state.handleUserScroll();
1032
+ }
1033
+ if (!this.content.viewportNode)
1034
+ return;
1035
+ const paddingTop = Number.parseInt(getComputedStyle(this.content.viewportNode).paddingTop, 10);
1036
+ this.canScrollUp = this.content.viewportNode.scrollTop - paddingTop > 0.1;
1037
+ };
1027
1038
  handleAutoScroll = () => {
1028
- afterTick(() => {
1029
- const viewport = this.content.viewportNode;
1030
- const selectedItem = this.root.highlightedNode;
1031
- if (!viewport || !selectedItem)
1032
- return;
1033
- viewport.scrollTop = viewport.scrollTop - selectedItem.offsetHeight;
1034
- });
1039
+ if (!this.content.viewportNode || !this.root.highlightedNode)
1040
+ return;
1041
+ this.content.viewportNode.scrollTop =
1042
+ this.content.viewportNode.scrollTop - this.root.highlightedNode.offsetHeight;
1035
1043
  };
1036
1044
  props = $derived.by(() => ({ ...this.state.props, [this.root.bitsAttrs["scroll-up-button"]]: "" }));
1037
1045
  }
@@ -24,6 +24,7 @@
24
24
  style = {},
25
25
  wrapperId = useId(),
26
26
  customAnchor = null,
27
+ enabled,
27
28
  }: ContentImplProps = $props();
28
29
 
29
30
  const contentState = useFloatingContentState({
@@ -43,7 +44,7 @@
43
44
  strategy: box.with(() => strategy),
44
45
  dir: box.with(() => dir),
45
46
  style: box.with(() => style),
46
- enabled: box.with(() => false),
47
+ enabled: box.with(() => enabled),
47
48
  wrapperId: box.with(() => wrapperId),
48
49
  customAnchor: box.with(() => customAnchor),
49
50
  });
@@ -106,6 +106,7 @@ export type FloatingLayerContentImplProps = {
106
106
  * Callback that is called when the floating element is placed.
107
107
  */
108
108
  onPlaced?: () => void;
109
+ enabled: boolean;
109
110
  } & FloatingLayerContentProps;
110
111
  export type FloatingLayerAnchorProps = {
111
112
  id: string;
@@ -70,6 +70,7 @@
70
70
  {style}
71
71
  {onPlaced}
72
72
  {customAnchor}
73
+ {enabled}
73
74
  >
74
75
  {#snippet content({ props: floatingProps, wrapperProps })}
75
76
  {#if restProps.forceMount && enabled}
@@ -0,0 +1,5 @@
1
+ import type { Getter } from "svelte-toolbelt";
2
+ /**
3
+ * Calls a function the next frame after all animations have finished.
4
+ */
5
+ export declare function useAfterAnimations(getNode: Getter<HTMLElement | null>): (fn: () => void) => void;
@@ -0,0 +1,27 @@
1
+ import { flushSync } from "svelte";
2
+ /**
3
+ * Calls a function the next frame after all animations have finished.
4
+ */
5
+ export function useAfterAnimations(getNode) {
6
+ let frame = -1;
7
+ function cancelFrame() {
8
+ cancelAnimationFrame(frame);
9
+ }
10
+ $effect(() => cancelFrame);
11
+ return (fn) => {
12
+ cancelFrame();
13
+ const node = getNode();
14
+ if (!node)
15
+ return;
16
+ if (typeof node.getAnimations !== "function" || globalThis.bitsAnimationsDisabled) {
17
+ fn();
18
+ }
19
+ else {
20
+ frame = requestAnimationFrame(() => {
21
+ Promise.allSettled(node.getAnimations().map((anim) => anim.finished)).then(() => {
22
+ flushSync(fn);
23
+ });
24
+ });
25
+ }
26
+ };
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bits-ui",
3
- "version": "1.0.0-next.82",
3
+ "version": "1.0.0-next.83",
4
4
  "license": "MIT",
5
5
  "repository": "github:huntabyte/bits-ui",
6
6
  "funding": "https://github.com/sponsors/huntabyte",
@@ -43,7 +43,7 @@
43
43
  "@internationalized/date": "^3.5.6",
44
44
  "esm-env": "^1.1.2",
45
45
  "runed": "^0.23.2",
46
- "svelte-toolbelt": "^0.7.0"
46
+ "svelte-toolbelt": "^0.7.1"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "svelte": "^5.11.0"