bits-ui 1.0.0-next.91 → 1.0.0-next.93

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,6 @@
1
1
  import type { CommandState } from "./types.js";
2
2
  import type { BitsKeyboardEvent, BitsMouseEvent, BitsPointerEvent, WithRefProps } from "../../internal/types.js";
3
3
  import type { ReadableBoxedValues, WritableBoxedValues } from "../../internal/box.svelte.js";
4
- export declare function defaultFilter(value: string, search: string, keywords?: string[]): number;
5
4
  type CommandRootStateProps = WithRefProps<ReadableBoxedValues<{
6
5
  filter: (value: string, search: string, keywords?: string[]) => number;
7
6
  shouldFilter: boolean;
@@ -1,11 +1,11 @@
1
- import { untrack } from "svelte";
2
1
  import { afterSleep, afterTick, srOnlyStyles, useRefById } from "svelte-toolbelt";
3
- import { Context } from "runed";
2
+ import { Context, watch } from "runed";
4
3
  import { findNextSibling, findPreviousSibling } from "./utils.js";
5
- import { commandScore } from "./command-score.js";
6
4
  import { kbd } from "../../internal/kbd.js";
7
5
  import { getAriaDisabled, getAriaExpanded, getAriaSelected, getDataDisabled, getDataSelected, } from "../../internal/attrs.js";
8
6
  import { getFirstNonCommentChild } from "../../internal/dom.js";
7
+ import { computeCommandScore } from "./index.js";
8
+ // attributes
9
9
  const COMMAND_ROOT_ATTR = "data-command-root";
10
10
  const COMMAND_LIST_ATTR = "data-command-list";
11
11
  const COMMAND_INPUT_ATTR = "data-command-input";
@@ -18,15 +18,13 @@ const COMMAND_GROUP_HEADING_ATTR = "data-command-group-heading";
18
18
  const COMMAND_ITEM_ATTR = "data-command-item";
19
19
  const COMMAND_VIEWPORT_ATTR = "data-command-viewport";
20
20
  const COMMAND_INPUT_LABEL_ATTR = "data-command-input-label";
21
- const VALUE_ATTR = `data-value`;
22
- const GROUP_SELECTOR = `[${COMMAND_GROUP_ATTR}]`;
23
- const GROUP_ITEMS_SELECTOR = `[${COMMAND_GROUP_ITEMS_ATTR}]`;
24
- const GROUP_HEADING_SELECTOR = `[${COMMAND_GROUP_HEADING_ATTR}]`;
25
- const ITEM_SELECTOR = `[${COMMAND_ITEM_ATTR}]`;
26
- const VALID_ITEM_SELECTOR = `${ITEM_SELECTOR}:not([aria-disabled="true"])`;
27
- export function defaultFilter(value, search, keywords) {
28
- return commandScore(value, search, keywords);
29
- }
21
+ const COMMAND_VALUE_ATTR = "data-value";
22
+ // selectors
23
+ const COMMAND_GROUP_SELECTOR = `[${COMMAND_GROUP_ATTR}]`;
24
+ const COMMAND_GROUP_ITEMS_SELECTOR = `[${COMMAND_GROUP_ITEMS_ATTR}]`;
25
+ const COMMAND_GROUP_HEADING_SELECTOR = `[${COMMAND_GROUP_HEADING_ATTR}]`;
26
+ const COMMAND_ITEM_SELECTOR = `[${COMMAND_ITEM_ATTR}]`;
27
+ const COMMAND_VALID_ITEM_SELECTOR = `${COMMAND_ITEM_SELECTOR}:not([aria-disabled="true"])`;
30
28
  const CommandRootContext = new Context("Command.Root");
31
29
  const CommandListContext = new Context("Command.List");
32
30
  const CommandGroupContainerContext = new Context("Command.Group");
@@ -103,7 +101,7 @@ class CommandRootState {
103
101
  this.onkeydown = this.onkeydown.bind(this);
104
102
  }
105
103
  #score(value, keywords) {
106
- const filter = this.opts.filter.current ?? defaultFilter;
104
+ const filter = this.opts.filter.current ?? computeCommandScore;
107
105
  const score = value ? filter(value, this._commandState.search, keywords) : 0;
108
106
  return score;
109
107
  }
@@ -143,11 +141,11 @@ class CommandRootState {
143
141
  return scoresB - scoresA;
144
142
  });
