bits-ui 1.3.13 → 1.3.15

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/README.md CHANGED
@@ -5,17 +5,22 @@
5
5
  [![npm version](https://flat.badgen.net/npm/v/bits-ui?color=pink)](https://npmjs.com/package/bits-ui)
6
6
  [![npm downloads](https://flat.badgen.net/npm/dm/bits-ui?color=pink)](https://npmjs.com/package/bits-ui)
7
7
  [![license](https://flat.badgen.net/github/license/huntabyte/bits-ui?color=pink)](https://github.com/huntabyte/bits-ui/blob/main/LICENSE)
8
+ [![](https://dcbadge.vercel.app/api/server/fdXy3Sk8Gq?style=flat)](https://discord.gg/fdXy3Sk8Gq)
8
9
 
9
- <!-- /automd -->
10
+ <img style="max-width: 100%" alt="hero" src="https://github.com/user-attachments/assets/19cac792-6a93-4289-b9c7-647794a7de79" />
11
+
12
+ **Bits UI** – the headless components for Svelte.
10
13
 
11
- The headless components for Svelte.
14
+ Flexible, unstyled, and accessible primitives that provide the foundation for building your own high-quality component library.
12
15
 
13
- [Read the docs](https://bits-ui.com)
16
+ ## Documentation
17
+
18
+ Visit https://bits-ui.com/docs to view the documentation.
14
19
 
15
20
  ## Credits
16
21
 
17
22
  - [Bitworks](https://bitworks.cz) - The design team behind the Bits UI documentation and example components.
18
- - [Melt UI](https://melt-ui.com) - The underlying builder API that powers Bits.
23
+ - [Melt UI](https://melt-ui.com) - A powerful builder API that inspired the internal architecture of Bits UI.
19
24
  - [Radix UI](https://radix-ui.com) - The incredible headless component APIs that we've taken heavy inspiration from.
20
25
  - [React Spectrum](https://react-spectrum.adobe.com) - An incredible collection of headless components we've taken inspiration from.
21
26
 
@@ -41,3 +46,14 @@ Built by [@huntabyte](https://github.com/huntabyte) and [community](https://gith
41
46
  </a>
42
47
 
43
48
  <!-- /automd -->
49
+
50
+ ## Community
51
+
52
+ Join the Discord server to ask questions, find collaborators, or just say hi!
53
+
54
+ <a href="https://discord.gg/fdXy3Sk8Gq" alt="Svecosystem Discord community">
55
+ <picture>
56
+ <source media="(prefers-color-scheme: dark)" srcset="https://invidget.switchblade.xyz/fdXy3Sk8Gq">
57
+ <img alt="Svecosystem Discord community" src="https://invidget.switchblade.xyz/fdXy3Sk8Gq?theme=light">
58
+ </picture>
59
+ </a>
@@ -14,6 +14,8 @@ type CommandRootStateProps = WithRefProps<ReadableBoxedValues<{
14
14
  declare class CommandRootState {
15
15
  #private;
16
16
  readonly opts: CommandRootStateProps;
17
+ sortAfterTick: boolean;
18
+ sortAndFilterAfterTick: boolean;
17
19
  allItems: Set<string>;
18
20
  allGroups: Map<string, Set<string>>;
19
21
  allIds: Map<string, {
@@ -32,6 +32,8 @@ const CommandGroupContainerContext = new Context("Command.Group");
32
32
  class CommandRootState {
33
33
  opts;
34
34
  #updateScheduled = false;
35
+ sortAfterTick = false;
36
+ sortAndFilterAfterTick = false;
35
37
  allItems = new Set();
36
38
  allGroups = new Map();
37
39
  allIds = new Map();
@@ -401,7 +403,14 @@ class CommandRootState {
401
403
  return noop;
402
404
  this.allIds.set(id, { value, keywords });
403
405
  this._commandState.filtered.items.set(id, this.#score(value, keywords));
404
- this.#sort();
406
+ // Schedule sorting to run after this tick when all items are added not each time an item is added
407
+ if (!this.sortAfterTick) {
408
+ this.sortAfterTick = true;
409
+ afterTick(() => {
410
+ this.#sort();
411
+ this.sortAfterTick = false;
412
+ });
413
+ }
405
414
  return () => {
406
415
  this.allIds.delete(id);
407
416
  };
@@ -425,8 +434,15 @@ class CommandRootState {
425
434
  this.allGroups.get(groupId).add(id);
426
435
  }
427
436
  }
428
- this.#filterItems();
429
- this.#sort();
437
+ // Schedule sorting and filtering to run after this tick when all items are added not each time an item is added
438
+ if (!this.sortAndFilterAfterTick) {
439
+ this.sortAndFilterAfterTick = true;
440
+ afterTick(() => {
441
+ this.#filterItems();
442
+ this.#sort();
443
+ this.sortAndFilterAfterTick = false;
444
+ });
445
+ }
430
446
  this.#scheduleUpdate();
431
447
  return () => {
432
448
  const selectedItem = this.#getSelectedItem();
@@ -4,11 +4,13 @@
4
4
  import { useMenubarMenu } from "../menubar.svelte.js";
5
5
  import Menu from "../../menu/components/menu.svelte";
6
6
  import { useId } from "../../../internal/use-id.js";
7
+ import { noop } from "../../../internal/noop.js";
7
8
 
8
- let { value = useId(), ...restProps }: MenubarMenuProps = $props();
9
+ let { value = useId(), onOpenChange = noop, ...restProps }: MenubarMenuProps = $props();
9
10
 
10
11
  const menuState = useMenubarMenu({
11
12
  value: box.with(() => value),
13
+ onOpenChange: box.with(() => onOpenChange),
12
14
  });
13
15
  </script>
14
16
 
@@ -3,7 +3,7 @@ import type { InteractOutsideBehaviorType } from "../utilities/dismissible-layer
3
3
  import type { ReadableBoxedValues, WritableBoxedValues } from "../../internal/box.svelte.js";
4
4
  import { type UseRovingFocusReturn } from "../../internal/use-roving-focus.svelte.js";
5
5
  import type { Direction } from "../../shared/index.js";
6
- import type { BitsFocusEvent, BitsKeyboardEvent, BitsPointerEvent, WithRefProps } from "../../internal/types.js";
6
+ import type { BitsFocusEvent, BitsKeyboardEvent, BitsPointerEvent, OnChangeFn, WithRefProps } from "../../internal/types.js";
7
7
  import { type FocusScopeContextValue } from "../utilities/focus-scope/use-focus-scope.svelte.js";
8
8
  type MenubarRootStateProps = WithRefProps<ReadableBoxedValues<{
9
9
  dir: Direction;
@@ -16,7 +16,7 @@ declare class MenubarRootState {
16
16
  rovingFocusGroup: UseRovingFocusReturn;
17
17
  wasOpenedByKeyboard: boolean;
18
18
  triggerIds: string[];
19
- valueToContentId: Map<string, ReadableBox<string>>;
19
+ valueToChangeHandler: Map<string, ReadableBox<OnChangeFn<boolean>>>;
20
20
  constructor(opts: MenubarRootStateProps);
21
21
  /**
22
22
  * @param id - the id of the trigger to register
@@ -28,7 +28,8 @@ declare class MenubarRootState {
28
28
  * @param contentId - the content id to associate with the value
29
29
  * @returns - a function to de-register the menu
30
30
  */
31
- registerMenu(value: string, contentId: ReadableBox<string>): () => void;
31
+ registerMenu(value: string, onOpenChange: ReadableBox<OnChangeFn<boolean>>): () => void;
32
+ updateValue(value: string): void;
32
33
  getTriggers(): HTMLButtonElement[];
33
34
  onMenuOpen(id: string, triggerId: string): void;
34
35
  onMenuClose(): void;
@@ -41,6 +42,7 @@ declare class MenubarRootState {
41
42
  }
42
43
  type MenubarMenuStateProps = ReadableBoxedValues<{
43
44
  value: string;
45
+ onOpenChange: OnChangeFn<boolean>;
44
46
  }>;
45
47
  declare class MenubarMenuState {
46
48
  readonly opts: MenubarMenuStateProps;
@@ -13,7 +13,7 @@ class MenubarRootState {
13
13
  rovingFocusGroup;
14
14
  wasOpenedByKeyboard = $state(false);
15
15
  triggerIds = $state([]);
16
- valueToContentId = new Map();
16
+ valueToChangeHandler = new Map();
17
17
  constructor(opts) {
18
18
  this.opts = opts;
19
19
  useRefById(opts);
@@ -43,12 +43,24 @@ class MenubarRootState {
43
43
  * @param contentId - the content id to associate with the value
44
44
  * @returns - a function to de-register the menu
45
45
  */
46
- registerMenu(value, contentId) {
47
- this.valueToContentId.set(value, contentId);
46
+ registerMenu(value, onOpenChange) {
47
+ this.valueToChangeHandler.set(value, onOpenChange);
48
48
  return () => {
49
- this.valueToContentId.delete(value);
49
+ this.valueToChangeHandler.delete(value);
50
50
  };
51
51
  }
52
+ updateValue(value) {
53
+ const currValue = this.opts.value.current;
54
+ const currHandler = this.valueToChangeHandler.get(currValue)?.current;
55
+ const nextHandler = this.valueToChangeHandler.get(value)?.current;
56
+ this.opts.value.current = value;
57
+ if (currHandler && currValue !== value) {
58
+ currHandler(false);
59
+ }
60
+ if (nextHandler) {
61
+ nextHandler(true);
62
+ }
63
+ }
52
64
  getTriggers() {
53
65
  const node = this.opts.ref.current;
54
66
  if (!node)
@@ -56,14 +68,14 @@ class MenubarRootState {
56
68
  return Array.from(node.querySelectorAll(`[${MENUBAR_TRIGGER_ATTR}]`));
57
69
  }
58
70
  onMenuOpen(id, triggerId) {
59
- this.opts.value.current = id;
71
+ this.updateValue(id);
60
72
  this.rovingFocusGroup.setCurrentTabStopId(triggerId);
61
73
  }
62
74
  onMenuClose() {
63
- this.opts.value.current = "";
75
+ this.updateValue("");
64
76
  }
65
77
  onMenuToggle(id) {
66
- this.opts.value.current = this.opts.value.current ? "" : id;
78
+ this.updateValue(this.opts.value.current ? "" : id);
67
79
  }
68
80
  props = $derived.by(() => ({
69
81
  id: this.opts.id.current,
@@ -87,7 +99,7 @@ class MenubarMenuState {
87
99
  }
88
100
  });
89
101
  onMount(() => {
90
- return this.root.registerMenu(this.opts.value.current, box.with(() => this.contentNode?.id ?? ""));
102
+ return this.root.registerMenu(this.opts.value.current, opts.onOpenChange);
91
103
  });
92
104
  }
93
105
  getTriggerNode() {
@@ -29,6 +29,10 @@ export type MenubarMenuPropsWithoutHTML = WithChildren<{
29
29
  * within the menubar.
30
30
  */
31
31
  value?: string;
32
+ /**
33
+ * A callback that is called when the menu is opened or closed.
34
+ */
35
+ onOpenChange?: OnChangeFn<boolean>;
32
36
  }>;
33
37
  export type MenubarMenuProps = MenubarMenuPropsWithoutHTML;
34
38
  export type MenubarTriggerPropsWithoutHTML = WithChild<{
@@ -423,6 +423,7 @@ class SelectTriggerState {
423
423
  this.root.opts.value.current = matchedItem.value;
424
424
  },
425
425
  enabled: !this.root.isMulti && this.root.dataTypeaheadEnabled,
426
+ candidateValues: () => (this.root.isMulti ? [] : this.root.candidateLabels),
426
427
  });
427
428
  this.onkeydown = this.onkeydown.bind(this);
428
429
  this.onpointerdown = this.onpointerdown.bind(this);
@@ -437,6 +438,29 @@ class SelectTriggerState {
437
438
  #handlePointerOpen(_) {
438
439
  this.#handleOpen();
439
440
  }
441
+ /**
442
+ * Logic used to handle keyboard selection/deselection.
443
+ *
444
+ * If it returns true, it means the item was selected and whatever is calling
445
+ * this function should return early
446
+ *
447
+ */
448
+ #handleKeyboardSelection() {
449
+ const isCurrentSelectedValue = this.root.highlightedValue === this.root.opts.value.current;
450
+ if (!this.root.opts.allowDeselect.current && isCurrentSelectedValue && !this.root.isMulti) {
451
+ this.root.handleClose();
452
+ return true;
453
+ }
454
+ // "" is a valid value for a select item so we need to check for that
455
+ if (this.root.highlightedValue !== null) {
456
+ this.root.toggleItem(this.root.highlightedValue, this.root.highlightedLabel ?? undefined);
457
+ }
458
+ if (!this.root.isMulti && !isCurrentSelectedValue) {
459
+ this.root.handleClose();
460
+ return true;
461
+ }
462
+ return false;
463
+ }
440
464
  onkeydown(e) {
441
465
  this.root.isUsingKeyboard = true;
442
466
  if (e.key === kbd.ARROW_UP || e.key === kbd.ARROW_DOWN)
@@ -450,7 +474,7 @@ class SelectTriggerState {
450
474
  this.root.handleOpen();
451
475
  }
452
476
  else if (!this.root.isMulti && this.root.dataTypeaheadEnabled) {
453
- this.#dataTypeahead.handleTypeaheadSearch(e.key, this.root.candidateLabels);
477
+ this.#dataTypeahead.handleTypeaheadSearch(e.key);
454
478
  return;
455
479
  }
456
480
  // we need to wait for a tick after the menu opens to ensure
@@ -474,23 +498,16 @@ class SelectTriggerState {
474
498
  this.root.handleClose();
475
499
  return;
476
500
  }
477
- if ((e.key === kbd.ENTER || e.key === kbd.SPACE) && !e.isComposing) {
501
+ if ((e.key === kbd.ENTER ||
502
+ // if we're currently "typing ahead", we don't want to select the item
503
+ // just yet as the item the user is trying to get to may have a space in it,
504
+ // so we defer handling the close for this case until further down
505
+ (e.key === kbd.SPACE && this.#domTypeahead.search.current === "")) &&
506
+ !e.isComposing) {
478
507
  e.preventDefault();
479
- const isCurrentSelectedValue = this.root.highlightedValue === this.root.opts.value.current;
480
- if (!this.root.opts.allowDeselect.current &&
481
- isCurrentSelectedValue &&
482
- !this.root.isMulti) {
483
- this.root.handleClose();
484
- return;
485
- }
486
- //"" is a valid value for a select item so we need to check for that
487
- if (this.root.highlightedValue !== null) {
488
- this.root.toggleItem(this.root.highlightedValue, this.root.highlightedLabel ?? undefined);
489
- }
490
- if (!this.root.isMulti && !isCurrentSelectedValue) {
491
- this.root.handleClose();
508
+ const shouldReturn = this.#handleKeyboardSelection();
509
+ if (shouldReturn)
492
510
  return;
493
- }
494
511
  }
495
512
  if (e.key === kbd.ARROW_UP && e.altKey) {
496
513
  this.root.handleClose();
@@ -529,14 +546,16 @@ class SelectTriggerState {
529
546
  }
530
547
  const isModifierKey = e.ctrlKey || e.altKey || e.metaKey;
531
548
  const isCharacterKey = e.key.length === 1;
532
- // prevent space from being considered with typeahead
533
- if (e.code === "Space")
534
- return;
549
+ const isSpaceKey = e.key === kbd.SPACE;
535
550
  const candidateNodes = this.root.getCandidateNodes();
536
551
  if (e.key === kbd.TAB)
537
552
  return;
538
- if (!isModifierKey && isCharacterKey) {
539
- this.#domTypeahead.handleTypeaheadSearch(e.key, candidateNodes);
553
+ if (!isModifierKey && (isCharacterKey || isSpaceKey)) {
554
+ const matchedNode = this.#domTypeahead.handleTypeaheadSearch(e.key, candidateNodes);
555
+ if (!matchedNode && isSpaceKey) {
556
+ e.preventDefault();
557
+ this.#handleKeyboardSelection();
558
+ }
540
559
  return;
541
560
  }
542
561
  if (!this.root.highlightedNode) {
@@ -66,21 +66,26 @@ export declare function forward<T>(array: T[], index: number, increment: number,
66
66
  */
67
67
  export declare function backward<T>(array: T[], index: number, decrement: number, loop?: boolean): T | undefined;
68
68
  /**
69
- * This is the "meat" of the typeahead matching logic. It takes in all the values,
70
- * the search and the current match, and returns the next match (or `undefined`).
69
+ * Finds the next matching item from a list of values based on a search string.
71
70
  *
72
- * We normalize the search because if a user has repeatedly pressed a character,
73
- * we want the exact same behavior as if we only had that one character
74
- * (ie. cycle through options starting with that character)
71
+ * This function handles several special cases in typeahead behavior:
75
72
  *
76
- * We also reorder the values by wrapping the array around the current match.
77
- * This is so we always look forward from the current match, and picking the first
78
- * match will always be the correct one.
73
+ * 1. Space handling: When a search string ends with a space, it handles it specially:
74
+ * - If there's only one match for the text before the space, it ignores the space
75
+ * - If there are multiple matches and the current match already starts with the search prefix
76
+ * followed by a space, it keeps the current match (doesn't change selection on space)
77
+ * - Only after typing characters beyond the space will it move to a more specific match
79
78
  *
80
- * Finally, if the normalized search is exactly one character, we exclude the
81
- * current match from the values because otherwise it would be the first to match always
82
- * and focus would never move. This is as opposed to the regular case, where we
83
- * don't want focus to move if the current match still matches.
79
+ * 2. Repeated character handling: If a search consists of repeated characters (e.g., "aaa"),
80
+ * it treats it as a single character for matching purposes
81
+ *
82
+ * 3. Cycling behavior: The function wraps around the values array starting from the current match
83
+ * to find the next appropriate match, creating a cycling selection behavior
84
+ *
85
+ * @param values - Array of string values to search through (e.g., the text content of menu items)
86
+ * @param search - The current search string typed by the user
87
+ * @param currentMatch - The currently selected/matched item, if any
88
+ * @returns The next matching value that should be selected, or undefined if no match is found
84
89
  */
85
90
  export declare function getNextMatch(values: string[], search: string, currentMatch?: string): string | undefined;
86
91
  /**
@@ -169,31 +169,76 @@ export function backward(array, index, decrement, loop = true) {
169
169
  return array[targetIndex];
170
170
  }
171
171
  /**
172
- * This is the "meat" of the typeahead matching logic. It takes in all the values,
173
- * the search and the current match, and returns the next match (or `undefined`).
172
+ * Finds the next matching item from a list of values based on a search string.
174
173
  *
175
- * We normalize the search because if a user has repeatedly pressed a character,
176
- * we want the exact same behavior as if we only had that one character
177
- * (ie. cycle through options starting with that character)
174
+ * This function handles several special cases in typeahead behavior:
178
175
  *
179
- * We also reorder the values by wrapping the array around the current match.
180
- * This is so we always look forward from the current match, and picking the first
181
- * match will always be the correct one.
176
+ * 1. Space handling: When a search string ends with a space, it handles it specially:
177
+ * - If there's only one match for the text before the space, it ignores the space
178
+ * - If there are multiple matches and the current match already starts with the search prefix
179
+ * followed by a space, it keeps the current match (doesn't change selection on space)
180
+ * - Only after typing characters beyond the space will it move to a more specific match
182
181
  *
183
- * Finally, if the normalized search is exactly one character, we exclude the
184
- * current match from the values because otherwise it would be the first to match always
185
- * and focus would never move. This is as opposed to the regular case, where we
186
- * don't want focus to move if the current match still matches.
182
+ * 2. Repeated character handling: If a search consists of repeated characters (e.g., "aaa"),
183
+ * it treats it as a single character for matching purposes
184
+ *
185
+ * 3. Cycling behavior: The function wraps around the values array starting from the current match
186
+ * to find the next appropriate match, creating a cycling selection behavior
187
+ *
188
+ * @param values - Array of string values to search through (e.g., the text content of menu items)
189
+ * @param search - The current search string typed by the user
190
+ * @param currentMatch - The currently selected/matched item, if any
191
+ * @returns The next matching value that should be selected, or undefined if no match is found
187
192
  */
188
193
  export function getNextMatch(values, search, currentMatch) {
194
+ const lowerSearch = search.toLowerCase();
195
+ if (lowerSearch.endsWith(" ")) {
196
+ const searchWithoutSpace = lowerSearch.slice(0, -1);
197
+ const matchesWithoutSpace = values.filter((value) => value.toLowerCase().startsWith(searchWithoutSpace));
198
+ /**
199
+ * If there's only one match for the prefix without space, we don't
200
+ * watch to match with space.
201
+ */
202
+ if (matchesWithoutSpace.length <= 1) {
203
+ return getNextMatch(values, searchWithoutSpace, currentMatch);
204
+ }
205
+ const currentMatchLowercase = currentMatch?.toLowerCase();
206
+ /**
207
+ * If the current match already starts with the search prefix and has a space afterward,
208
+ * and the user has only typed up to that space, keep the current match until they
209
+ * disambiguate.
210
+ */
211
+ if (currentMatchLowercase &&
212
+ currentMatchLowercase.startsWith(searchWithoutSpace) &&
213
+ currentMatchLowercase.charAt(searchWithoutSpace.length) === " " &&
214
+ search.trim() === searchWithoutSpace) {
215
+ return currentMatch;
216
+ }
217
+ /**
218
+ * With multiple matches, find items that match the full search string with space
219
+ */
220
+ const spacedMatches = values.filter((value) => value.toLowerCase().startsWith(lowerSearch));
221
+ /**
222
+ * If we found matches with the space, use the first one that's not the current match
223
+ */
224
+ if (spacedMatches.length > 0) {
225
+ const currentMatchIndex = currentMatch ? values.indexOf(currentMatch) : -1;
226
+ let wrappedMatches = wrapArray(spacedMatches, Math.max(currentMatchIndex, 0));
227
+ // return the first match that is not the current one.
228
+ const nextMatch = wrappedMatches.find((match) => match !== currentMatch);
229
+ // fallback to current if no other is found.
230
+ return nextMatch || currentMatch;
231
+ }
232
+ }
189
233
  const isRepeated = search.length > 1 && Array.from(search).every((char) => char === search[0]);
190
234
  const normalizedSearch = isRepeated ? search[0] : search;
235
+ const normalizedLowerSearch = normalizedSearch.toLowerCase();
191
236
  const currentMatchIndex = currentMatch ? values.indexOf(currentMatch) : -1;
192
237
  let wrappedValues = wrapArray(values, Math.max(currentMatchIndex, 0));
193
238
  const excludeCurrentMatch = normalizedSearch.length === 1;
194
239
  if (excludeCurrentMatch)
195
240
  wrappedValues = wrappedValues.filter((v) => v !== currentMatch);
196
- const nextMatch = wrappedValues.find((value) => value?.toLowerCase().startsWith(normalizedSearch.toLowerCase()));
241
+ const nextMatch = wrappedValues.find((value) => value?.toLowerCase().startsWith(normalizedLowerSearch));
197
242
  return nextMatch !== currentMatch ? nextMatch : undefined;
198
243
  }
199
244
  /**
@@ -1,12 +1,14 @@
1
+ import type { Getter } from "svelte-toolbelt";
1
2
  export type DataTypeahead = ReturnType<typeof useDataTypeahead>;
2
3
  type UseDataTypeaheadOpts = {
3
4
  onMatch: (value: string) => void;
4
5
  getCurrentItem: () => string;
6
+ candidateValues: Getter<string[]>;
5
7
  enabled: boolean;
6
8
  };
7
9
  export declare function useDataTypeahead(opts: UseDataTypeaheadOpts): {
8
10
  search: import("svelte-toolbelt").WritableBox<string>;
9
- handleTypeaheadSearch: (key: string, candidateValues: string[]) => string | undefined;
11
+ handleTypeaheadSearch: (key: string) => string | undefined;
10
12
  resetTypeahead: () => void;
11
13
  };
12
14
  export {};
@@ -3,7 +3,8 @@ import { boxAutoReset } from "./box-auto-reset.svelte.js";
3
3
  export function useDataTypeahead(opts) {
4
4
  // Reset `search` 1 second after it was last updated
5
5
  const search = boxAutoReset("", 1000);
6
- function handleTypeaheadSearch(key, candidateValues) {
6
+ const candidateValues = $derived(opts.candidateValues());
7
+ function handleTypeaheadSearch(key) {
7
8
  if (!opts.enabled)
8
9
  return;
9
10
  if (!candidateValues.length)
@@ -10,10 +10,10 @@ export function useDOMTypeahead(opts) {
10
10
  return;
11
11
  search.current = search.current + key;
12
12
  const currentItem = getCurrentItem();
13
- const currentMatch = candidates.find((item) => item === currentItem)?.textContent?.trim() ?? "";
14
- const values = candidates.map((item) => item.textContent?.trim() ?? "");
13
+ const currentMatch = candidates.find((item) => item === currentItem)?.textContent ?? "";
14
+ const values = candidates.map((item) => item.textContent ?? "");
15
15
  const nextMatch = getNextMatch(values, search.current, currentMatch);
16
- const newItem = candidates.find((item) => item.textContent?.trim() === nextMatch);
16
+ const newItem = candidates.find((item) => item.textContent === nextMatch);
17
17
  if (newItem) {
18
18
  onMatch(newItem);
19
19
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bits-ui",
3
- "version": "1.3.13",
3
+ "version": "1.3.15",
4
4
  "license": "MIT",
5
5
  "repository": "github:huntabyte/bits-ui",
6
6
  "funding": "https://github.com/sponsors/huntabyte",
@@ -1,5 +0,0 @@
1
- import type { Polygon } from "../polygon.js";
2
- /**
3
- * Debugging utility to visualize the grace area of a floating layer component.
4
- */
5
- export declare function visualizeGraceArea(graceArea: Polygon): void;
@@ -1,28 +0,0 @@
1
- let graceAreaElement = null;
2
- let svgContainer = null;
3
- /**
4
- * Debugging utility to visualize the grace area of a floating layer component.
5
- */
6
- export function visualizeGraceArea(graceArea) {
7
- if (graceAreaElement) {
8
- graceAreaElement.remove();
9
- }
10
- if (!svgContainer) {
11
- svgContainer = document.createElementNS("http://www.w3.org/2000/svg", "svg");
12
- svgContainer.style.position = "absolute";
13
- svgContainer.style.top = "0";
14
- svgContainer.style.left = "0";
15
- svgContainer.style.width = "100%";
16
- svgContainer.style.height = "100%";
17
- svgContainer.style.pointerEvents = "none";
18
- document.body.appendChild(svgContainer);
19
- }
20
- graceAreaElement = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
21
- const pointsString = graceArea.map((p) => `${p.x},${p.y}`).join(" ");
22
- graceAreaElement.setAttribute("points", pointsString);
23
- graceAreaElement.setAttribute("fill", "rgba(255, 0, 0, 0.3)");
24
- graceAreaElement.setAttribute("stroke", "red");
25
- graceAreaElement.setAttribute("stroke-width", "1");
26
- graceAreaElement.style.pointerEvents = "none";
27
- svgContainer.appendChild(graceAreaElement);
28
- }
@@ -1,10 +0,0 @@
1
- export interface Point {
2
- x: number;
3
- y: number;
4
- }
5
- export type Polygon = Array<Point>;
6
- export declare function makeHullPresorted<P extends Point>(points: Readonly<Array<P>>): Array<P>;
7
- export declare function POINT_COMPARATOR(a: Point, b: Point): number;
8
- export declare function makeHullFromElements(els: Array<HTMLElement>): Array<Point>;
9
- export declare function pointInPolygon(point: Point, polygon: Polygon): boolean;
10
- export declare function isPointerInGraceArea(e: Pick<PointerEvent, "clientX" | "clientY">, area?: Polygon): boolean;
@@ -1,115 +0,0 @@
1
- /*
2
- * Convex hull algorithm - Library (TypeScript)
3
- *
4
- * Copyright (c) 2021 Project Nayuki
5
- * https://www.nayuki.io/page/convex-hull-algorithm
6
- *
7
- * This program is free software: you can redistribute it and/or modify
8
- * it under the terms of the GNU Lesser General Public License as published by
9
- * the Free Software Foundation, either version 3 of the License, or
10
- * (at your option) any later version.
11
- *
12
- * This program is distributed in the hope that it will be useful,
13
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
- * GNU Lesser General Public License for more details.
16
- *
17
- * You should have received a copy of the GNU Lesser General Public License
18
- * along with this program (see COPYING.txt and COPYING.LESSER.txt).
19
- * If not, see <http://www.gnu.org/licenses/>.
20
- */
21
- // Returns a new array of points representing the convex hull of
22
- // the given set of points. The convex hull excludes collinear points.
23
- // This algorithm runs in O(n log n) time.
24
- function makeHull(points) {
25
- const newPoints = points.slice();
26
- newPoints.sort(POINT_COMPARATOR);
27
- return makeHullPresorted(newPoints);
28
- }
29
- // Returns the convex hull, assuming that each points[i] <= points[i + 1]. Runs in O(n) time.
30
- export function makeHullPresorted(points) {
31
- if (points.length <= 1)
32
- return points.slice();
33
- // Andrew's monotone chain algorithm. Positive y coordinates correspond to "up"
34
- // as per the mathematical convention, instead of "down" as per the computer
35
- // graphics convention. This doesn't affect the correctness of the result.
36
- const upperHull = [];
37
- for (let i = 0; i < points.length; i++) {
38
- const p = points[i];
39
- while (upperHull.length >= 2) {
40
- const q = upperHull[upperHull.length - 1];
41
- const r = upperHull[upperHull.length - 2];
42
- if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x))
43
- upperHull.pop();
44
- else
45
- break;
46
- }
47
- upperHull.push(p);
48
- }
49
- upperHull.pop();
50
- const lowerHull = [];
51
- for (let i = points.length - 1; i >= 0; i--) {
52
- const p = points[i];
53
- while (lowerHull.length >= 2) {
54
- const q = lowerHull[lowerHull.length - 1];
55
- const r = lowerHull[lowerHull.length - 2];
56
- if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x))
57
- lowerHull.pop();
58
- else
59
- break;
60
- }
61
- lowerHull.push(p);
62
- }
63
- lowerHull.pop();
64
- if (upperHull.length === 1 &&
65
- lowerHull.length === 1 &&
66
- upperHull[0].x === lowerHull[0].x &&
67
- upperHull[0].y === lowerHull[0].y)
68
- return upperHull;
69
- else
70
- return upperHull.concat(lowerHull);
71
- }
72
- export function POINT_COMPARATOR(a, b) {
73
- if (a.x < b.x)
74
- return -1;
75
- else if (a.x > b.x)
76
- return +1;
77
- else if (a.y < b.y)
78
- return -1;
79
- else if (a.y > b.y)
80
- return +1;
81
- else
82
- return 0;
83
- }
84
- function getPointsFromEl(el) {
85
- const rect = el.getBoundingClientRect();
86
- return [
87
- { x: rect.left, y: rect.top },
88
- { x: rect.right, y: rect.top },
89
- { x: rect.right, y: rect.bottom },
90
- { x: rect.left, y: rect.bottom },
91
- ];
92
- }
93
- export function makeHullFromElements(els) {
94
- const points = els.flatMap((el) => getPointsFromEl(el));
95
- return makeHull(points);
96
- }
97
- export function pointInPolygon(point, polygon) {
98
- let inside = false;
99
- for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
100
- const xi = polygon[i].x;
101
- const yi = polygon[i].y;
102
- const xj = polygon[j].x;
103
- const yj = polygon[j].y;
104
- const intersect = yi > point.y !== yj > point.y &&
105
- point.x < ((xj - xi) * (point.y - yi)) / (yj - yi) + xi;
106
- if (intersect)
107
- inside = !inside;
108
- }
109
- return inside;
110
- }
111
- export function isPointerInGraceArea(e, area) {
112
- if (!area)
113
- return false;
114
- return pointInPolygon({ x: e.clientX, y: e.clientY }, area);
115
- }