@astryxdesign/core 0.7.2-canary.40c469b → 0.7.2-canary.b06a093

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.
@@ -532,7 +532,7 @@ export function PowerSearch({
532
532
  } else if (change.type === 'remove' && change.item) {
533
533
  const auxData = change.item.auxiliaryData;
534
534
  if (auxData?.filterIndex != null) {
535
- const newFilters = filters.filter((_, i) => i !== auxData.filterIndex);
535
+ const newFilters = filters.filter((_, i) => _items.some(item => item.auxiliaryData?.filterIndex === i));
536
536
  onChange(newFilters, 'remove', auxData.filterIndex);
537
537
  }
538
538
  }
@@ -1 +1 @@
1
- {"version":3,"file":"useTooltip.d.ts","sourceRoot":"","sources":["../../src/Tooltip/useTooltip.tsx"],"names":[],"mappings":"AAIA;;;;;;;;GAQG;AAEH,OAAO,EAIL,KAAK,SAAS,EACd,KAAK,WAAW,EACjB,MAAM,OAAO,CAAC;AAEf,OAAO,EAEL,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,cAAc,EACpB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAEL,KAAK,iBAAiB,EACvB,MAAM,0BAA0B,CAAC;AA2ClC;;GAEG;AACH,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,QAAQ,GAAG,OAAO,CAAC;AAE9D;;GAEG;AACH,MAAM,MAAM,mBAAmB,GAAG,iBAAiB,CAAC;AAEpD,MAAM,WAAW,cAAc;IAC7B;;;OAGG;IACH,SAAS,CAAC,EAAE,cAAc,CAAC;IAE3B;;;OAGG;IACH,SAAS,CAAC,EAAE,cAAc,CAAC;IAE3B;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAEnC;;;;;;;;;;;OAWG;IACH,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAEnC;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IAEpB;;;;;;;;;;OAUG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IAEjB;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IAExB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IAEpB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,MAAM,WAAW,aAAa;IAC5B;;;OAGG;IACH,GAAG,EAAE,WAAW,CAAC,WAAW,CAAC,CAAC;IAE9B;;;OAGG;IACH,WAAW,EAAE,WAAW,CAAC,WAAW,CAAC,CAAC;IAEtC;;;;OAIG;IACH,cAAc,EAAE,WAAW,CAAC,WAAW,CAAC,CAAC;IAEzC;;;OAGG;IACH,QAAQ,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,WAAW,EAAE,MAAM,CAAC;IAEpB;;;;;;;OAOG;IACH,aAAa,EAAE,CACb,QAAQ,EAAE,SAAS,EACnB,KAAK,CAAC,EAAE,IAAI,CAAC,kBAAkB,EAAE,aAAa,CAAC,KAC5C,SAAS,CAAC;CAChB;AAyBD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,UAAU,CAAC,OAAO,GAAE,cAAmB,GAAG,aAAa,CA6WtE"}
1
+ {"version":3,"file":"useTooltip.d.ts","sourceRoot":"","sources":["../../src/Tooltip/useTooltip.tsx"],"names":[],"mappings":"AAIA;;;;;;;;GAQG;AAEH,OAAO,EAIL,KAAK,SAAS,EACd,KAAK,WAAW,EACjB,MAAM,OAAO,CAAC;AAEf,OAAO,EAEL,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,cAAc,EACpB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAEL,KAAK,iBAAiB,EACvB,MAAM,0BAA0B,CAAC;AA2ClC;;GAEG;AACH,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,QAAQ,GAAG,OAAO,CAAC;AAE9D;;GAEG;AACH,MAAM,MAAM,mBAAmB,GAAG,iBAAiB,CAAC;AAEpD,MAAM,WAAW,cAAc;IAC7B;;;OAGG;IACH,SAAS,CAAC,EAAE,cAAc,CAAC;IAE3B;;;OAGG;IACH,SAAS,CAAC,EAAE,cAAc,CAAC;IAE3B;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAEnC;;;;;;;;;;;OAWG;IACH,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAEnC;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IAEpB;;;;;;;;;;OAUG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IAEjB;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IAExB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IAEpB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,MAAM,WAAW,aAAa;IAC5B;;;OAGG;IACH,GAAG,EAAE,WAAW,CAAC,WAAW,CAAC,CAAC;IAE9B;;;OAGG;IACH,WAAW,EAAE,WAAW,CAAC,WAAW,CAAC,CAAC;IAEtC;;;;OAIG;IACH,cAAc,EAAE,WAAW,CAAC,WAAW,CAAC,CAAC;IAEzC;;;OAGG;IACH,QAAQ,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,WAAW,EAAE,MAAM,CAAC;IAEpB;;;;;;;OAOG;IACH,aAAa,EAAE,CACb,QAAQ,EAAE,SAAS,EACnB,KAAK,CAAC,EAAE,IAAI,CAAC,kBAAkB,EAAE,aAAa,CAAC,KAC5C,SAAS,CAAC;CAChB;AAyBD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,UAAU,CAAC,OAAO,GAAE,cAAmB,GAAG,aAAa,CAwXtE"}
@@ -177,6 +177,17 @@ export function useTooltip(options = {}) {
177
177
  }, delay);
