bits-ui 1.4.1 → 1.4.2

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.
@@ -54,7 +54,7 @@
54
54
  trapFocus,
55
55
  open: contentState.root.opts.open.current,
56
56
  })}
57
- {...mergedProps}
57
+ {id}
58
58
  onCloseAutoFocus={(e) => {
59
59
  onCloseAutoFocus(e);
60
60
  if (e.defaultPrevented) return;
@@ -28,7 +28,6 @@ declare class CommandRootState {
28
28
  labelNode: HTMLElement | null;
29
29
  commandState: CommandState;
30
30
  _commandState: CommandState;
31
- searchHasHadValue: boolean;
32
31
  setState<K extends keyof CommandState>(key: K, value: CommandState[K], opts?: boolean): void;
33
32
  constructor(opts: CommandRootStateProps);
34
33
  /**
@@ -104,7 +103,7 @@ declare class CommandRootState {
104
103
  * @param keywords - Optional search boost terms
105
104
  * @returns Cleanup function
106
105
  */
107
- registerValue(id: string, value: string, keywords?: string[]): () => void;
106
+ registerValue(value: string, keywords?: string[]): () => void;
108
107
  /**
109
108
  * Registers item in command list and its group.
110
109
  * Handles filtering, sorting and selection updates.
@@ -153,8 +152,8 @@ declare class CommandGroupContainerState {
153
152
  readonly opts: CommandGroupContainerStateProps;
154
153
  readonly root: CommandRootState;
155
154
  headingNode: HTMLElement | null;
156
- shouldRender: boolean;
157
155
  trueValue: string;
156
+ shouldRender: boolean;
158
157
  constructor(opts: CommandGroupContainerStateProps, root: CommandRootState);
159
158
  props: {
160
159
  readonly id: string;
@@ -5,7 +5,6 @@ import { kbd } from "../../internal/kbd.js";
5
5
  import { getAriaDisabled, getAriaExpanded, getAriaSelected, getDataDisabled, getDataSelected, } from "../../internal/attrs.js";
6
6
  import { getFirstNonCommentChild } from "../../internal/dom.js";
7
7
  import { computeCommandScore } from "./index.js";
8
- import { noop } from "../../internal/noop.js";
9
8
  // attributes
10
9
  const COMMAND_ROOT_ATTR = "data-command-root";
11
10
  const COMMAND_LIST_ATTR = "data-command-list";
@@ -60,8 +59,6 @@ class CommandRootState {
60
59
  commandState = $state.raw(defaultState);
61
60
  // internal state that we mutate in batches and publish to the `state` at once
62
61
  _commandState = $state(defaultState);
63
- // whether the search has had a value other than ""
64
- searchHasHadValue = $state(false);
65
62
  #snapshot() {
66
63
  return $state.snapshot(this._commandState);
67
64
  }
@@ -87,10 +84,6 @@ class CommandRootState {
87
84
  // Filter synchronously before emitting back to children
88
85
  this.#filterItems();
89
86
  this.#sort();
90
- this.#selectFirstItem();
91
- afterTick(() => {
92
- this.#selectFirstItem();
93
- });
94
87
  }
95
88
  else if (key === "value") {
96
89
  // opts is a boolean referring to whether it should NOT be scrolled into view
@@ -108,11 +101,6 @@ class CommandRootState {
108
101
  this.commandState = defaults;
109
102
  useRefById(opts);
110
103
  this.onkeydown = this.onkeydown.bind(this);
111
- $effect(() => {
112
- if (this._commandState.search !== "") {
113
- this.searchHasHadValue = true;
114
- }
115
- });
116
104
  }
117
105
  /**
118
106
  * Calculates score for an item based on search text and keywords.
@@ -135,8 +123,10 @@ class CommandRootState {
135
123
  #sort() {
136
124
  if (!this._commandState.search || this.opts.shouldFilter.current === false) {
137
125
  // If no search and no selection yet, select first item
138
- if (!this.commandState.value)
139
- this.#selectFirstItem();
126
+ this.#selectFirstItem();
127
+ // if (!this.commandState.value) {
128
+ // this.#selectFirstItem();
129
+ // }
140
130
  return;
141
131
  }
142
132
  const scores = this._commandState.filtered.items;
@@ -161,8 +151,8 @@ class CommandRootState {
161
151
  // Sort groups to bottom (pushes all non-grouped items to the top)
162
152
  const listInsertionElement = this.viewportNode;
163
153
  const sorted = this.getValidItems().sort((a, b) => {
164
- const valueA = a.getAttribute("id");
165
- const valueB = b.getAttribute("id");
154
+ const valueA = a.getAttribute("data-value");
155
+ const valueB = b.getAttribute("data-value");
166
156
  const scoresA = scores.get(valueA) ?? 0;
167
157
  const scoresB = scores.get(valueB) ?? 0;
168
158
  return scoresB - scoresA;
@@ -191,6 +181,7 @@ class CommandRootState {
191
181
  const element = listInsertionElement?.querySelector(`${COMMAND_GROUP_SELECTOR}[${COMMAND_VALUE_ATTR}="${encodeURIComponent(group[0])}"]`);
192
182
  element?.parentElement?.appendChild(element);
193
183
  }
184
+ this.#selectFirstItem();
194
185
  }
195
186
  /**
196
187
  * Sets current value and triggers re-render if cleared.
@@ -399,11 +390,11 @@ class CommandRootState {
399
390
  * @param keywords - Optional search boost terms
400
391
  * @returns Cleanup function
401
392
  */
402
- registerValue(id, value, keywords) {
403
- if (value === this.allIds.get(id)?.value)
404
- return noop;
405
- this.allIds.set(id, { value, keywords });
406
- this._commandState.filtered.items.set(id, this.#score(value, keywords));
393
+ registerValue(value, keywords) {
394
+ if (!(value && value === this.allIds.get(value)?.value)) {
395
+ this.allIds.set(value, { value, keywords });
396
+ }
397
+ this._commandState.filtered.items.set(value, this.#score(value, keywords));
407
398
  // Schedule sorting to run after this tick when all items are added not each time an item is added
408
399
  if (!this.sortAfterTick) {
409
400
  this.sortAfterTick = true;
@@ -413,7 +404,7 @@ class CommandRootState {
413
404
  });
414
405
  }
415
406
  return () => {
416
- this.allIds.delete(id);
407
+ this.allIds.delete(value);
417
408
  };
418
409
  }
419
410
  /**
@@ -587,9 +578,7 @@ class CommandEmptyState {
587
578
  root;
588
579
  #isInitialRender = true;
589
580
  shouldRender = $derived.by(() => {
590
- return ((this.root._commandState.filtered.count === 0 &&
591
- this.#isInitialRender === false &&
592
- this.root.searchHasHadValue) ||
581
+ return ((this.root._commandState.filtered.count === 0 && this.#isInitialRender === false) ||
593
582
  this.opts.forceMount.current);
594
583
  });
595
584
  constructor(opts, root) {
@@ -613,6 +602,7 @@ class CommandGroupContainerState {
613
602
  opts;
614
603
  root;
615
604
  headingNode = $state(null);
605
+ trueValue = $state("");
616
606
  shouldRender = $derived.by(() => {
617
607
  if (this.opts.forceMount.current)
618
608
  return true;
@@ -620,9 +610,8 @@ class CommandGroupContainerState {
620
610
  return true;
621
611
  if (!this.root.commandState.search)
622
612
  return true;
623
- return this.root.commandState.filtered.groups.has(this.opts.id.current);
613
+ return this.root._commandState.filtered.groups.has(this.trueValue);
624
614
  });
625
- trueValue = $state("");
626
615
  constructor(opts, root) {
627
616
  this.opts = opts;
628
617
  this.root = root;
@@ -631,21 +620,21 @@ class CommandGroupContainerState {
631
620
  ...opts,
632
621
  deps: () => this.shouldRender,
633
622
  });
634
- watch(() => this.opts.id.current, () => {
635
- return this.root.registerGroup(this.opts.id.current);
623
+ watch(() => this.trueValue, () => {
624
+ return this.root.registerGroup(this.trueValue);
636
625
  });
637
626
  $effect(() => {
638
627
  if (this.opts.value.current) {
639
628
  this.trueValue = this.opts.value.current;
640
- return this.root.registerValue(this.opts.id.current, this.opts.value.current);
629
+ return this.root.registerValue(this.opts.value.current);
641
630
  }
642
631
  else if (this.headingNode && this.headingNode.textContent) {
643
632
  this.trueValue = this.headingNode.textContent.trim().toLowerCase();
644
- return this.root.registerValue(this.opts.id.current, this.trueValue);
633
+ return this.root.registerValue(this.trueValue);
645
634
  }
646
635
  else if (this.opts.ref.current?.textContent) {
647
636
  this.trueValue = this.opts.ref.current.textContent.trim().toLowerCase();
648
- return this.root.registerValue(this.opts.id.current, this.trueValue);
637
+ return this.root.registerValue(this.trueValue);
649
638
  }
650
639
  });
651
640
  }
@@ -750,7 +739,7 @@ class CommandItemState {
750
739
  !this.root.commandState.search) {
751
740
  return true;
752
741
  }
753
- const currentScore = this.root.commandState.filtered.items.get(this.opts.id.current);
742
+ const currentScore = this.root.commandState.filtered.items.get(this.trueValue);
754
743
  if (currentScore === undefined)
755
744
  return false;
756
745
  return currentScore > 0;
@@ -766,23 +755,20 @@ class CommandItemState {
766
755
  deps: () => Boolean(this.root.commandState.search),
767
756
  });
768
757
  watch([
769
- () => this.opts.id.current,
770
- () => this.#group?.opts.id.current,
758
+ () => this.trueValue,
759
+ () => this.#group?.trueValue,
771
760
  () => this.opts.forceMount.current,
772
- () => this.opts.ref.current,
773
761
  ], () => {
774
762
  if (this.opts.forceMount.current)
775
763
  return;
776
- return this.root.registerItem(this.opts.id.current, this.#group?.opts.id.current);
764
+ return this.root.registerItem(this.trueValue, this.#group?.trueValue);
777
765
  });
778
766
  watch([() => this.opts.value.current, () => this.opts.ref.current], () => {
779
- if (!this.opts.ref.current)
780
- return;
781
- if (!this.opts.value.current && this.opts.ref.current.textContent) {
767
+ if (!this.opts.value.current && this.opts.ref.current?.textContent) {
782
768
  this.trueValue = this.opts.ref.current.textContent.trim();
783
769
  }
784
- this.root.registerValue(this.opts.id.current, this.trueValue, opts.keywords.current.map((kw) => kw.trim()));
785
- this.opts.ref.current.setAttribute(COMMAND_VALUE_ATTR, this.trueValue);
770
+ this.root.registerValue(this.trueValue, opts.keywords.current.map((kw) => kw.trim()));
771
+ this.opts.ref.current?.setAttribute(COMMAND_VALUE_ATTR, this.trueValue);
786
772
  });
787
773
  // bindings
788
774
  this.onclick = this.onclick.bind(this);
@@ -8,7 +8,7 @@ export type CommandState = {
8
8
  filtered: {
9
9
  /** The count of all visible items. */
10
10
  count: number;
11
- /** Map from visible item id to its search store. */
11
+ /** Map from visible item value to its search store. */
12
12
  items: Map<string, number>;
13
13
  /** Set of groups with at least one visible item. */
14
14
  groups: Set<string>;
@@ -19,6 +19,7 @@
19
19
  ref = $bindable(null),
20
20
  forceMount = false,
21
21
  onCloseAutoFocus = noop,
22
+ onOpenAutoFocus = noop,
22
23
  onEscapeKeydown = noop,
23
24
  onInteractOutside = noop,
24
25
  trapFocus = true,
@@ -52,7 +53,8 @@
52
53
  trapFocus,
53
54
  open: contentState.root.opts.open.current,
54
55
  })}
55
- {...mergedProps}
56
+ {onOpenAutoFocus}
57
+ {id}
56
58
  onCloseAutoFocus={(e) => {
57
59
  onCloseAutoFocus(e);
58
60
  if (e.defaultPrevented) return;
@@ -9,7 +9,6 @@ type DialogRootStateProps = WritableBoxedValues<{
9
9
  declare class DialogRootState {
10
10
  readonly opts: DialogRootStateProps;
11
11
  triggerNode: HTMLElement | null;
12
- titleNode: HTMLElement | null;
13
12
  contentNode: HTMLElement | null;
14
13
  descriptionNode: HTMLElement | null;
15
14
  contentId: string | undefined;
@@ -95,7 +94,6 @@ declare class DialogTitleState {
95
94
  props: {
96
95
  readonly "data-state": "open" | "closed";
97
96
  readonly id: string;
98
- readonly role: "heading";
99
97
  readonly "aria-level": 1 | 2 | 3 | 4 | 5 | 6;
100
98
  };
101
99
  }
@@ -17,7 +17,6 @@ function createAttrs(variant) {
17
17
  class DialogRootState {
18
18
  opts;
19
19
  triggerNode = $state(null);
20
- titleNode = $state(null);
21
20
  contentNode = $state(null);
22
21
  descriptionNode = $state(null);
23
22
  contentId = $state(undefined);
@@ -151,7 +150,6 @@ class DialogTitleState {
151
150
  useRefById({
152
151
  ...opts,
153
152
  onRefChange: (node) => {
154
- this.root.titleNode = node;
155
153
  this.root.titleId = node?.id;
156
154
  },
157
155
  deps: () => this.root.opts.open.current,
@@ -159,7 +157,7 @@ class DialogTitleState {
159
157
  }
160
158
  props = $derived.by(() => ({
161
159
  id: this.opts.id.current,
162
- role: "heading",
160
+ // role: "heading",
163
161
  "aria-level": this.opts.level.current,
164
162
  [this.root.attrs.title]: "",
165
163
  ...this.root.sharedProps,
@@ -56,15 +56,46 @@ export function useFocusScope({ id, loop, enabled, onOpenAutoFocus, onCloseAutoF
56
56
  focusScope.isHandlingFocus = false;
57
57
  }
58
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) {
59
+ /**
60
+ * Handles DOM mutations within the container. Specifically checks if the
61
+ * last known focused element inside the container has been removed. If so,
62
+ * and focus has escaped the container (likely moved to document.body),
63
+ * it refocuses the container itself to maintain the trap.
64
+ */
65
+ function handleMutations(mutations) {
66
+ // if there's no record of a last focused el, or container isn't mounted, bail
67
+ if (!lastFocusedElement || !ref.current)
68
+ return;
69
+ // track if the last focused element was removed
70
+ let elementWasRemoved = false;
71
+ for (const mutation of mutations) {
72
+ // we only care about mutations where nodes were removed
73
+ if (mutation.type === "childList" && mutation.removedNodes.length > 0) {
74
+ // check if any removed nodes are the last focused element or contain it
75
+ for (const removedNode of mutation.removedNodes) {
76
+ if (removedNode === lastFocusedElement) {
77
+ elementWasRemoved = true;
78
+ // found it directly
79
+ break;
80
+ }
81
+ // contains() only works on elements, so we need to check nodeType
82
+ if (removedNode.nodeType === Node.ELEMENT_NODE &&
83
+ removedNode.contains(lastFocusedElement)) {
84
+ elementWasRemoved = true;
85
+ // descendant found,
86
+ break;
87
+ }
88
+ }
89
+ }
90
+ // if we've confirmed removal in any mutation, bail
91
+ if (elementWasRemoved)
92
+ break;
93
+ }
94
+ /**
95
+ * If the element was removed and focus is now outside the container,
96
+ * (e.g., browser moved it to body), refocus the container.
97
+ */
98
+ if (elementWasRemoved && ref.current && !ref.current.contains(document.activeElement)) {
68
99
  focus(ref.current);
69
100
  }
70
101
  }
@@ -73,7 +104,11 @@ export function useFocusScope({ id, loop, enabled, onOpenAutoFocus, onCloseAutoF
73
104
  return;
74
105
  const removeEvents = executeCallbacks(on(document, "focusin", manageFocus), on(document, "focusout", manageFocus));
75
106
  const mutationObserver = new MutationObserver(handleMutations);
76
- mutationObserver.observe(container, { childList: true, subtree: true });
107
+ mutationObserver.observe(container, {
108
+ childList: true,
109
+ subtree: true,
110
+ attributes: false,
111
+ });
77
112
  return () => {
78
113
  removeEvents();
79
114
  mutationObserver.disconnect();
@@ -115,10 +150,11 @@ export function useFocusScope({ id, loop, enabled, onOpenAutoFocus, onCloseAutoF
115
150
  afterTick(() => {
116
151
  if (!container)
117
152
  return;
118
- focusFirst(removeLinks(getTabbableCandidates(container)), { select: true });
119
- if (document.activeElement === prevFocusedElement) {
153
+ const result = focusFirst(removeLinks(getTabbableCandidates(container)), {
154
+ select: true,
155
+ });
156
+ if (!result)
120
157
  focus(container);
121
- }
122
158
  });
123
159
  }
124
160
  }
@@ -51,9 +51,8 @@ export function focusFirst(candidates, { select = false } = {}) {
51
51
  const previouslyFocusedElement = document.activeElement;
52
52
  for (const candidate of candidates) {
53
53
  focus(candidate, { select });
54
- if (document.activeElement !== previouslyFocusedElement) {
54
+ if (document.activeElement !== previouslyFocusedElement)
55
55
  return true;
56
- }
57
56
  }
58
57
  }
59
58
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bits-ui",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
4
4
  "license": "MIT",
5
5
  "repository": "github:huntabyte/bits-ui",
6
6
  "funding": "https://github.com/sponsors/huntabyte",