145
143
  for (const item of sorted) {
146
- const group = item.closest(GROUP_ITEMS_SELECTOR);
144
+ const group = item.closest(COMMAND_GROUP_ITEMS_SELECTOR);
147
145
  if (group) {
148
146
  const itemToAppend = item.parentElement === group
149
147
  ? item
150
- : item.closest(`${GROUP_ITEMS_SELECTOR} > *`);
148
+ : item.closest(`${COMMAND_GROUP_ITEMS_SELECTOR} > *`);
151
149
  if (itemToAppend) {
152
150
  group.appendChild(itemToAppend);
153
151
  }
@@ -155,7 +153,7 @@ class CommandRootState {
155
153
  else {
156
154
  const itemToAppend = item.parentElement === listInsertionElement
157
155
  ? item
158
- : item.closest(`${GROUP_ITEMS_SELECTOR} > *`);
156
+ : item.closest(`${COMMAND_GROUP_ITEMS_SELECTOR} > *`);
159
157
  if (itemToAppend) {
160
158
  listInsertionElement?.appendChild(itemToAppend);
161
159
  }
@@ -163,7 +161,7 @@ class CommandRootState {
163
161
  }
164
162
  const sortedGroups = groups.sort((a, b) => b[1] - a[1]);
165
163
  for (const group of sortedGroups) {
166
- const element = listInsertionElement?.querySelector(`${GROUP_SELECTOR}[${VALUE_ATTR}="${encodeURIComponent(group[0])}"]`);
164
+ const element = listInsertionElement?.querySelector(`${COMMAND_GROUP_SELECTOR}[${COMMAND_VALUE_ATTR}="${encodeURIComponent(group[0])}"]`);
167
165
  element?.parentElement?.appendChild(element);
168
166
  }
169
167
  }
@@ -179,7 +177,7 @@ class CommandRootState {
179
177
  #selectFirstItem() {
180
178
  afterTick(() => {
181
179
  const item = this.#getValidItems().find((item) => item.getAttribute("aria-disabled") !== "true");
182
- const value = item?.getAttribute(VALUE_ATTR);
180
+ const value = item?.getAttribute(COMMAND_VALUE_ATTR);
183
181
  this.setValue(value || "");
184
182
  });
185
183
  }
@@ -216,14 +214,14 @@ class CommandRootState {
216
214
  const node = this.opts.ref.current;
217
215
  if (!node)
218
216
  return [];
219
- const validItems = Array.from(node.querySelectorAll(VALID_ITEM_SELECTOR)).filter((el) => !!el);
217
+ const validItems = Array.from(node.querySelectorAll(COMMAND_VALID_ITEM_SELECTOR)).filter((el) => !!el);
220
218
  return validItems;
221
219
  }
222
220
  #getSelectedItem() {
223
221
  const node = this.opts.ref.current;
224
222
  if (!node)
225
223
  return;
226
- const selectedNode = node.querySelector(`${VALID_ITEM_SELECTOR}[aria-selected="true"]`);
224
+ const selectedNode = node.querySelector(`${COMMAND_VALID_ITEM_SELECTOR}[aria-selected="true"]`);
227
225
  if (!selectedNode)
228
226
  return;
229
227
  return selectedNode;
@@ -239,8 +237,8 @@ class CommandRootState {
239
237
  const firstChildOfParent = getFirstNonCommentChild(grandparent);
240
238
  if (firstChildOfParent && firstChildOfParent.dataset?.value === item.dataset?.value) {
241
239
  item
242
- ?.closest(GROUP_SELECTOR)
243
- ?.querySelector(GROUP_HEADING_SELECTOR)
240
+ ?.closest(COMMAND_GROUP_SELECTOR)
241
+ ?.querySelector(COMMAND_GROUP_HEADING_SELECTOR)
244
242
  ?.scrollIntoView({ block: "nearest" });
245
243
  return;
246
244
  }
@@ -251,7 +249,7 @@ class CommandRootState {
251
249
  const items = this.#getValidItems();
252
250
  const item = items[index];
253
251
  if (item) {
254
- this.setValue(item.getAttribute(VALUE_ATTR) ?? "");
252
+ this.setValue(item.getAttribute(COMMAND_VALUE_ATTR) ?? "");
255
253
  }
256
254
  }
257
255
  #updateSelectedByItem(change) {
@@ -269,22 +267,22 @@ class CommandRootState {
269
267
  : items[index + change];
270
268
  }
271
269
  if (newSelected) {
272
- this.setValue(newSelected.getAttribute(VALUE_ATTR) ?? "");
270
+ this.setValue(newSelected.getAttribute(COMMAND_VALUE_ATTR) ?? "");
273
271
  }
274
272
  }
275
273
  #updateSelectedByGroup(change) {
276
274
  const selected = this.#getSelectedItem();
277
- let group = selected?.closest(GROUP_SELECTOR);
275
+ let group = selected?.closest(COMMAND_GROUP_SELECTOR);
278
276
  let item;
279
277
  while (group && !item) {
280
278
  group =
281
279
  change > 0
282
- ? findNextSibling(group, GROUP_SELECTOR)
283
- : findPreviousSibling(group, GROUP_SELECTOR);
284
- item = group?.querySelector(VALID_ITEM_SELECTOR);
280
+ ? findNextSibling(group, COMMAND_GROUP_SELECTOR)
281
+ : findPreviousSibling(group, COMMAND_GROUP_SELECTOR);
282
+ item = group?.querySelector(COMMAND_VALID_ITEM_SELECTOR);
285
283
  }
286
284
  if (item) {
287
- this.setValue(item.getAttribute(VALUE_ATTR) ?? "");
285
+ this.setValue(item.getAttribute(COMMAND_VALUE_ATTR) ?? "");
288
286
  }
289
287
  else {
290
288
  this.#updateSelectedByItem(change);
@@ -534,7 +532,7 @@ class CommandInputState {
534
532
  opts;
535
533
  root;
536
534
  #selectedItemId = $derived.by(() => {
537
- const item = this.root.viewportNode?.querySelector(`${ITEM_SELECTOR}[${VALUE_ATTR}="${encodeURIComponent(this.opts.value.current)}"]`);
535
+ const item = this.root.viewportNode?.querySelector(`${COMMAND_ITEM_SELECTOR}[${COMMAND_VALUE_ATTR}="${encodeURIComponent(this.opts.value.current)}"]`);
538
536
  if (!item)
539
537
  return;
540
538
  return item?.getAttribute("id") ?? undefined;
@@ -548,21 +546,16 @@ class CommandInputState {
548
546
  this.root.inputNode = node;
549
547
  },
550
548
  });
551
- $effect(() => {
549
+ watch(() => this.opts.ref.current, () => {
552
550
  const node = this.opts.ref.current;
553
- untrack(() => {
554
- if (node && this.opts.autofocus.current) {
555
- afterSleep(10, () => node.focus());
556
- }
557
- });
551
+ if (node && this.opts.autofocus.current) {
552
+ afterSleep(10, () => node.focus());
553
+ }
558
554
  });
559
- $effect(() => {
560
- this.opts.value.current;
561
- untrack(() => {
562
- if (this.root.commandState.search !== this.opts.value.current) {
563
- this.root.setState("search", this.opts.value.current);
564
- }
565
- });
555
+ watch(() => this.opts.value.current, () => {
556
+ if (this.root.commandState.search !== this.opts.value.current) {
557
+ this.root.setState("search", this.opts.value.current);
558
+ }
566
559
  });
567
560
  }
568
561
  props = $derived.by(() => ({
@@ -609,27 +602,23 @@ class CommandItemState {
609
602
  ...opts,
610
603
  deps: () => Boolean(this.root.commandState.search),
611
604
  });
612
- $effect(() => {
613
- this.opts.id.current;
614
- this.#group?.opts.id.current;
615
- if (!this.opts.forceMount.current) {
616
- return untrack(() => {
617
- return this.root.registerItem(this.opts.id.current, this.#group?.opts.id.current);
618
- });
619
- }
605
+ watch([
606
+ () => this.opts.id.current,
607
+ () => this.#group?.opts.id.current,
608
+ () => this.opts.forceMount.current,
609
+ ], () => {
610
+ if (this.opts.forceMount.current)
611
+ return;
612
+ return this.root.registerItem(this.opts.id.current, this.#group?.opts.id.current);
620
613
  });
621
- $effect(() => {
622
- const value = this.opts.value.current;
623
- const node = this.opts.ref.current;
624
- if (!node)
614
+ watch([() => this.opts.value.current, () => this.opts.ref.current], () => {
615
+ if (!this.opts.ref.current)
625
616
  return;
626
- if (!value && node.textContent) {
627
- this.trueValue = node.textContent.trim();
617
+ if (!this.opts.value.current && this.opts.ref.current.textContent) {
618
+ this.trueValue = this.opts.ref.current.textContent.trim();
628
619
  }
629
- untrack(() => {
630
- this.root.registerValue(this.opts.id.current, this.trueValue, opts.keywords.current.map((keyword) => keyword.trim()));
631
- node.setAttribute(VALUE_ATTR, this.trueValue);
632
- });
620
+ this.root.registerValue(this.opts.id.current, this.trueValue, opts.keywords.current.map((kw) => kw.trim()));
621
+ this.opts.ref.current.setAttribute(COMMAND_VALUE_ATTR, this.trueValue);
633
622
  });
634
623
  // bindings
635
624
  this.onclick = this.onclick.bind(this);
@@ -1,10 +1,11 @@
1
1
  <script lang="ts">
2
2
  import { box, mergeProps } from "svelte-toolbelt";
3
- import { defaultFilter, useCommandRoot } from "../command.svelte.js";
3
+ import { useCommandRoot } from "../command.svelte.js";
4
4
  import type { CommandRootProps } from "../types.js";
5
5
  import CommandLabel from "./_command-label.svelte";
6
6
  import { noop } from "../../../internal/noop.js";
7
7
  import { useId } from "../../../internal/use-id.js";
8
+ import { computeCommandScore } from "../index.js";
8
9
 
9
10
  let {
10
11
  id = useId(),
@@ -14,7 +15,7 @@
14
15
  onStateChange = noop,
15
16
  loop = false,
16
17
  shouldFilter = true,
17
- filter = defaultFilter,
18
+ filter = computeCommandScore,
18
19
  label = "",
19
20
  vimBindings = true,
20
21
  disablePointerSelection = false,
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Given a command, a search query, and (optionally) a list of keywords for the command,
3
+ * computes a score between 0 and 1 that represents how well the search query matches the
4
+ * abbreviation and keywords. 1 is a perfect match, 0 is no match.
5
+ *
6
+ * The score is calculated based on the following rules:
7
+ * - The scores are arranged so that a continuous match of characters will result in a total
8
+ * score of 1. The best case, this character is a match, and either this is the start of the string
9
+ * or the previous character was also a match.
10
+ * - A new match at the start of a word scores better than a new match elsewhere as it's more likely
11
+ * that the user will type the starts of fragments.
12
+ * - Word jumps between spaces are scored slightly higher than slashes, brackets, hyphens, etc.
13
+ * - A continuous match of characters will result in a total score of 1.
14
+ * - A new match at the start of a word scores better than a new match elsewhere as it's more likely that the user will type the starts of fragments.
15
+ * - Any other match isn't ideal, but we include it for completeness.
16
+ * - If the user transposed two letters, it should be significantly penalized.
17
+ * - The goodness of a match should decay slightly with each missing character.
18
+ * - Match higher for letters closer to the beginning of the word.
19
+ *
20
+ * @param command - The value to score against the search string (e.g. a command name like "Calculator")
21
+ * @param search - The search string to score against the value/aliases
22
+ * @param commandKeywords - An optional list of aliases/keywords to score against the search string - e.g. ["math", "add", "divide", "multiply", "subtract"]
23
+ * @returns A score between 0 and 1 that represents how well the search string matches the
24
+ * command (and keywords)
25
+ */
26
+ export declare function computeCommandScore(command: string, search: string, commandKeywords?: string[]): number;
@@ -37,7 +37,7 @@ const PENALTY_SKIPPED = 0.999;
37
37
  const PENALTY_CASE_MISMATCH = 0.9999;
38
38
  // Match higher for letters closer to the beginning of the word
39
39
  // If the word has more characters than the user typed, it should
40
- // be penalised slightly.
40
+ // be penalized slightly.
41
41
  //
42
42
  // i.e. "html" is more likely than "html5" if I type "html".
43
43
  //
@@ -50,23 +50,21 @@ const IS_GAP_REGEXP = /[\\/_+.#"@[({&]/;
50
50
  const COUNT_GAPS_REGEXP = /[\\/_+.#"@[({&]/g;
51
51
  const IS_SPACE_REGEXP = /[\s-]/;
52
52
  const COUNT_SPACE_REGEXP = /[\s-]/g;
53
- function commandScoreInner(string, abbreviation, lowerString, lowerAbbreviation, stringIndex, abbreviationIndex, memoizedResults) {
53
+ function computeCommandScoreInner(string, abbreviation, lowerString, lowerAbbreviation, stringIndex, abbreviationIndex, memoizedResults) {
54
54
  if (abbreviationIndex === abbreviation.length) {
55
- if (stringIndex === string.length) {
55
+ if (stringIndex === string.length)
56
56
  return SCORE_CONTINUE_MATCH;
57
- }
58
57
  return PENALTY_NOT_COMPLETE;
59
58
  }
60
59
  const memoizeKey = `${stringIndex},${abbreviationIndex}`;
61
- if (memoizedResults[memoizeKey] !== undefined) {
60
+ if (memoizedResults[memoizeKey] !== undefined)
62
61
  return memoizedResults[memoizeKey];
63
- }
64
62
  const abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex);
65
63
  let index = lowerString.indexOf(abbreviationChar, stringIndex);
66
64
  let highScore = 0;
67
65
  let score, transposedScore, wordBreaks, spaceBreaks;
68
66
  while (index >= 0) {
69
- score = commandScoreInner(string, abbreviation, lowerString, lowerAbbreviation, index + 1, abbreviationIndex + 1, memoizedResults);
67
+ score = computeCommandScoreInner(string, abbreviation, lowerString, lowerAbbreviation, index + 1, abbreviationIndex + 1, memoizedResults);
70
68
  if (score > highScore) {
71
69
  if (index === stringIndex) {
72
70
  score *= SCORE_CONTINUE_MATCH;
@@ -99,9 +97,9 @@ function commandScoreInner(string, abbreviation, lowerString, lowerAbbreviation,
99
97
  lowerString.charAt(index - 1) ===
100
98
  lowerAbbreviation.charAt(abbreviationIndex + 1)) ||
101
99
  (lowerAbbreviation.charAt(abbreviationIndex + 1) ===
102
- lowerAbbreviation.charAt(abbreviationIndex) && // allow duplicate letters. Ref #7428
100
+ lowerAbbreviation.charAt(abbreviationIndex) &&
103
101
  lowerString.charAt(index - 1) !== lowerAbbreviation.charAt(abbreviationIndex))) {
104
- transposedScore = commandScoreInner(string, abbreviation, lowerString, lowerAbbreviation, index + 1, abbreviationIndex + 2, memoizedResults);
102
+ transposedScore = computeCommandScoreInner(string, abbreviation, lowerString, lowerAbbreviation, index + 1, abbreviationIndex + 2, memoizedResults);
105
103
  if (transposedScore * SCORE_TRANSPOSITION > score) {
106
104
  score = transposedScore * SCORE_TRANSPOSITION;
107
105
  }
@@ -114,15 +112,49 @@ function commandScoreInner(string, abbreviation, lowerString, lowerAbbreviation,
114
112
  memoizedResults[memoizeKey] = highScore;
115
113
  return highScore;
116
114
  }
115
+ /**
116
+ *
117
+ * @param string
118
+ * @returns
119
+ */
117
120
  function formatInput(string) {
118
121
  // convert all valid space characters to space so they match each other
119
122
  return string.toLowerCase().replace(COUNT_SPACE_REGEXP, " ");
120
123
  }
121
- export function commandScore(string, abbreviation, aliases) {
122
- /* NOTE:
123
- * in the original, we used to do the lower-casing on each recursive call, but this meant that toLowerCase()
124
- * was the dominating cost in the algorithm, passing both is a little ugly, but considerably faster.
124
+ /**
125
+ * Given a command, a search query, and (optionally) a list of keywords for the command,
126
+ * computes a score between 0 and 1 that represents how well the search query matches the
127
+ * abbreviation and keywords. 1 is a perfect match, 0 is no match.
128
+ *
129
+ * The score is calculated based on the following rules:
130
+ * - The scores are arranged so that a continuous match of characters will result in a total
131
+ * score of 1. The best case, this character is a match, and either this is the start of the string
132
+ * or the previous character was also a match.
133
+ * - A new match at the start of a word scores better than a new match elsewhere as it's more likely
134
+ * that the user will type the starts of fragments.
135
+ * - Word jumps between spaces are scored slightly higher than slashes, brackets, hyphens, etc.
136
+ * - A continuous match of characters will result in a total score of 1.
137
+ * - A new match at the start of a word scores better than a new match elsewhere as it's more likely that the user will type the starts of fragments.
138
+ * - Any other match isn't ideal, but we include it for completeness.
139
+ * - If the user transposed two letters, it should be significantly penalized.
140
+ * - The goodness of a match should decay slightly with each missing character.
141
+ * - Match higher for letters closer to the beginning of the word.
142
+ *
143
+ * @param command - The value to score against the search string (e.g. a command name like "Calculator")
144
+ * @param search - The search string to score against the value/aliases
145
+ * @param commandKeywords - An optional list of aliases/keywords to score against the search string - e.g. ["math", "add", "divide", "multiply", "subtract"]
146
+ * @returns A score between 0 and 1 that represents how well the search string matches the
147
+ * command (and keywords)
148
+ */
149
+ export function computeCommandScore(command, search, commandKeywords) {
150
+ /**
151
+ * NOTE: We used to do lower-casing on each recursive call, but this meant that `toLowerCase()`
152
+ * was the dominating cost in the algorithm. Passing both is a little ugly, but considerably
153
+ * faster.
125
154
  */
126
- string = aliases && aliases.length > 0 ? `${`${string} ${aliases?.join(" ")}`}` : string;
127
- return commandScoreInner(string, abbreviation, formatInput(string), formatInput(abbreviation), 0, 0, {});
155
+ command =
156
+ commandKeywords && commandKeywords.length > 0
157
+ ? `${`${command} ${commandKeywords?.join(" ")}`}`
158
+ : command;
159
+ return computeCommandScoreInner(command, search, formatInput(command), formatInput(search), 0, 0, {});
128
160
  }
@@ -1 +1,2 @@
1
1
  export * as Command from "./exports.js";
2
+ export { computeCommandScore } from "./compute-command-score.js";
@@ -1 +1,2 @@
1
1
  export * as Command from "./exports.js";
2
+ export { computeCommandScore } from "./compute-command-score.js";
@@ -32,7 +32,8 @@ export type CommandRootPropsWithoutHTML = WithChild<{
32
32
  * with `1` being a perfect match, and `0` being no match, resulting
33
33
  * in the item being hidden entirely.
34
34
  *
35
- * By default, it will use the `command-score` package to score.
35
+ * By default, it will use the `computeCommandScore` function exported
36
+ * by this package to compute the score.
36
37
  */
37
38
  filter?: (value: string, search: string, keywords?: string[]) => number;
38
39
  /**
@@ -7,7 +7,7 @@ export { Calendar } from "./calendar/index.js";
7
7
  export { Checkbox } from "./checkbox/index.js";
8
8
  export { Collapsible } from "./collapsible/index.js";
9
9
  export { Combobox } from "./combobox/index.js";
10
- export { Command } from "./command/index.js";
10
+ export { Command, computeCommandScore } from "./command/index.js";
11
11
  export { ContextMenu } from "./context-menu/index.js";
12
12
  export { DateField } from "./date-field/index.js";
13
13
  export { DatePicker } from "./date-picker/index.js";
@@ -7,7 +7,7 @@ export { Calendar } from "./calendar/index.js";
7
7
  export { Checkbox } from "./checkbox/index.js";
8
8
  export { Collapsible } from "./collapsible/index.js";
9
9
  export { Combobox } from "./combobox/index.js";
10
- export { Command } from "./command/index.js";
10
+ export { Command, computeCommandScore } from "./command/index.js";
11
11
  export { ContextMenu } from "./context-menu/index.js";
12
12
  export { DateField } from "./date-field/index.js";
13
13
  export { DatePicker } from "./date-picker/index.js";
@@ -207,7 +207,7 @@ class TooltipContentState {
207
207
  useGraceArea({
208
208
  triggerNode: () => this.root.triggerNode,
209
209
  contentNode: () => this.root.contentNode,
210
- enabled: () => this.root.opts.open.current && this.root.disableHoverableContent,
210
+ enabled: () => this.root.opts.open.current && !this.root.disableHoverableContent,
211
211
  onPointerExit: () => {
212
212
  this.root.handleClose();
213
213
  },
@@ -1,12 +1,14 @@
1
- export type FocusScopeAPI = {
1
+ export interface FocusScopeAPI {
2
2
  id: string;
3
3
  paused: boolean;
4
4
  pause: () => void;
5
5
  resume: () => void;
6
- };
6
+ isHandlingFocus: boolean;
7
+ }
7
8
  export declare function createFocusScopeStack(): {
8
9
  add(focusScope: FocusScopeAPI): void;
9
10
  remove(focusScope: FocusScopeAPI): void;
11
+ readonly current: FocusScopeAPI[];
10
12
  };
11
13
  export declare function createFocusScopeAPI(): FocusScopeAPI;
12
14
  export declare function removeLinks(items: HTMLElement[]): HTMLElement[];
@@ -2,31 +2,38 @@ import { box } from "svelte-toolbelt";
2
2
  import { useId } from "../../../internal/use-id.js";
3
3
  const focusStack = box([]);
4
4
  export function createFocusScopeStack() {
5
- const stack = focusStack;
6
5
  return {
7
6
  add(focusScope) {
8
- // pause the currently active focus scope (top of the stack)
9
- const activeFocusScope = stack.current[0];
10
- if (focusScope.id !== activeFocusScope?.id) {
11
- activeFocusScope?.pause();
7
+ const activeFocusScope = focusStack.current[0];
8
+ if (activeFocusScope && focusScope.id !== activeFocusScope.id) {
9
+ activeFocusScope.pause();
12
10
  }
13
- // remove in case it already exists because it'll be added to the top
14
- stack.current = removeFromFocusScopeArray(stack.current, focusScope);
15
- stack.current.unshift(focusScope);
11
+ focusStack.current = removeFromFocusScopeArray(focusStack.current, focusScope);
12
+ focusStack.current.unshift(focusScope);
16
13
  },
17
14
  remove(focusScope) {
18
- stack.current = removeFromFocusScopeArray(stack.current, focusScope);
19
- stack.current[0]?.resume();
15
+ focusStack.current = removeFromFocusScopeArray(focusStack.current, focusScope);
16
+ focusStack.current[0]?.resume();
17
+ },
18
+ get current() {
19
+ return focusStack.current;
20
20
  },
21
21
  };
22
22
  }
23
23
  export function createFocusScopeAPI() {
24
24
  let paused = $state(false);
25
+ let isHandlingFocus = $state(false);
25
26
  return {
26
27
  id: useId(),
27
28
  get paused() {
28
29
  return paused;
29
30
  },
31
+ get isHandlingFocus() {
32
+ return isHandlingFocus;
33
+ },
34
+ set isHandlingFocus(value) {
35
+ isHandlingFocus = value;
36
+ },
30
37
  pause() {
31
38
  paused = true;
32
39
  },
@@ -21,69 +21,57 @@ export function useFocusScope({ id, loop, enabled, onOpenAutoFocus, onCloseAutoF
21
21
  const focusScope = createFocusScopeAPI();
22
22
  const ref = box(null);
23
23
  const ctx = FocusScopeContext.getOr({ ignoreCloseAutoFocus: false });
24
+ let lastFocusedElement = null;
24
25
  useRefById({
25
26
  id,
26
27
  ref,
27
28
  deps: () => enabled.current,
28
29
  });
29
- let lastFocusedElement = null;
30
- watch([() => ref.current, () => enabled.current], ([container, enabled]) => {
31
- if (!container || !enabled)
30
+ function manageFocus(event) {
31
+ if (focusScope.paused || !ref.current || focusScope.isHandlingFocus)
32
32
  return;
33
- const handleFocusIn = (event) => {
34
- if (focusScope.paused || !container)
35
- return;
33
+ focusScope.isHandlingFocus = true;
34
+ try {
36
35
  const target = event.target;
37
36
  if (!isHTMLElement(target))
38
37
  return;
39
- if (container.contains(target)) {
40
- lastFocusedElement = target;
41
- }
42
- else {
43
- if (ctx.ignoreCloseAutoFocus)
44
- return;
45
- focus(lastFocusedElement, { select: true });
46
- }
47
- };
48
- const handleFocusOut = (event) => {
49
- if (focusScope.paused || !container || ctx.ignoreCloseAutoFocus) {
50
- return;
51
- }
52
- const relatedTarget = event.relatedTarget;
53
- if (!isHTMLElement(relatedTarget))
54
- return;
55
- // A `focusout` event with a `null` `relatedTarget` will happen in at least two cases:
56
- //
57
- // 1. When the user switches app/tabs/windows/the browser itself loses focus.
58
- // 2. In Google Chrome, when the focused element is removed from the DOM.
59
- //
60
- // We let the browser do its thing here because:
61
- //
62
- // 1. The browser already keeps a memory of what's focused for when the
63
- // page gets refocused.
64
- // 2. In Google Chrome, if we try to focus the deleted focused element it throws
65
- // the CPU to 100%, so we avoid doing anything for this reason here too.
66
- if (relatedTarget === null)
67
- return;
68
- // If the focus has moved to an actual legitimate element (`relatedTarget !== null`)
69
- // that is outside the container, we move focus to the last valid focused element inside.
70
- if (!container.contains(relatedTarget)) {
71
- focus(lastFocusedElement, { select: true });
38
+ const isWithinActiveScope = ref.current.contains(target);
39
+ if (event.type === "focusin") {
40
+ if (isWithinActiveScope) {
41
+ lastFocusedElement = target;
42
+ }
43
+ else {
44
+ if (ctx.ignoreCloseAutoFocus)
45
+ return;
46
+ focus(lastFocusedElement, { select: true });
47
+ }
72
48
  }
73
- };
74
- // When the focused element gets removed from the DOM, browsers move focus
75
- // back to the document.body. In this case, we move focus to the container
76
- // to keep focus trapped correctly.
77
- // instead of leaning on document.activeElement, we use lastFocusedElement to check
78
- // if the element still exists inside the container,
79
- // if not then we focus to the container
80
- const handleMutations = (_) => {
81
- const lastFocusedElementExists = container?.contains(lastFocusedElement);
82
- if (!lastFocusedElementExists) {
83
- focus(container);
49
+ else if (event.type === "focusout") {
50
+ if (!isWithinActiveScope && !ctx.ignoreCloseAutoFocus) {
51
+ focus(lastFocusedElement, { select: true });
52
+ }
84
53
  }
85
- };
86
- const removeEvents = executeCallbacks(on(document, "focusin", handleFocusIn), on(document, "focusout", handleFocusOut));
54
+ }
55
+ finally {
56
+ focusScope.isHandlingFocus = false;
57
+ }
58
+ }
59
+ // When the focused element gets removed from the DOM, browsers move focus
60
+ // back to the document.body. In this case, we move focus to the container
61
+ // to keep focus trapped correctly.
62
+ // instead of leaning on document.activeElement, we use lastFocusedElement to check
63
+ // if the element still exists inside the container,
64
+ // if not then we focus to the container
65
+ function handleMutations(_) {
66
+ const lastFocusedElementExists = ref.current?.contains(lastFocusedElement);
67
+ if (!lastFocusedElementExists && ref.current) {
68
+ focus(ref.current);
69
+ }
70
+ }
71
+ watch([() => ref.current, () => enabled.current], ([container, enabled]) => {
72
+ if (!container || !enabled)
73
+ return;
74
+ const removeEvents = executeCallbacks(on(document, "focusin", manageFocus), on(document, "focusout", manageFocus));
87
75
  const mutationObserver = new MutationObserver(handleMutations);
88
76
  mutationObserver.observe(container, { childList: true, subtree: true });
89
77
  return () => {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { Accordion, AlertDialog, AspectRatio, Avatar, Button, Calendar, Checkbox, Collapsible, Combobox, Command, ContextMenu, DateField, DatePicker, DateRangeField, DateRangePicker, Dialog, DropdownMenu, Label, LinkPreview, Menubar, NavigationMenu, Pagination, PinInput, Popover, Progress, RadioGroup, RangeCalendar, ScrollArea, Select, Separator, Slider, Switch, Tabs, Toggle, ToggleGroup, Toolbar, Tooltip, Portal, IsUsingKeyboard, } from "./bits/index.js";
1
+ export { Accordion, AlertDialog, AspectRatio, Avatar, Button, Calendar, Checkbox, Collapsible, Combobox, Command, ContextMenu, DateField, DatePicker, DateRangeField, DateRangePicker, Dialog, DropdownMenu, Label, LinkPreview, Menubar, NavigationMenu, Pagination, PinInput, Popover, Progress, RadioGroup, RangeCalendar, ScrollArea, Select, Separator, Slider, Switch, Tabs, Toggle, ToggleGroup, Toolbar, Tooltip, Portal, IsUsingKeyboard, computeCommandScore, } from "./bits/index.js";
2
2
  export * from "./shared/index.js";
3
3
  export type * from "./shared/index.js";
4
4
  export * from "./types.js";
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- export { Accordion, AlertDialog, AspectRatio, Avatar, Button, Calendar, Checkbox, Collapsible, Combobox, Command, ContextMenu, DateField, DatePicker, DateRangeField, DateRangePicker, Dialog, DropdownMenu, Label, LinkPreview, Menubar, NavigationMenu, Pagination, PinInput, Popover, Progress, RadioGroup, RangeCalendar, ScrollArea, Select, Separator, Slider, Switch, Tabs, Toggle, ToggleGroup, Toolbar, Tooltip, Portal, IsUsingKeyboard, } from "./bits/index.js";
1
+ export { Accordion, AlertDialog, AspectRatio, Avatar, Button, Calendar, Checkbox, Collapsible, Combobox, Command, ContextMenu, DateField, DatePicker, DateRangeField, DateRangePicker, Dialog, DropdownMenu, Label, LinkPreview, Menubar, NavigationMenu, Pagination, PinInput, Popover, Progress, RadioGroup, RangeCalendar, ScrollArea, Select, Separator, Slider, Switch, Tabs, Toggle, ToggleGroup, Toolbar, Tooltip, Portal, IsUsingKeyboard, computeCommandScore, } from "./bits/index.js";
2
2
  export * from "./shared/index.js";
3
3
  export * from "./types.js";
@@ -33,6 +33,8 @@ export function focusWithoutScroll(element) {
33
33
  export function focus(element, { select = false } = {}) {
34
34
  if (!(element && element.focus))
35
35
  return;
36
+ if (document.activeElement === element)
37
+ return;
36
38
  const previouslyFocusedElement = document.activeElement;
37
39
  // prevent scroll on focus
38
40
  element.focus({ preventScroll: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bits-ui",
3
- "version": "1.0.0-next.91",
3
+ "version": "1.0.0-next.93",
4
4
  "license": "MIT",
5
5
  "repository": "github:huntabyte/bits-ui",
6
6
  "funding": "https://github.com/sponsors/huntabyte",
@@ -1 +0,0 @@
1
- export declare function commandScore(string: string, abbreviation: string, aliases?: string[]): number;