178
178
  }, [isEnabled, isOpen, clearTimeouts, showLayer, delay]);
179
179
 
180
+ // A show scheduled while the triggers were enabled must not outlive them:
181
+ // disabling also detaches the leave listener that would have cancelled it,
182
+ // so the timer would open a tooltip that nothing ever closes. Only the
183
+ // pending show is dropped; a pending hide still runs.
184
+ useEffect(() => {
185
+ if (!isEnabled && showTimeoutRef.current) {
186
+ clearTimeout(showTimeoutRef.current);
187
+ showTimeoutRef.current = null;
188
+ }
189
+ }, [isEnabled]);
190
+
180
191
  // Schedule hide with delay (suppressed when isOpen is true).
181
192
  // A small hover bridge (when hideDelay is 0) lets the pointer travel from the
182
193
  // trigger onto the tooltip surface without the tooltip vanishing — required
@@ -1 +1 @@
1
- {"version":3,"file":"BaseTypeahead.d.ts","sourceRoot":"","sources":["../../src/Typeahead/BaseTypeahead.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,EAMZ,KAAK,SAAS,EACd,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAIf,OAAO,KAAK,EAAC,YAAY,EAAC,MAAM,kBAAkB,CAAC;AA2BnD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAC,cAAc,EAAE,YAAY,EAAC,MAAM,SAAS,CAAC;AAW1D,MAAM,WAAW,kBAAkB,CAAC,CAAC,SAAS,cAAc,CAAE,SAAQ,IAAI,CACxE,SAAS,CAAC,WAAW,CAAC,EACtB,UAAU,CACX;IACC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC;IAClC;;OAEG;IACH,YAAY,EAAE,YAAY,CAAC,CAAC,CAAC,CAAC;IAE9B;;OAEG;IACH,KAAK,EAAE,CAAC,GAAG,IAAI,CAAC;IAEhB;;OAEG;IACH,QAAQ,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,IAAI,KAAK,IAAI,CAAC;IAEnC;;OAEG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,SAAS,CAAC;IAEpC;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAE5B;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB,mEAAmE;IACnE,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;;;;;;;;OAUG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB;;;;;;;;;;;;;OAaG;IACH,eAAe,CAAC,EAAE,SAAS,CAAC;IAE5B;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IAErB;;;;;;;;;OASG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAE9B;;;OAGG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IAEvB;;OAEG;IACH,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAExC;;OAEG;IACH,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IAEzC;;;;;;;;;;;;;;;;;;;OAmBG;IACH,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,EAAE,KAAK,CAAC,EAAE,CAAC;IAEtD;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB;;OAEG;IACH,WAAW,CAAC,EAAE,YAAY,CAAC;IAE3B;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IAEvB;;;;OAIG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAE1C;;;OAGG;IACH,SAAS,CAAC,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC,gBAAgB,CAAC,KAAK,IAAI,CAAC;IAE/D;;;;OAIG;IACH,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;CAC3B;AAoKD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,aAAa,EAo1BrB,CAAC,CAAC,SAAS,cAAc,EAC5B,KAAK,EAAE,kBAAkB,CAAC,CAAC,CAAC,KACzB,KAAK,CAAC,YAAY,CAAC"}
1
+ {"version":3,"file":"BaseTypeahead.d.ts","sourceRoot":"","sources":["../../src/Typeahead/BaseTypeahead.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,EAMZ,KAAK,SAAS,EACd,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAIf,OAAO,KAAK,EAAC,YAAY,EAAC,MAAM,kBAAkB,CAAC;AA2BnD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAC,cAAc,EAAE,YAAY,EAAC,MAAM,SAAS,CAAC;AAW1D,MAAM,WAAW,kBAAkB,CAAC,CAAC,SAAS,cAAc,CAAE,SAAQ,IAAI,CACxE,SAAS,CAAC,WAAW,CAAC,EACtB,UAAU,CACX;IACC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC;IAClC;;OAEG;IACH,YAAY,EAAE,YAAY,CAAC,CAAC,CAAC,CAAC;IAE9B;;OAEG;IACH,KAAK,EAAE,CAAC,GAAG,IAAI,CAAC;IAEhB;;OAEG;IACH,QAAQ,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,IAAI,KAAK,IAAI,CAAC;IAEnC;;OAEG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,SAAS,CAAC;IAEpC;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAE5B;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB,mEAAmE;IACnE,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;;;;;;;;OAUG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB;;;;;;;;;;;;;OAaG;IACH,eAAe,CAAC,EAAE,SAAS,CAAC;IAE5B;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IAErB;;;;;;;;;OASG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAE9B;;;OAGG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IAEvB;;OAEG;IACH,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAExC;;OAEG;IACH,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IAEzC;;;;;;;;;;;;;;;;;;;OAmBG;IACH,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,EAAE,KAAK,CAAC,EAAE,CAAC;IAEtD;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB;;OAEG;IACH,WAAW,CAAC,EAAE,YAAY,CAAC;IAE3B;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IAEvB;;;;OAIG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAE1C;;;OAGG;IACH,SAAS,CAAC,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC,gBAAgB,CAAC,KAAK,IAAI,CAAC;IAE/D;;;;OAIG;IACH,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;CAC3B;AAoKD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,aAAa,EA82BrB,CAAC,CAAC,SAAS,cAAc,EAC5B,KAAK,EAAE,kBAAkB,CAAC,CAAC,CAAC,KACzB,KAAK,CAAC,YAAY,CAAC"}
@@ -340,6 +340,32 @@ export const BaseTypeahead = function BaseTypeahead({
340
340
  sourceGenRef.current = ++searchGenRef.current;
341
341
  }
342
342
 
343
+ // The previous source is remembered in state, not a ref, so a render React
344
+ // discards cannot consume the comparison without its state updates.
345
+ const [sourceOfState, setSourceOfState] = useState(searchSource);
346
+ if (sourceOfState !== searchSource) {
347
+ setSourceOfState(searchSource);
348
+ if (isLoading) {
349
+ setIsLoading(false);
350
+ setHasSearched(false);
351
+ }
352
+ loadingRef.current = false;
353
+ }
354
+
355
+ // When the search source is replaced, any in-flight load from the previous
356
+ // source is abandoned (generation bumped above). Notify the wrapper's busy lane
357
+ // in an effect so external store listeners run safely after commit.
358
+ const isInitialMountRef = useRef(true);
359
+ useEffect(() => {
360
+ if (isInitialMountRef.current) {
361
+ isInitialMountRef.current = false;
362
+ return;
363
+ }
364
+ if (!loadingRef.current) {
365
+ onLoadingChangeRef.current?.(false);
366
+ }
367
+ }, [searchSource]);
368
+
343
369
  // Layer for dropdown
344
370
  const handleLayerShow = useCallback(() => {
345
371
  onOpenChange?.(true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.7.2-canary.40c469b",
3
+ "version": "0.7.2-canary.b06a093",
4
4
  "displayName": "Astryx Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -787,8 +787,8 @@
787
787
  "react-dom": ">=19.0.0"
788
788
  },
789
789
  "devDependencies": {
790
- "@astryxdesign/a11y-spec": "0.7.2-canary.40c469b",
791
- "@astryxdesign/cli": "0.7.2-canary.40c469b",
790
+ "@astryxdesign/a11y-spec": "0.7.2-canary.b06a093",
791
+ "@astryxdesign/cli": "0.7.2-canary.b06a093",
792
792
  "@babel/cli": "^7.29.7",
793
793
  "@babel/core": "^7.29.7",
794
794
  "@babel/preset-react": "^7.29.7",
@@ -32,7 +32,7 @@ export const docs = {
32
32
  name: 'options.selectableItems',
33
33
  type: 'MultiSelectorOptionData[]',
34
34
  description:
35
- 'The options in the same order as they render. Options with `disabled` are skipped by the navigation keys and cannot be toggled.',
35
+ 'The options in the same order as they render. Options with `disabled` cannot be toggled and are skipped while moving through the open list; ArrowDown or ArrowUp that opens the list still lands on the first or last option.',
36
36
  required: true,
37
37
  },
38
38
  {
@@ -72,7 +72,7 @@ export const docs = {
72
72
  name: 'options.hasSearch',
73
73
  type: 'boolean',
74
74
  description:
75
- 'Whether the list has its own search field. Space then types instead of toggling, opening does not pre-highlight, Delete and Backspace edit the query, and type-to-select is off.',
75
+ 'Whether the list has its own search field. Space then types instead of toggling, opening by a press or Enter does not pre-highlight (ArrowDown and ArrowUp still open on the first and last option), Delete and Backspace edit the query, and type-to-select is off. Home and End still jump to the first and last option, so a search input that keeps them for the caret should stop them before this handler and rely on PageUp and PageDown.',
76
76
  default: 'false',
77
77
  },
78
78
  {
@@ -206,7 +206,7 @@ export const docsDense = {
206
206
  paramDescriptions: {
207
207
  options: 'config.',
208
208
  'options.selectableItems':
209
- 'options in render order; disabled ones skipped by nav, not toggleable.',
209
+ 'options in render order; disabled ones not toggleable, skipped while moving in open list (ArrowDown/Up opening still lands first / last).',
210
210
  'options.isOpen': 'list shown; caller owns state.',
211
211
  'options.onOpen':
212
212
  'show list; return false to refuse (highlight untouched).',
@@ -215,7 +215,7 @@ export const docsDense = {
215
215
  'options.listboxId':
216
216
  'listbox / grid id; option + action-cell ids derive from it.',
217
217
  'options.hasSearch':
218
- 'own search field: Space types, no pre-highlight, Delete / Backspace edit query, no type-to-select.',
218
+ 'own search field: Space types, press / Enter opening does not pre-highlight (ArrowDown/Up still first / last), Delete / Backspace edit query, no type-to-select. Home / End still jump; stop them first to keep them for the caret, use PageUp / PageDown.',
219
219
  'options.onClear':
220
220
  'clear all; Delete / Backspace on closed control. ignored when open or hasSearch.',
221
221
  'options.hasValue': 'anything selected; clear skipped when false.',
@@ -165,6 +165,91 @@ describe('PowerSearch', () => {
165
165
  expect(screen.getByRole('combobox')).toHaveFocus();
166
166
  });
167
167
 
168
+ it('clears all added filters when the clear button is clicked', async () => {
169
+ const user = userEvent.setup();
170
+ render(<PowerSearchWrapper config={config} />);
171
+ const input = screen.getByRole('combobox', {name: 'Search'});
172
+ await user.type(input, 'status open');
173
+ await user.click(
174
+ await screen.findByRole('option', {name: 'Status is Open', hidden: true}),
175
+ );
176
+ await user.type(input, 'title login');
177
+ await user.click(
178
+ await screen.findByRole('option', {
179
+ name: 'Title contains "login"',
180
+ hidden: true,
181
+ }),
182
+ );
183
+
184
+ await user.type(input, 'status closed');
185
+ await user.click(
186
+ await screen.findByRole('option', {
187
+ name: 'Status is Closed',
188
+ hidden: true,
189
+ }),
190
+ );
191
+
192
+ expect(screen.getAllByRole('button', {name: /^Remove /})).toHaveLength(3);
193
+
194
+ await user.click(screen.getByRole('button', {name: 'Clear all'}));
195
+
196
+ expect(screen.queryAllByRole('button', {name: /^Remove /})).toHaveLength(0);
197
+ expect(
198
+ screen.queryByRole('button', {name: 'Clear all'}),
199
+ ).not.toBeInTheDocument();
200
+ });
201
+
202
+ it('clicking the remove button on a filter removes only that filter', async () => {
203
+ const user = userEvent.setup();
204
+ render(<PowerSearchWrapper config={config} />);
205
+ const input = screen.getByRole('combobox', {name: 'Search'});
206
+ await user.type(input, 'status open');
207
+ await user.click(
208
+ await screen.findByRole('option', {name: 'Status is Open', hidden: true}),
209
+ );
210
+ await user.type(input, 'title login');
211
+ await user.click(
212
+ await screen.findByRole('option', {
213
+ name: 'Title contains "login"',
214
+ hidden: true,
215
+ }),
216
+ );
217
+
218
+ expect(screen.getAllByRole('button', {name: /^Remove /})).toHaveLength(2);
219
+
220
+ await user.click(
221
+ screen.getAllByRole('button', {name: 'Remove Status: is'})[0],
222
+ );
223
+
224
+ expect(screen.getAllByRole('button', {name: /^Remove /})).toHaveLength(1);
225
+ expect(screen.getByText('login')).toBeInTheDocument();
226
+ });
227
+
228
+ it('Backspace on an empty input removes only the last filter', async () => {
229
+ const user = userEvent.setup();
230
+ render(<PowerSearchWrapper config={config} />);
231
+ const input = screen.getByRole('combobox', {name: 'Search'});
232
+ await user.type(input, 'status open');
233
+ await user.click(
234
+ await screen.findByRole('option', {name: 'Status is Open', hidden: true}),
235
+ );
236
+ await user.type(input, 'title login');
237
+ await user.click(
238
+ await screen.findByRole('option', {
239
+ name: 'Title contains "login"',
240
+ hidden: true,
241
+ }),
242
+ );
243
+
244
+ expect(screen.getAllByRole('button', {name: /^Remove /})).toHaveLength(2);
245
+
246
+ await user.click(input);
247
+ await user.keyboard('{Backspace}');
248
+
249
+ expect(screen.getAllByRole('button', {name: /^Remove /})).toHaveLength(1);
250
+ expect(screen.getByText('Open')).toBeInTheDocument();
251
+ });
252
+
168
253
  describe('token value truncation (#4759)', () => {
169
254
  it('truncates token values by characters, not code units', () => {
170
255
  const truncConfig: PowerSearchConfig = {
@@ -733,8 +733,8 @@ export function PowerSearch({
733
733
  } else if (change.type === 'remove' && change.item) {
734
734
  const auxData = change.item.auxiliaryData as PowerSearchAuxData;
735
735
  if (auxData?.filterIndex != null) {
736
- const newFilters = filters.filter(
737
- (_, i) => i !== auxData.filterIndex,
736
+ const newFilters = filters.filter((_, i) =>
737
+ _items.some(item => item.auxiliaryData?.filterIndex === i),
738
738
  );
739
739
  onChange(newFilters, 'remove', auxData.filterIndex);
740
740
  }
@@ -20,7 +20,7 @@ import {
20
20
  afterEach,
21
21
  } from 'vitest';
22
22
  import {render, screen, fireEvent, act, waitFor} from '@testing-library/react';
23
- import {createRef, Profiler} from 'react';
23
+ import {createRef, Profiler, useState} from 'react';
24
24
  import userEvent from '@testing-library/user-event';
25
25
  import {Tokenizer} from './Tokenizer';
26
26
  import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
@@ -1790,6 +1790,59 @@ describe('Tokenizer end-lane reserve', () => {
1790
1790
  expect(renderedTokens).toEqual([]);
1791
1791
  });
1792
1792
 
1793
+ it('clears the busy spinner when a token is added while a bootstrap load is in flight', async () => {
1794
+ let settle: (items: SearchableItem[]) => void = () => {};
1795
+ const bootstrap = vi.fn(
1796
+ async () =>
1797
+ new Promise<SearchableItem[]>(resolve => {
1798
+ settle = resolve;
1799
+ }),
1800
+ );
1801
+ const source: SearchSource = {search: () => [], bootstrap};
1802
+
1803
+ function Harness() {
1804
+ const [value, setValue] = useState<SearchableItem[]>([]);
1805
+ return (
1806
+ <>
1807
+ <Tokenizer
1808
+ label="Items"
1809
+ searchSource={source}
1810
+ value={value}
1811
+ onChange={setValue}
1812
+ hasEntriesOnFocus
1813
+ />
1814
+ <button type="button" onClick={() => setValue([users[0]])}>
1815
+ Add Token
1816
+ </button>
1817
+ </>
1818
+ );
1819
+ }
1820
+
1821
+ render(<Harness />);
1822
+ const input = screen.getByRole('combobox');
1823
+
1824
+ fireEvent.focus(input);
1825
+ await waitFor(() => {
1826
+ expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
1827
+ });
1828
+
1829
+ fireEvent.click(screen.getByRole('button', {name: 'Add Token'}));
1830
+
1831
+ await waitFor(() => {
1832
+ expect(
1833
+ screen.queryByRole('status', {name: 'Loading'}),
1834
+ ).not.toBeInTheDocument();
1835
+ });
1836
+
1837
+ await act(async () => {
1838
+ settle([]);
1839
+ await Promise.resolve();
1840
+ });
1841
+ expect(
1842
+ screen.queryByRole('status', {name: 'Loading'}),
1843
+ ).not.toBeInTheDocument();
1844
+ });
1845
+
1793
1846
  it('shares one observer across every field on the page', () => {
1794
1847
  // Browsers batch per observer instance, so one observer per lane meant N
1795
1848
  // callback dispatches a frame for N fields.
@@ -147,6 +147,19 @@ describe('useTooltip — show delay', () => {
147
147
 
148
148
  expect(onShow).not.toHaveBeenCalled();
149
149
  });
150
+
151
+ it('drops a pending show when isEnabled turns false before the delay elapses', () => {
152
+ const onShow = vi.fn();
153
+ const {rerender} = render(<Harness onShow={onShow} />);
154
+
155
+ fireEvent.mouseEnter(trigger());
156
+ tick(100);
157
+ rerender(<Harness isEnabled={false} onShow={onShow} />);
158
+ tick(1000);
159
+
160
+ expect(onShow).not.toHaveBeenCalled();
161
+ expect(tooltipElement().matches(':popover-open')).toBe(false);
162
+ });
150
163
  });
151
164
 
152
165
  describe('useTooltip — touch suppression', () => {
@@ -352,6 +352,17 @@ export function useTooltip(options: TooltipOptions = {}): TooltipReturn {
352
352
  }, delay);
353
353
  }, [isEnabled, isOpen, clearTimeouts, showLayer, delay]);
354
354
 
355
+ // A show scheduled while the triggers were enabled must not outlive them:
356
+ // disabling also detaches the leave listener that would have cancelled it,
357
+ // so the timer would open a tooltip that nothing ever closes. Only the
358
+ // pending show is dropped; a pending hide still runs.
359
+ useEffect(() => {
360
+ if (!isEnabled && showTimeoutRef.current) {
361
+ clearTimeout(showTimeoutRef.current);
362
+ showTimeoutRef.current = null;
363
+ }
364
+ }, [isEnabled]);
365
+
355
366
  // Schedule hide with delay (suppressed when isOpen is true).
356
367
  // A small hover bridge (when hideDelay is 0) lets the pointer travel from the
357
368
  // trigger onto the tooltip surface without the tooltip vanishing — required
@@ -624,6 +624,32 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
624
624
  sourceGenRef.current = ++searchGenRef.current;
625
625
  }
626
626
 
627
+ // The previous source is remembered in state, not a ref, so a render React
628
+ // discards cannot consume the comparison without its state updates.
629
+ const [sourceOfState, setSourceOfState] = useState(searchSource);
630
+ if (sourceOfState !== searchSource) {
631
+ setSourceOfState(searchSource);
632
+ if (isLoading) {
633
+ setIsLoading(false);
634
+ setHasSearched(false);
635
+ }
636
+ loadingRef.current = false;
637
+ }
638
+
639
+ // When the search source is replaced, any in-flight load from the previous
640
+ // source is abandoned (generation bumped above). Notify the wrapper's busy lane
641
+ // in an effect so external store listeners run safely after commit.
642
+ const isInitialMountRef = useRef(true);
643
+ useEffect(() => {
644
+ if (isInitialMountRef.current) {
645
+ isInitialMountRef.current = false;
646
+ return;
647
+ }
648
+ if (!loadingRef.current) {
649
+ onLoadingChangeRef.current?.(false);
650
+ }
651
+ }, [searchSource]);
652
+
627
653
  // Layer for dropdown
628
654
  const handleLayerShow = useCallback(() => {
629
655
  onOpenChange?.(true);
@@ -17,10 +17,20 @@ import {
17
17
  beforeAll,
18
18
  afterAll,
19
19
  beforeEach,
20
+ afterEach,
20
21
  } from 'vitest';
21
22
  import {render, screen, fireEvent, waitFor, act} from '@testing-library/react';
22
23
  import userEvent from '@testing-library/user-event';
23
- import {createRef, Profiler, type ProfilerOnRenderCallback} from 'react';
24
+ import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
25
+ import {
26
+ createRef,
27
+ Profiler,
28
+ startTransition,
29
+ StrictMode,
30
+ Suspense,
31
+ useState,
32
+ type ProfilerOnRenderCallback,
33
+ } from 'react';
24
34
  import {Typeahead} from './Typeahead';
25
35
  import {readFile} from 'node:fs/promises';
26
36
  import {resolve} from 'node:path';
@@ -70,6 +80,10 @@ afterAll(() => {
70
80
  (HTMLElement.prototype as any).matches = originalMatches;
71
81
  });
72
82
 
83
+ afterEach(() => {
84
+ __resetLiveRegionsForTest();
85
+ });
86
+
73
87
  // Test data
74
88
  const fruits: SearchableItem[] = [
75
89
  {id: '1', label: 'Apple'},
@@ -964,6 +978,193 @@ describe('BaseTypeahead hasEntriesOnFocus', () => {
964
978
  ).not.toBeInTheDocument();
965
979
  });
966
980
 
981
+ it('clears the loading state when the search source is replaced during an in-flight load', async () => {
982
+ let settle: (items: SearchableItem[]) => void = () => {};
983
+ const bootstrap = vi.fn(
984
+ async () =>
985
+ new Promise<SearchableItem[]>(resolve => {
986
+ settle = resolve;
987
+ }),
988
+ );
989
+ const initialSource: SearchSource = {search: () => [], bootstrap};
990
+ const replacementSource: SearchSource = {
991
+ search: () => [],
992
+ bootstrap: () => [],
993
+ };
994
+
995
+ const {rerender} = render(
996
+ <BaseTypeahead
997
+ searchSource={initialSource}
998
+ value={null}
999
+ onChange={() => {}}
1000
+ hasEntriesOnFocus
1001
+ />,
1002
+ );
1003
+ const input = screen.getByRole('combobox');
1004
+
1005
+ fireEvent.focus(input);
1006
+ await waitFor(() => {
1007
+ expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
1008
+ });
1009
+
1010
+ rerender(
1011
+ <BaseTypeahead
1012
+ searchSource={replacementSource}
1013
+ value={null}
1014
+ onChange={() => {}}
1015
+ hasEntriesOnFocus
1016
+ />,
1017
+ );
1018
+
1019
+ await waitFor(() => {
1020
+ expect(
1021
+ screen.queryByRole('status', {name: 'Loading'}),
1022
+ ).not.toBeInTheDocument();
1023
+ });
1024
+
1025
+ await act(async () => {
1026
+ settle([]);
1027
+ await Promise.resolve();
1028
+ });
1029
+ expect(
1030
+ screen.queryByRole('status', {name: 'Loading'}),
1031
+ ).not.toBeInTheDocument();
1032
+ });
1033
+
1034
+ it('keeps the loading state under StrictMode for a pending autofocus bootstrap', async () => {
1035
+ let settle: (items: SearchableItem[]) => void = () => {};
1036
+ const bootstrap = vi.fn(
1037
+ async () =>
1038
+ new Promise<SearchableItem[]>(resolve => {
1039
+ settle = resolve;
1040
+ }),
1041
+ );
1042
+ render(
1043
+ <StrictMode>
1044
+ <Typeahead
1045
+ label="Test"
1046
+ searchSource={{search: () => [], bootstrap}}
1047
+ value={null}
1048
+ onChange={() => {}}
1049
+ hasAutoFocus
1050
+ hasEntriesOnFocus
1051
+ />
1052
+ </StrictMode>,
1053
+ );
1054
+
1055
+ expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
1056
+
1057
+ await act(async () => {
1058
+ settle([]);
1059
+ await Promise.resolve();
1060
+ });
1061
+ expect(
1062
+ screen.queryByRole('status', {name: 'Loading'}),
1063
+ ).not.toBeInTheDocument();
1064
+ });
1065
+
1066
+ let settlePendingTransitionA: (items: SearchableItem[]) => void = () => {};
1067
+ const stableTransitionSourceA: SearchSource = {
1068
+ search: vi.fn(
1069
+ async () =>
1070
+ new Promise<SearchableItem[]>(resolve => {
1071
+ settlePendingTransitionA = resolve;
1072
+ }),
1073
+ ),
1074
+ bootstrap: () => [],
1075
+ };
1076
+ const stableTransitionSourceB: SearchSource = {
1077
+ search: vi.fn(async () => []),
1078
+ bootstrap: () => [],
1079
+ };
1080
+
1081
+ it('clears loading and aria-busy when source is replaced in a transition that suspends and retries', async () => {
1082
+ let isSuspended = false;
1083
+ let resolveSuspension: () => void = () => {};
1084
+ let pendingSuspension: Promise<void> | null = null;
1085
+
1086
+ function SuspendingSibling({shouldSuspend}: {shouldSuspend: boolean}) {
1087
+ if (shouldSuspend && isSuspended && pendingSuspension) {
1088
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
1089
+ throw pendingSuspension;
1090
+ }
1091
+ return null;
1092
+ }
1093
+
1094
+ let updateState!: (source: SearchSource, shouldSuspend: boolean) => void;
1095
+
1096
+ function TransitionContainer() {
1097
+ const [source, setSource] = useState<SearchSource>(
1098
+ stableTransitionSourceA,
1099
+ );
1100
+ const [shouldSuspend, setShouldSuspend] = useState(false);
1101
+ updateState = (newSource, suspend) => {
1102
+ setSource(newSource);
1103
+ setShouldSuspend(suspend);
1104
+ };
1105
+
1106
+ return (
1107
+ <Suspense fallback={<div>Suspended...</div>}>
1108
+ <Typeahead
1109
+ label="Test"
1110
+ searchSource={source}
1111
+ value={null}
1112
+ onChange={() => {}}
1113
+ debounceMs={0}
1114
+ />
1115
+ <SuspendingSibling shouldSuspend={shouldSuspend} />
1116
+ </Suspense>
1117
+ );
1118
+ }
1119
+
1120
+ render(<TransitionContainer />);
1121
+ const input = screen.getByRole('combobox');
1122
+
1123
+ // Trigger a load so aria-busy="true" is set on the combobox
1124
+ fireEvent.change(input, {target: {value: 'query'}});
1125
+ await waitFor(() => {
1126
+ expect(input).toHaveAttribute('aria-busy', 'true');
1127
+ });
1128
+ expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
1129
+
1130
+ // Create the pending promise for suspension
1131
+ isSuspended = true;
1132
+ pendingSuspension = new Promise<void>(resolve => {
1133
+ resolveSuspension = () => {
1134
+ isSuspended = false;
1135
+ resolve();
1136
+ };
1137
+ });
1138
+
1139
+ // Inside startTransition, switch from source A to source B while sibling suspends
1140
+ act(() => {
1141
+ startTransition(() => {
1142
+ updateState(stableTransitionSourceB, true);
1143
+ });
1144
+ });
1145
+
1146
+ // The render suspended, so the previous committed state remains
1147
+ expect(input).toHaveAttribute('aria-busy', 'true');
1148
+
1149
+ // Resolve the pending promise and let the transition commit
1150
+ await act(async () => {
1151
+ resolveSuspension();
1152
+ await pendingSuspension;
1153
+ });
1154
+
1155
+ // Settle the abandoned load
1156
+ await act(async () => {
1157
+ settlePendingTransitionA([]);
1158
+ await Promise.resolve();
1159
+ });
1160
+
1161
+ // The combobox must not keep aria-busy and the spinner must be gone
1162
+ expect(input).not.toHaveAttribute('aria-busy');
1163
+ expect(
1164
+ screen.queryByRole('status', {name: 'Loading'}),
1165
+ ).not.toBeInTheDocument();
1166
+ });
1167
+
967
1168
  it('shows bootstrap results on mouse click', async () => {
968
1169
  render(
969
1170
  <BaseTypeahead
@@ -1323,6 +1524,63 @@ describe('BaseTypeahead hasSearched reset', () => {
1323
1524
  // "No results found" should disappear since hasSearched is reset
1324
1525
  expect(screen.queryByText('No results found')).not.toBeInTheDocument();
1325
1526
  });
1527
+
1528
+ it('resets hasSearched and does not announce "No results found" when search source is replaced during a pending search', async () => {
1529
+ let settle: (items: SearchableItem[]) => void = () => {};
1530
+ const initialSource: SearchSource = {
1531
+ search: vi.fn(
1532
+ async () =>
1533
+ new Promise<SearchableItem[]>(resolve => {
1534
+ settle = resolve;
1535
+ }),
1536
+ ),
1537
+ bootstrap: () => [],
1538
+ };
1539
+ const replacementSource: SearchSource = {
1540
+ search: vi.fn(async () => []),
1541
+ bootstrap: () => [],
1542
+ };
1543
+
1544
+ const {rerender} = render(
1545
+ <BaseTypeahead
1546
+ searchSource={initialSource}
1547
+ value={null}
1548
+ onChange={() => {}}
1549
+ debounceMs={0}
1550
+ />,
1551
+ );
1552
+ const input = screen.getByRole('combobox');
1553
+
1554
+ // Type a query that starts an asynchronous search
1555
+ fireEvent.change(input, {target: {value: 'abc'}});
1556
+ expect(initialSource.search).toHaveBeenCalledWith('abc');
1557
+
1558
+ // Replace the search source while the search is still pending
1559
+ rerender(
1560
+ <BaseTypeahead
1561
+ searchSource={replacementSource}
1562
+ value={null}
1563
+ onChange={() => {}}
1564
+ debounceMs={0}
1565
+ />,
1566
+ );
1567
+
1568
+ // Settle the abandoned search
1569
+ await act(async () => {
1570
+ settle([]);
1571
+ await Promise.resolve();
1572
+ });
1573
+
1574
+ await act(
1575
+ async () =>
1576
+ new Promise<void>(resolve => requestAnimationFrame(() => resolve())),
1577
+ );
1578
+ expect(
1579
+ document.querySelector('[data-astryx-live-region="polite"]')
1580
+ ?.textContent ?? '',
1581
+ ).not.toContain('No results found');
1582
+ expect(screen.queryByText('No results found')).not.toBeInTheDocument();
1583
+ });
1326
1584
  });
1327
1585
 
1328
1586
  describe('BaseTypeahead popover after selection', () => {
@@ -2313,7 +2571,7 @@ describe('Typeahead consumer key handler order', () => {
2313
2571
  currentTarget = event.currentTarget;
2314
2572
  event.preventDefault();
2315
2573
  });
2316
- const {container} = render(
2574
+ render(
2317
2575
  <Typeahead
2318
2576
  label="Fruit"
2319
2577
  searchSource={fruitSource}