@astryxdesign/core 0.6.2-canary.5a7b819 → 0.6.2-canary.7ccd6c8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/PowerSearch/PowerSearchEditPopover.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearchEditPopover.js +46 -30
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +15 -6
- package/package.json +3 -3
- package/src/PowerSearch/PowerSearchEditPopover.test.tsx +150 -1
- package/src/PowerSearch/PowerSearchEditPopover.tsx +51 -28
- package/src/Typeahead/BaseTypeahead.spec.md +4 -3
- package/src/Typeahead/BaseTypeahead.tsx +15 -6
- package/src/Typeahead/Typeahead.test.tsx +53 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PowerSearchEditPopover.d.ts","sourceRoot":"","sources":["../../src/PowerSearch/PowerSearchEditPopover.tsx"],"names":[],"mappings":"AAIA;;;;;;;;GAQG;AAEH,OAAO,KAA0D,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"PowerSearchEditPopover.d.ts","sourceRoot":"","sources":["../../src/PowerSearch/PowerSearchEditPopover.tsx"],"names":[],"mappings":"AAIA;;;;;;;;GAQG;AAEH,OAAO,KAA0D,MAAM,OAAO,CAAC;AAe/E,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,qBAAqB,CAAC;AACxD,OAAO,KAAK,EACV,iBAAiB,EACjB,aAAa,EAGd,MAAM,SAAS,CAAC;AA+DjB,MAAM,WAAW,2BAA2B;IAC1C,MAAM,EAAE,cAAc,CAAC;IACvB,uCAAuC;IACvC,MAAM,EAAE,aAAa,CAAC;IACtB,qDAAqD;IACrD,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAC;IACxB,6EAA6E;IAC7E,MAAM,EAAE,CAAC,MAAM,EAAE,iBAAiB,GAAG,IAAI,KAAK,IAAI,CAAC;IACnD,wDAAwD;IACxD,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,kDAAkD;IAClD,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,6DAA6D;IAC7D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,uCAAuC;IACvC,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AA0hBD,wBAAgB,sBAAsB,CAAC,EACrC,MAAM,EACN,MAAM,EAAE,aAAa,EACrB,IAAI,EACJ,MAAM,EACN,QAAQ,EACR,eAAe,EAAE,wBAAwB,EACzC,YAAY,EACZ,UAAkB,GACnB,EAAE,2BAA2B,qBAyQ7B"}
|
|
@@ -229,7 +229,7 @@ function NestedSubFilterRow({
|
|
|
229
229
|
config: config,
|
|
230
230
|
maxMenuItems: maxMenuItems,
|
|
231
231
|
isDisabled: isReadOnly
|
|
232
|
-
})
|
|
232
|
+
}, valueEditorKey(subFilter))
|
|
233
233
|
})]
|
|
234
234
|
});
|
|
235
235
|
}
|
|
@@ -414,6 +414,42 @@ function NestedEditor({
|
|
|
414
414
|
});
|
|
415
415
|
}
|
|
416
416
|
|
|
417
|
+
// =============================================================================
|
|
418
|
+
// Value editor cell
|
|
419
|
+
// =============================================================================
|
|
420
|
+
|
|
421
|
+
// A field switch must replace the editor, not update it in place: a reused
|
|
422
|
+
// editor keeps its open menu and old results.
|
|
423
|
+
function valueEditorKey(filter) {
|
|
424
|
+
return `${filter.field}\u0000${filter.operator ?? ''}`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Keyed on field + operator, so this also mounts on a field switch, not just
|
|
428
|
+
// when the popover opens; the focus handoff below runs for both.
|
|
429
|
+
function ValueEditorCell(props) {
|
|
430
|
+
const ref = useRef(null);
|
|
431
|
+
useEffect(() => {
|
|
432
|
+
const frame = requestAnimationFrame(() => {
|
|
433
|
+
const container = ref.current;
|
|
434
|
+
if (!container) {
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const focusable = container.querySelector('input:not([disabled]), button:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])');
|
|
438
|
+
focusable?.focus();
|
|
439
|
+
});
|
|
440
|
+
return () => cancelAnimationFrame(frame);
|
|
441
|
+
}, []);
|
|
442
|
+
return /*#__PURE__*/_jsx("div", {
|
|
443
|
+
ref: ref,
|
|
444
|
+
...{
|
|
445
|
+
className: "xgyuaek xeuugli"
|
|
446
|
+
},
|
|
447
|
+
children: /*#__PURE__*/_jsx(PowerSearchValueEditor, {
|
|
448
|
+
...props
|
|
449
|
+
})
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
417
453
|
// =============================================================================
|
|
418
454
|
// Main popover
|
|
419
455
|
// =============================================================================
|
|
@@ -431,20 +467,6 @@ export function PowerSearchEditPopover({
|
|
|
431
467
|
const t = useTranslator();
|
|
432
468
|
const saveButtonLabel = saveButtonLabelFromProps ?? t('@astryx.powersearch.editor.apply');
|
|
433
469
|
const [partialFilter, setPartialFilter] = useState(initialFilter);
|
|
434
|
-
const valueEditorRef = useRef(null);
|
|
435
|
-
|
|
436
|
-
// Focus the first focusable element inside the value editor after mount
|
|
437
|
-
useEffect(() => {
|
|
438
|
-
const frame = requestAnimationFrame(() => {
|
|
439
|
-
const container = valueEditorRef.current;
|
|
440
|
-
if (!container) {
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
const focusable = container.querySelector('input:not([disabled]), button:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])');
|
|
444
|
-
focusable?.focus();
|
|
445
|
-
});
|
|
446
|
-
return () => cancelAnimationFrame(frame);
|
|
447
|
-
}, []);
|
|
448
470
|
const currentOperator = partialFilter.operator ? config.getOperator(partialFilter.field, partialFilter.operator) : undefined;
|
|
449
471
|
|
|
450
472
|
// Build field options for the selector
|
|
@@ -651,21 +673,15 @@ export function PowerSearchEditPopover({
|
|
|
651
673
|
isDisabled: isReadOnly,
|
|
652
674
|
size: "md"
|
|
653
675
|
})
|
|
654
|
-
}), operatorValue && !isEmptyType && /*#__PURE__*/_jsx(
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
onEnter: handleSave,
|
|
664
|
-
config: config,
|
|
665
|
-
maxMenuItems: maxMenuItems,
|
|
666
|
-
isDisabled: isReadOnly
|
|
667
|
-
})
|
|
668
|
-
})]
|
|
676
|
+
}), operatorValue && !isEmptyType && /*#__PURE__*/_jsx(ValueEditorCell, {
|
|
677
|
+
operatorValue: operatorValue,
|
|
678
|
+
filterValue: partialFilter.value,
|
|
679
|
+
onChange: handleValueChange,
|
|
680
|
+
onEnter: handleSave,
|
|
681
|
+
config: config,
|
|
682
|
+
maxMenuItems: maxMenuItems,
|
|
683
|
+
isDisabled: isReadOnly
|
|
684
|
+
}, valueEditorKey(partialFilter))]
|
|
669
685
|
})
|
|
670
686
|
}), !isEmptyType && /*#__PURE__*/_jsx("div", {
|
|
671
687
|
...{
|
|
@@ -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;AAGf,OAAO,KAAK,EAAC,YAAY,EAAC,MAAM,kBAAkB,CAAC;AAyBnD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAC,cAAc,EAAE,YAAY,EAAC,MAAM,SAAS,CAAC;AAS1D,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;;;OAGG;IACH,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAEhC;;;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;AA+JD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,aAAa,
|
|
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;AAGf,OAAO,KAAK,EAAC,YAAY,EAAC,MAAM,kBAAkB,CAAC;AAyBnD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAC,cAAc,EAAE,YAAY,EAAC,MAAM,SAAS,CAAC;AAS1D,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;;;OAGG;IACH,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAEhC;;;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;AA+JD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,aAAa,EAouBrB,CAAC,CAAC,SAAS,cAAc,EAC5B,KAAK,EAAE,kBAAkB,CAAC,CAAC,CAAC,KACzB,KAAK,CAAC,YAAY,CAAC"}
|
|
@@ -274,9 +274,9 @@ export const BaseTypeahead = function BaseTypeahead({
|
|
|
274
274
|
// Debounce ref
|
|
275
275
|
const searchTimeoutRef = useRef(null);
|
|
276
276
|
|
|
277
|
-
// Monotonic counter incremented on selection
|
|
278
|
-
// searches that resolve
|
|
279
|
-
// generation to the current value and discard stale results.
|
|
277
|
+
// Monotonic counter incremented on selection, query-clear, and source
|
|
278
|
+
// replacement. Async searches that resolve afterwards compare their
|
|
279
|
+
// captured generation to the current value and discard stale results.
|
|
280
280
|
const searchGenRef = useRef(0);
|
|
281
281
|
// The generation at which results were last populated. handleFocus
|
|
282
282
|
// compares this to searchGenRef — if they differ, the cached results
|
|
@@ -284,6 +284,15 @@ export const BaseTypeahead = function BaseTypeahead({
|
|
|
284
284
|
// be re-shown.
|
|
285
285
|
const resultsGenRef = useRef(0);
|
|
286
286
|
|
|
287
|
+
// Results still arriving from a replaced source must not land in the new
|
|
288
|
+
// one's menu.
|
|
289
|
+
const prevSearchSourceRef = useRef(searchSource);
|
|
290
|
+
if (prevSearchSourceRef.current !== searchSource) {
|
|
291
|
+
prevSearchSourceRef.current.cancel?.();
|
|
292
|
+
prevSearchSourceRef.current = searchSource;
|
|
293
|
+
searchGenRef.current++;
|
|
294
|
+
}
|
|
295
|
+
|
|
287
296
|
// Layer for dropdown
|
|
288
297
|
const handleLayerShow = useCallback(() => {
|
|
289
298
|
onOpenChange?.(true);
|
|
@@ -630,15 +639,15 @@ export const BaseTypeahead = function BaseTypeahead({
|
|
|
630
639
|
});
|
|
631
640
|
const selectedKey = value == null ? null : getKey(value.id, () => results.indexOf(value));
|
|
632
641
|
|
|
633
|
-
//
|
|
642
|
+
// Unmount: clear the pending debounce and cancel in-flight work.
|
|
634
643
|
useEffect(() => {
|
|
635
644
|
return () => {
|
|
636
645
|
if (searchTimeoutRef.current) {
|
|
637
646
|
clearTimeout(searchTimeoutRef.current);
|
|
638
647
|
}
|
|
639
|
-
|
|
648
|
+
prevSearchSourceRef.current.cancel?.();
|
|
640
649
|
};
|
|
641
|
-
}, [
|
|
650
|
+
}, []);
|
|
642
651
|
return /*#__PURE__*/_jsxs(_Fragment, {
|
|
643
652
|
children: [/*#__PURE__*/_jsx("input", {
|
|
644
653
|
...rest,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/core",
|
|
3
|
-
"version": "0.6.2-canary.
|
|
3
|
+
"version": "0.6.2-canary.7ccd6c8",
|
|
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",
|
|
@@ -672,8 +672,8 @@
|
|
|
672
672
|
"react-dom": ">=19.0.0"
|
|
673
673
|
},
|
|
674
674
|
"devDependencies": {
|
|
675
|
-
"@astryxdesign/a11y-spec": "0.6.2-canary.
|
|
676
|
-
"@astryxdesign/cli": "0.6.2-canary.
|
|
675
|
+
"@astryxdesign/a11y-spec": "0.6.2-canary.7ccd6c8",
|
|
676
|
+
"@astryxdesign/cli": "0.6.2-canary.7ccd6c8",
|
|
677
677
|
"@babel/cli": "^7.29.7",
|
|
678
678
|
"@babel/core": "^7.29.7",
|
|
679
679
|
"@babel/preset-react": "^7.29.7",
|
|
@@ -10,7 +10,14 @@ import {
|
|
|
10
10
|
afterAll,
|
|
11
11
|
afterEach,
|
|
12
12
|
} from 'vitest';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
render,
|
|
15
|
+
screen,
|
|
16
|
+
fireEvent,
|
|
17
|
+
act,
|
|
18
|
+
waitFor,
|
|
19
|
+
within,
|
|
20
|
+
} from '@testing-library/react';
|
|
14
21
|
import React, {useState} from 'react';
|
|
15
22
|
import * as stylex from '@stylexjs/stylex';
|
|
16
23
|
import {spacingVars} from '../theme/tokens.stylex';
|
|
@@ -170,6 +177,148 @@ describe('PowerSearch', () => {
|
|
|
170
177
|
expect(popoverText).toContain('equals');
|
|
171
178
|
});
|
|
172
179
|
|
|
180
|
+
describe('value editor identity follows the field and operator', () => {
|
|
181
|
+
// Two fields with the same value type, so an unkeyed editor would be
|
|
182
|
+
// reused across the switch and keep the first field's menu contents.
|
|
183
|
+
const sameTypeConfig: PowerSearchConfig = {
|
|
184
|
+
name: 'test-same-type',
|
|
185
|
+
fields: [
|
|
186
|
+
{
|
|
187
|
+
key: 'creator',
|
|
188
|
+
label: 'Creator',
|
|
189
|
+
operators: [
|
|
190
|
+
{
|
|
191
|
+
key: 'is_any_of',
|
|
192
|
+
label: 'is any of',
|
|
193
|
+
value: {
|
|
194
|
+
type: 'enum_list',
|
|
195
|
+
values: [{value: 'creator-1', label: 'Creator Person'}],
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
key: 'owner',
|
|
202
|
+
label: 'Owner',
|
|
203
|
+
operators: [
|
|
204
|
+
{
|
|
205
|
+
key: 'is_any_of',
|
|
206
|
+
label: 'is any of',
|
|
207
|
+
value: {
|
|
208
|
+
type: 'enum_list',
|
|
209
|
+
values: [{value: 'owner-1', label: 'Owner Person'}],
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const creatorOption = () =>
|
|
218
|
+
screen.queryByRole('option', {name: 'Creator Person', hidden: true});
|
|
219
|
+
const ownerOption = () =>
|
|
220
|
+
screen.queryByRole('option', {name: 'Owner Person', hidden: true});
|
|
221
|
+
|
|
222
|
+
it('replaces an open value menu when the field changes', async () => {
|
|
223
|
+
function Harness() {
|
|
224
|
+
const internalConfig = useInternalConfig(sameTypeConfig);
|
|
225
|
+
return (
|
|
226
|
+
<PowerSearchEditPopover
|
|
227
|
+
config={internalConfig}
|
|
228
|
+
filter={{
|
|
229
|
+
field: 'creator',
|
|
230
|
+
operator: 'is_any_of',
|
|
231
|
+
value: {type: 'enum_list', value: []},
|
|
232
|
+
}}
|
|
233
|
+
mode="edit"
|
|
234
|
+
onSave={() => {}}
|
|
235
|
+
onCancel={() => {}}
|
|
236
|
+
/>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
render(<Harness />);
|
|
241
|
+
|
|
242
|
+
// Open the Creator value menu and leave it open.
|
|
243
|
+
fireEvent.focus(screen.getByRole('combobox', {name: 'Values'}));
|
|
244
|
+
await waitFor(() => expect(creatorOption()).toBeInTheDocument());
|
|
245
|
+
|
|
246
|
+
// Switch the field underneath the open menu.
|
|
247
|
+
fireEvent.click(screen.getByRole('combobox', {name: 'Field'}));
|
|
248
|
+
fireEvent.click(
|
|
249
|
+
screen.getByRole('option', {name: 'Owner', hidden: true}),
|
|
250
|
+
);
|
|
251
|
+
act(() => flushRAF());
|
|
252
|
+
|
|
253
|
+
// The old field's options are gone, the new field's are shown.
|
|
254
|
+
expect(creatorOption()).not.toBeInTheDocument();
|
|
255
|
+
await waitFor(() => expect(ownerOption()).toBeInTheDocument());
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('replaces an open value menu on a nested sub-filter row', async () => {
|
|
259
|
+
const nestedConfig: PowerSearchConfig = {
|
|
260
|
+
name: 'test-nested-same-type',
|
|
261
|
+
fields: [
|
|
262
|
+
{
|
|
263
|
+
key: 'group',
|
|
264
|
+
label: 'Group',
|
|
265
|
+
operators: [
|
|
266
|
+
{key: 'all_of', label: 'all of', value: {type: 'nested'}},
|
|
267
|
+
],
|
|
268
|
+
},
|
|
269
|
+
...sameTypeConfig.fields,
|
|
270
|
+
],
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
function Harness() {
|
|
274
|
+
const internalConfig = useInternalConfig(nestedConfig);
|
|
275
|
+
return (
|
|
276
|
+
<PowerSearchEditPopover
|
|
277
|
+
config={internalConfig}
|
|
278
|
+
filter={{
|
|
279
|
+
field: 'group',
|
|
280
|
+
operator: 'all_of',
|
|
281
|
+
value: {
|
|
282
|
+
type: 'nested',
|
|
283
|
+
value: [
|
|
284
|
+
{
|
|
285
|
+
field: 'creator',
|
|
286
|
+
operator: 'is_any_of',
|
|
287
|
+
value: {type: 'enum_list', value: []},
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
}}
|
|
292
|
+
mode="edit"
|
|
293
|
+
onSave={() => {}}
|
|
294
|
+
onCancel={() => {}}
|
|
295
|
+
/>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
render(<Harness />);
|
|
300
|
+
|
|
301
|
+
fireEvent.focus(screen.getByRole('combobox', {name: 'Values'}));
|
|
302
|
+
await waitFor(() => expect(creatorOption()).toBeInTheDocument());
|
|
303
|
+
|
|
304
|
+
// The nested row's own field selector is the one currently on
|
|
305
|
+
// "Creator"; the root selector stays on "Group".
|
|
306
|
+
const rowField = screen
|
|
307
|
+
.getAllByRole('combobox', {name: 'Field'})
|
|
308
|
+
.find(el => el.textContent === 'Creator');
|
|
309
|
+
expect(rowField).toBeDefined();
|
|
310
|
+
fireEvent.click(rowField!);
|
|
311
|
+
const rowListbox = document.getElementById(
|
|
312
|
+
rowField!.getAttribute('aria-controls')!,
|
|
313
|
+
)!;
|
|
314
|
+
fireEvent.click(
|
|
315
|
+
within(rowListbox).getByRole('option', {name: 'Owner', hidden: true}),
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
expect(creatorOption()).not.toBeInTheDocument();
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
173
322
|
it('edit popover shows correct filter after removing a preceding filter', () => {
|
|
174
323
|
const filters: PowerSearchFilter[] = [
|
|
175
324
|
{field: 'status', operator: 'is', value: {type: 'string', value: 'open'}},
|
|
@@ -22,7 +22,10 @@ import {TreeList, type TreeListItemData} from '../TreeList';
|
|
|
22
22
|
import {useTranslator} from '../i18n';
|
|
23
23
|
import {isImeKeyEvent} from '../utils/ime';
|
|
24
24
|
import {spacingVars, typeScaleVars} from '../theme/tokens.stylex';
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
PowerSearchValueEditor,
|
|
27
|
+
type PowerSearchValueEditorProps,
|
|
28
|
+
} from './PowerSearchValueEditor';
|
|
26
29
|
import {resolveOperatorLabel} from './resolveOperatorLabel';
|
|
27
30
|
import type {InternalConfig} from './useInternalConfig';
|
|
28
31
|
import type {
|
|
@@ -356,6 +359,7 @@ function NestedSubFilterRow({
|
|
|
356
359
|
{operatorValue && !isEmptyType && !isNestedType && (
|
|
357
360
|
<div {...stylex.props(styles.nestedRowValueEditor)}>
|
|
358
361
|
<PowerSearchValueEditor
|
|
362
|
+
key={valueEditorKey(subFilter)}
|
|
359
363
|
operatorValue={operatorValue}
|
|
360
364
|
filterValue={subFilter.value}
|
|
361
365
|
onChange={handleValueChange}
|
|
@@ -606,6 +610,42 @@ function NestedEditor({
|
|
|
606
610
|
return <TreeList items={items} density="balanced" />;
|
|
607
611
|
}
|
|
608
612
|
|
|
613
|
+
// =============================================================================
|
|
614
|
+
// Value editor cell
|
|
615
|
+
// =============================================================================
|
|
616
|
+
|
|
617
|
+
// A field switch must replace the editor, not update it in place: a reused
|
|
618
|
+
// editor keeps its open menu and old results.
|
|
619
|
+
function valueEditorKey(filter: {field: string; operator?: string}): string {
|
|
620
|
+
return `${filter.field}\u0000${filter.operator ?? ''}`;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Keyed on field + operator, so this also mounts on a field switch, not just
|
|
624
|
+
// when the popover opens; the focus handoff below runs for both.
|
|
625
|
+
function ValueEditorCell(props: PowerSearchValueEditorProps) {
|
|
626
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
627
|
+
|
|
628
|
+
useEffect(() => {
|
|
629
|
+
const frame = requestAnimationFrame(() => {
|
|
630
|
+
const container = ref.current;
|
|
631
|
+
if (!container) {
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
const focusable = container.querySelector<HTMLElement>(
|
|
635
|
+
'input:not([disabled]), button:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
636
|
+
);
|
|
637
|
+
focusable?.focus();
|
|
638
|
+
});
|
|
639
|
+
return () => cancelAnimationFrame(frame);
|
|
640
|
+
}, []);
|
|
641
|
+
|
|
642
|
+
return (
|
|
643
|
+
<div ref={ref} {...stylex.props(styles.valueEditor)}>
|
|
644
|
+
<PowerSearchValueEditor {...props} />
|
|
645
|
+
</div>
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
609
649
|
// =============================================================================
|
|
610
650
|
// Main popover
|
|
611
651
|
// =============================================================================
|
|
@@ -625,22 +665,6 @@ export function PowerSearchEditPopover({
|
|
|
625
665
|
saveButtonLabelFromProps ?? t('@astryx.powersearch.editor.apply');
|
|
626
666
|
const [partialFilter, setPartialFilter] =
|
|
627
667
|
useState<PartialFilter>(initialFilter);
|
|
628
|
-
const valueEditorRef = useRef<HTMLDivElement>(null);
|
|
629
|
-
|
|
630
|
-
// Focus the first focusable element inside the value editor after mount
|
|
631
|
-
useEffect(() => {
|
|
632
|
-
const frame = requestAnimationFrame(() => {
|
|
633
|
-
const container = valueEditorRef.current;
|
|
634
|
-
if (!container) {
|
|
635
|
-
return;
|
|
636
|
-
}
|
|
637
|
-
const focusable = container.querySelector<HTMLElement>(
|
|
638
|
-
'input:not([disabled]), button:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
639
|
-
);
|
|
640
|
-
focusable?.focus();
|
|
641
|
-
});
|
|
642
|
-
return () => cancelAnimationFrame(frame);
|
|
643
|
-
}, []);
|
|
644
668
|
|
|
645
669
|
const currentOperator = partialFilter.operator
|
|
646
670
|
? config.getOperator(partialFilter.field, partialFilter.operator)
|
|
@@ -854,17 +878,16 @@ export function PowerSearchEditPopover({
|
|
|
854
878
|
</div>
|
|
855
879
|
)}
|
|
856
880
|
{operatorValue && !isEmptyType && (
|
|
857
|
-
<
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
</div>
|
|
881
|
+
<ValueEditorCell
|
|
882
|
+
key={valueEditorKey(partialFilter)}
|
|
883
|
+
operatorValue={operatorValue}
|
|
884
|
+
filterValue={partialFilter.value}
|
|
885
|
+
onChange={handleValueChange}
|
|
886
|
+
onEnter={handleSave}
|
|
887
|
+
config={config}
|
|
888
|
+
maxMenuItems={maxMenuItems}
|
|
889
|
+
isDisabled={isReadOnly}
|
|
890
|
+
/>
|
|
868
891
|
)}
|
|
869
892
|
</HStack>
|
|
870
893
|
</div>
|
|
@@ -98,7 +98,7 @@ Draft requirements identify their observational or current-authority basis.
|
|
|
98
98
|
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------- |
|
|
99
99
|
| FR1 | The input is controlled by internal query state while `value` is caller-controlled; selecting a result calls `onChange(item)`, clears query/results, closes the popup, and returns focus to the input. | Current source and tests | verified shipped behavior |
|
|
100
100
|
| FR2 | Search starts only at the grapheme-count threshold, uses the configured debounce, and never presents an empty result for a query that was not searched. | Public API, current i18n character utility, tests | verified audit remediation |
|
|
101
|
-
| FR3 | A newer query, selection, clear, or unmount invalidates stale asynchronous work. Escape hides the current popup but does not invalidate pending work, so a late response currently reopens it (retained violation).
|
|
101
|
+
| FR3 | A newer query, selection, clear, source replacement, or unmount invalidates stale asynchronous work. Escape hides the current popup but does not invalidate pending work, so a late response currently reopens it (retained violation). | Current source, focused retained-red probe, and tests | verified shipped behavior |
|
|
102
102
|
| FR4 | The popup is a named listbox whose result rows are options. A completed empty search renders one disabled option so the listbox retains a valid owned child. | APG combobox pattern, axe, tests | verified audit remediation |
|
|
103
103
|
| FR5 | Arrow keys wrap the highlight; Home/End move to the first/last option; Enter selects; Escape and Tab hide the current popup; IME-owned key events do not activate combobox commands. Escape dismissal is not durable while source work remains pending (FR3). | Current source and tests | verified shipped behavior; Escape gap retained |
|
|
104
104
|
| FR6 | Pending asynchronous source work sets `aria-busy` and renders one named Spinner unless the composed wrapper owns the busy indicator lane. | Current source, input-family FR7, tests | verified shipped behavior |
|
|
@@ -139,8 +139,9 @@ Draft requirements identify their observational or current-authority basis.
|
|
|
139
139
|
|
|
140
140
|
### Performance and resources
|
|
141
141
|
|
|
142
|
-
- **PR1 — Search lifetime.** One generation counter rejects stale responses
|
|
143
|
-
|
|
142
|
+
- **PR1 — Search lifetime.** One generation counter rejects stale responses,
|
|
143
|
+
including responses from a source that has since been replaced; debounce
|
|
144
|
+
timers and optional source cancellation are cleared on replacement and
|
|
144
145
|
unmount.
|
|
145
146
|
|
|
146
147
|
## Accessibility contract
|
|
@@ -533,9 +533,9 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
|
|
|
533
533
|
// Debounce ref
|
|
534
534
|
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
535
535
|
|
|
536
|
-
// Monotonic counter incremented on selection
|
|
537
|
-
// searches that resolve
|
|
538
|
-
// generation to the current value and discard stale results.
|
|
536
|
+
// Monotonic counter incremented on selection, query-clear, and source
|
|
537
|
+
// replacement. Async searches that resolve afterwards compare their
|
|
538
|
+
// captured generation to the current value and discard stale results.
|
|
539
539
|
const searchGenRef = useRef(0);
|
|
540
540
|
// The generation at which results were last populated. handleFocus
|
|
541
541
|
// compares this to searchGenRef — if they differ, the cached results
|
|
@@ -543,6 +543,15 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
|
|
|
543
543
|
// be re-shown.
|
|
544
544
|
const resultsGenRef = useRef(0);
|
|
545
545
|
|
|
546
|
+
// Results still arriving from a replaced source must not land in the new
|
|
547
|
+
// one's menu.
|
|
548
|
+
const prevSearchSourceRef = useRef(searchSource);
|
|
549
|
+
if (prevSearchSourceRef.current !== searchSource) {
|
|
550
|
+
prevSearchSourceRef.current.cancel?.();
|
|
551
|
+
prevSearchSourceRef.current = searchSource;
|
|
552
|
+
searchGenRef.current++;
|
|
553
|
+
}
|
|
554
|
+
|
|
546
555
|
// Layer for dropdown
|
|
547
556
|
const handleLayerShow = useCallback(() => {
|
|
548
557
|
onOpenChange?.(true);
|
|
@@ -987,15 +996,15 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
|
|
|
987
996
|
const selectedKey =
|
|
988
997
|
value == null ? null : getKey(value.id, () => results.indexOf(value));
|
|
989
998
|
|
|
990
|
-
//
|
|
999
|
+
// Unmount: clear the pending debounce and cancel in-flight work.
|
|
991
1000
|
useEffect(() => {
|
|
992
1001
|
return () => {
|
|
993
1002
|
if (searchTimeoutRef.current) {
|
|
994
1003
|
clearTimeout(searchTimeoutRef.current);
|
|
995
1004
|
}
|
|
996
|
-
|
|
1005
|
+
prevSearchSourceRef.current.cancel?.();
|
|
997
1006
|
};
|
|
998
|
-
}, [
|
|
1007
|
+
}, []);
|
|
999
1008
|
|
|
1000
1009
|
return (
|
|
1001
1010
|
<>
|
|
@@ -574,6 +574,59 @@ describe('Typeahead', () => {
|
|
|
574
574
|
expect(screen.getByText('Apple')).toBeInTheDocument();
|
|
575
575
|
expect(screen.queryByText('Avocado')).not.toBeInTheDocument();
|
|
576
576
|
});
|
|
577
|
+
|
|
578
|
+
it('discards a response from a source that was replaced while it was in flight', async () => {
|
|
579
|
+
let resolveOld: ((items: SearchableItem[]) => void) | null = null;
|
|
580
|
+
const oldSource: SearchSource = {
|
|
581
|
+
search: async () =>
|
|
582
|
+
new Promise<SearchableItem[]>(resolve => {
|
|
583
|
+
resolveOld = resolve;
|
|
584
|
+
}),
|
|
585
|
+
bootstrap: () => [],
|
|
586
|
+
};
|
|
587
|
+
const newSource: SearchSource = {
|
|
588
|
+
search: () => [{id: 'banana', label: 'Banana'}],
|
|
589
|
+
bootstrap: () => [],
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
const {rerender} = render(
|
|
593
|
+
<Typeahead
|
|
594
|
+
label="Fruit"
|
|
595
|
+
searchSource={oldSource}
|
|
596
|
+
value={null}
|
|
597
|
+
onChange={() => {}}
|
|
598
|
+
debounceMs={0}
|
|
599
|
+
/>,
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
const input = screen.getByRole('combobox');
|
|
603
|
+
fireEvent.change(input, {target: {value: 'a'}});
|
|
604
|
+
await waitFor(() => expect(resolveOld).not.toBeNull());
|
|
605
|
+
|
|
606
|
+
// The source is swapped while the old search is still pending and no
|
|
607
|
+
// new query is typed. The stale response must not populate a menu that
|
|
608
|
+
// now reads from a different source.
|
|
609
|
+
rerender(
|
|
610
|
+
<Typeahead
|
|
611
|
+
label="Fruit"
|
|
612
|
+
searchSource={newSource}
|
|
613
|
+
value={null}
|
|
614
|
+
onChange={() => {}}
|
|
615
|
+
debounceMs={0}
|
|
616
|
+
/>,
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
await act(async () => {
|
|
620
|
+
resolveOld!([{id: 'apple', label: 'Apple'}]);
|
|
621
|
+
});
|
|
622
|
+
expect(screen.queryByText('Apple')).not.toBeInTheDocument();
|
|
623
|
+
|
|
624
|
+
// Work started against the new source still lands normally.
|
|
625
|
+
fireEvent.change(input, {target: {value: 'ba'}});
|
|
626
|
+
await waitFor(() =>
|
|
627
|
+
expect(screen.getByText('Banana')).toBeInTheDocument(),
|
|
628
|
+
);
|
|
629
|
+
});
|
|
577
630
|
});
|
|
578
631
|
|
|
579
632
|
it('renders with label', () => {
|