@astryxdesign/core 0.6.2-canary.bfee8a4 → 0.6.2-canary.da998a2
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 +58 -30
- package/dist/ToggleButton/ToggleButton.d.ts +2 -1
- package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
- package/dist/ToggleButton/ToggleButton.js +7 -1
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +9 -4
- package/package.json +3 -3
- package/src/PowerSearch/PowerSearchEditPopover.test.tsx +150 -1
- package/src/PowerSearch/PowerSearchEditPopover.tsx +63 -28
- package/src/ToggleButton/ToggleButton.test.tsx +133 -0
- package/src/ToggleButton/ToggleButton.tsx +9 -2
- package/src/ToggleButton/__tests__/ToggleButton.a11y.chromium.spec.ts +209 -0
- package/src/Typeahead/BaseTypeahead.spec.md +4 -3
- package/src/Typeahead/BaseTypeahead.tsx +9 -4
- 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;AAsiBD,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,54 @@ function NestedEditor({
|
|
|
414
414
|
});
|
|
415
415
|
}
|
|
416
416
|
|
|
417
|
+
// =============================================================================
|
|
418
|
+
// Value editor cell
|
|
419
|
+
// =============================================================================
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The value editor's identity is the (field, operator) pair it edits. Keying
|
|
423
|
+
* on it remounts the editor when either changes, so nothing an editor keeps
|
|
424
|
+
* privately — a Tokenizer's open menu, its query text, results fetched from
|
|
425
|
+
* the previous field's source — survives into the next field. Without the
|
|
426
|
+
* key, two fields sharing a value type (two `enum_list` fields, say) reuse
|
|
427
|
+
* one editor instance and the open menu keeps showing the old field's
|
|
428
|
+
* options.
|
|
429
|
+
*/
|
|
430
|
+
function valueEditorKey(filter) {
|
|
431
|
+
return `${filter.field}\u0000${filter.operator ?? ''}`;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Wraps the value editor and moves focus into it once it mounts. Because the
|
|
436
|
+
* cell is keyed on the filter's field and operator, "mount" means both the
|
|
437
|
+
* popover opening and the user picking a different field or operator — the
|
|
438
|
+
* same focus handoff either way, so the freshly mounted editor can bootstrap
|
|
439
|
+
* its own suggestions.
|
|
440
|
+
*/
|
|
441
|
+
function ValueEditorCell(props) {
|
|
442
|
+
const ref = useRef(null);
|
|
443
|
+
useEffect(() => {
|
|
444
|
+
const frame = requestAnimationFrame(() => {
|
|
445
|
+
const container = ref.current;
|
|
446
|
+
if (!container) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const focusable = container.querySelector('input:not([disabled]), button:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])');
|
|
450
|
+
focusable?.focus();
|
|
451
|
+
});
|
|
452
|
+
return () => cancelAnimationFrame(frame);
|
|
453
|
+
}, []);
|
|
454
|
+
return /*#__PURE__*/_jsx("div", {
|
|
455
|
+
ref: ref,
|
|
456
|
+
...{
|
|
457
|
+
className: "xgyuaek xeuugli"
|
|
458
|
+
},
|
|
459
|
+
children: /*#__PURE__*/_jsx(PowerSearchValueEditor, {
|
|
460
|
+
...props
|
|
461
|
+
})
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
417
465
|
// =============================================================================
|
|
418
466
|
// Main popover
|
|
419
467
|
// =============================================================================
|
|
@@ -431,20 +479,6 @@ export function PowerSearchEditPopover({
|
|
|
431
479
|
const t = useTranslator();
|
|
432
480
|
const saveButtonLabel = saveButtonLabelFromProps ?? t('@astryx.powersearch.editor.apply');
|
|
433
481
|
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
482
|
const currentOperator = partialFilter.operator ? config.getOperator(partialFilter.field, partialFilter.operator) : undefined;
|
|
449
483
|
|
|
450
484
|
// Build field options for the selector
|
|
@@ -651,21 +685,15 @@ export function PowerSearchEditPopover({
|
|
|
651
685
|
isDisabled: isReadOnly,
|
|
652
686
|
size: "md"
|
|
653
687
|
})
|
|
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
|
-
})]
|
|
688
|
+
}), operatorValue && !isEmptyType && /*#__PURE__*/_jsx(ValueEditorCell, {
|
|
689
|
+
operatorValue: operatorValue,
|
|
690
|
+
filterValue: partialFilter.value,
|
|
691
|
+
onChange: handleValueChange,
|
|
692
|
+
onEnter: handleSave,
|
|
693
|
+
config: config,
|
|
694
|
+
maxMenuItems: maxMenuItems,
|
|
695
|
+
isDisabled: isReadOnly
|
|
696
|
+
}, valueEditorKey(partialFilter))]
|
|
669
697
|
})
|
|
670
698
|
}), !isEmptyType && /*#__PURE__*/_jsx("div", {
|
|
671
699
|
...{
|
|
@@ -77,7 +77,8 @@ export interface ToggleButtonProps extends BaseProps<HTMLButtonElement> {
|
|
|
77
77
|
elevation?: Elevation;
|
|
78
78
|
/**
|
|
79
79
|
* Whether the button is disabled.
|
|
80
|
-
* When used inside ToggleButtonGroup,
|
|
80
|
+
* When used inside ToggleButtonGroup, a disabled group disables this button
|
|
81
|
+
* too, but an enabled group does not re-enable a button that disables itself.
|
|
81
82
|
* @default false
|
|
82
83
|
*/
|
|
83
84
|
isDisabled?: boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ToggleButton.d.ts","sourceRoot":"","sources":["../../src/ToggleButton/ToggleButton.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,EAAgB,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAI3D,OAAO,EAAS,KAAK,UAAU,EAAC,MAAM,WAAW,CAAC;AAClD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,gBAAgB,CAAC;AAE9C,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AA4D5C,MAAM,WAAW,iBAAkB,SAAQ,SAAS,CAAC,iBAAiB,CAAC;IACrE,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IACnC;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IAEpB;;;;;;;OAOG;IACH,eAAe,CAAC,EAAE,CAChB,SAAS,EAAE,OAAO,EAClB,KAAK,EAAE,KAAK,CAAC,UAAU,CAAC,iBAAiB,CAAC,KACvC,IAAI,CAAC;IAEV;;;;;;;;;;;;;;;;;OAiBG;IACH,mBAAmB,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEnE;;;;OAIG;IACH,IAAI,CAAC,EAAE,UAAU,CAAC;IAElB;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;IAEtB
|
|
1
|
+
{"version":3,"file":"ToggleButton.d.ts","sourceRoot":"","sources":["../../src/ToggleButton/ToggleButton.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,EAAgB,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAI3D,OAAO,EAAS,KAAK,UAAU,EAAC,MAAM,WAAW,CAAC;AAClD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,gBAAgB,CAAC;AAE9C,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AA4D5C,MAAM,WAAW,iBAAkB,SAAQ,SAAS,CAAC,iBAAiB,CAAC;IACrE,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IACnC;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IAEpB;;;;;;;OAOG;IACH,eAAe,CAAC,EAAE,CAChB,SAAS,EAAE,OAAO,EAClB,KAAK,EAAE,KAAK,CAAC,UAAU,CAAC,iBAAiB,CAAC,KACvC,IAAI,CAAC;IAEV;;;;;;;;;;;;;;;;;OAiBG;IACH,mBAAmB,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEnE;;;;OAIG;IACH,IAAI,CAAC,EAAE,UAAU,CAAC;IAElB;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;IAEtB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IAErB;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IAEpB;;OAEG;IACH,IAAI,CAAC,EAAE,SAAS,CAAC;IAEjB;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IAErB;;;;;;;;;;OAUG;IACH,WAAW,CAAC,EAAE,SAAS,CAAC;IAExB;;;OAGG;IACH,QAAQ,CAAC,EAAE,SAAS,CAAC;IAErB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAMD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,YAAY,CAAC,EAC3B,GAAG,EACH,KAAK,EACL,SAAS,EAAE,aAAa,EACxB,eAAe,EAAE,mBAAmB,EACpC,mBAAmB,EACnB,IAAI,EAAE,QAAQ,EACd,SAAkB,EAClB,UAAU,EAAE,cAAsB,EAClC,SAAiB,EACjB,IAAI,EACJ,UAAkB,EAClB,WAAW,EACX,QAAQ,EACR,OAAO,EACP,KAAK,EACL,MAAM,EACN,SAAS,EAAE,UAAU,EACrB,KAAK,EACL,GAAG,KAAK,EACT,EAAE,iBAAiB,GAAG,SAAS,CAiH/B;yBArIe,YAAY"}
|
|
@@ -99,7 +99,13 @@ export function ToggleButton({
|
|
|
99
99
|
const group = useToggleButtonGroup();
|
|
100
100
|
const committedPressed = group && value != null ? group.selectedValues.has(value) : isPressedProp ?? false;
|
|
101
101
|
const size = sizeProp ?? group?.size ?? 'md';
|
|
102
|
-
|
|
102
|
+
// Either source disabling this button is enough. `??` could not express that:
|
|
103
|
+
// the group always supplies a boolean (its own prop defaults to false), so the
|
|
104
|
+
// fallback never ran and an enabled group handed a member that had disabled
|
|
105
|
+
// itself its availability back. A group still disables everything it contains
|
|
106
|
+
// — that is the half `??` got right — but it cannot re-enable a member
|
|
107
|
+
// (family:buttons FR3).
|
|
108
|
+
const isDisabled = (group?.isDisabled ?? false) || isDisabledProp;
|
|
103
109
|
|
|
104
110
|
// Track the pressed state optimistically so the button reflects the intended
|
|
105
111
|
// state immediately while an async action is pending. The optimistic update
|
|
@@ -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,EAguBrB,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
|
|
@@ -630,12 +630,17 @@ export const BaseTypeahead = function BaseTypeahead({
|
|
|
630
630
|
});
|
|
631
631
|
const selectedKey = value == null ? null : getKey(value.id, () => results.indexOf(value));
|
|
632
632
|
|
|
633
|
-
//
|
|
633
|
+
// A replaced or unmounted source ends its search lifetime: drop the pending
|
|
634
|
+
// debounce, cancel in-flight work where the source supports it, and advance
|
|
635
|
+
// the generation so a late response from the old source can no longer
|
|
636
|
+
// commit results into a typeahead that is now reading a different source.
|
|
634
637
|
useEffect(() => {
|
|
635
638
|
return () => {
|
|
636
639
|
if (searchTimeoutRef.current) {
|
|
637
640
|
clearTimeout(searchTimeoutRef.current);
|
|
638
641
|
}
|
|
642
|
+
// eslint-disable-next-line @eslint-react/exhaustive-deps -- a counter, not a node: the cleanup must advance the live generation
|
|
643
|
+
searchGenRef.current++;
|
|
639
644
|
searchSource.cancel?.();
|
|
640
645
|
};
|
|
641
646
|
}, [searchSource]);
|
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.da998a2",
|
|
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.da998a2",
|
|
676
|
+
"@astryxdesign/cli": "0.6.2-canary.da998a2",
|
|
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,54 @@ function NestedEditor({
|
|
|
606
610
|
return <TreeList items={items} density="balanced" />;
|
|
607
611
|
}
|
|
608
612
|
|
|
613
|
+
// =============================================================================
|
|
614
|
+
// Value editor cell
|
|
615
|
+
// =============================================================================
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* The value editor's identity is the (field, operator) pair it edits. Keying
|
|
619
|
+
* on it remounts the editor when either changes, so nothing an editor keeps
|
|
620
|
+
* privately — a Tokenizer's open menu, its query text, results fetched from
|
|
621
|
+
* the previous field's source — survives into the next field. Without the
|
|
622
|
+
* key, two fields sharing a value type (two `enum_list` fields, say) reuse
|
|
623
|
+
* one editor instance and the open menu keeps showing the old field's
|
|
624
|
+
* options.
|
|
625
|
+
*/
|
|
626
|
+
function valueEditorKey(filter: {field: string; operator?: string}): string {
|
|
627
|
+
return `${filter.field}\u0000${filter.operator ?? ''}`;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Wraps the value editor and moves focus into it once it mounts. Because the
|
|
632
|
+
* cell is keyed on the filter's field and operator, "mount" means both the
|
|
633
|
+
* popover opening and the user picking a different field or operator — the
|
|
634
|
+
* same focus handoff either way, so the freshly mounted editor can bootstrap
|
|
635
|
+
* its own suggestions.
|
|
636
|
+
*/
|
|
637
|
+
function ValueEditorCell(props: PowerSearchValueEditorProps) {
|
|
638
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
639
|
+
|
|
640
|
+
useEffect(() => {
|
|
641
|
+
const frame = requestAnimationFrame(() => {
|
|
642
|
+
const container = ref.current;
|
|
643
|
+
if (!container) {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const focusable = container.querySelector<HTMLElement>(
|
|
647
|
+
'input:not([disabled]), button:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
648
|
+
);
|
|
649
|
+
focusable?.focus();
|
|
650
|
+
});
|
|
651
|
+
return () => cancelAnimationFrame(frame);
|
|
652
|
+
}, []);
|
|
653
|
+
|
|
654
|
+
return (
|
|
655
|
+
<div ref={ref} {...stylex.props(styles.valueEditor)}>
|
|
656
|
+
<PowerSearchValueEditor {...props} />
|
|
657
|
+
</div>
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
609
661
|
// =============================================================================
|
|
610
662
|
// Main popover
|
|
611
663
|
// =============================================================================
|
|
@@ -625,22 +677,6 @@ export function PowerSearchEditPopover({
|
|
|
625
677
|
saveButtonLabelFromProps ?? t('@astryx.powersearch.editor.apply');
|
|
626
678
|
const [partialFilter, setPartialFilter] =
|
|
627
679
|
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
680
|
|
|
645
681
|
const currentOperator = partialFilter.operator
|
|
646
682
|
? config.getOperator(partialFilter.field, partialFilter.operator)
|
|
@@ -854,17 +890,16 @@ export function PowerSearchEditPopover({
|
|
|
854
890
|
</div>
|
|
855
891
|
)}
|
|
856
892
|
{operatorValue && !isEmptyType && (
|
|
857
|
-
<
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
</div>
|
|
893
|
+
<ValueEditorCell
|
|
894
|
+
key={valueEditorKey(partialFilter)}
|
|
895
|
+
operatorValue={operatorValue}
|
|
896
|
+
filterValue={partialFilter.value}
|
|
897
|
+
onChange={handleValueChange}
|
|
898
|
+
onEnter={handleSave}
|
|
899
|
+
config={config}
|
|
900
|
+
maxMenuItems={maxMenuItems}
|
|
901
|
+
isDisabled={isReadOnly}
|
|
902
|
+
/>
|
|
868
903
|
)}
|
|
869
904
|
</HStack>
|
|
870
905
|
</div>
|
|
@@ -570,6 +570,139 @@ describe('ToggleButtonGroup (multiple)', () => {
|
|
|
570
570
|
});
|
|
571
571
|
});
|
|
572
572
|
|
|
573
|
+
// =============================================================================
|
|
574
|
+
// Disabled state — family:buttons FR3 (disabled means non-operable)
|
|
575
|
+
// =============================================================================
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Two ways a ToggleButton becomes unavailable, and the rule that binds them:
|
|
579
|
+
* a group disables everything it contains, and a member can disable itself
|
|
580
|
+
* while the group stays enabled. Neither source may cancel the other out.
|
|
581
|
+
*
|
|
582
|
+
* The tooltip cases are here at the attribute and callback level only. A
|
|
583
|
+
* tooltip'd disabled toggle carries `aria-disabled` instead of the native
|
|
584
|
+
* `disabled` attribute, so whether a REAL mouse press is refused is an engine
|
|
585
|
+
* fact that jsdom's synthetic click cannot settle — that half lives in
|
|
586
|
+
* ./__tests__/ToggleButton.a11y.chromium.spec.ts.
|
|
587
|
+
*/
|
|
588
|
+
describe('disabled state', () => {
|
|
589
|
+
it('keeps a member disabled when the group disables nothing', async () => {
|
|
590
|
+
const user = userEvent.setup();
|
|
591
|
+
const handleChange = vi.fn();
|
|
592
|
+
render(
|
|
593
|
+
<ToggleButtonGroup value={null} onChange={handleChange} label="View mode">
|
|
594
|
+
<ToggleButton value="list" label="List" isDisabled />
|
|
595
|
+
<ToggleButton value="grid" label="Grid" />
|
|
596
|
+
</ToggleButtonGroup>,
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
expect(screen.getByRole('button', {name: 'List'})).toBeDisabled();
|
|
600
|
+
|
|
601
|
+
await user.click(screen.getByRole('button', {name: 'List'}));
|
|
602
|
+
expect(handleChange).not.toHaveBeenCalled();
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('leaves the rest of an enabled group selectable', async () => {
|
|
606
|
+
const user = userEvent.setup();
|
|
607
|
+
const handleChange = vi.fn();
|
|
608
|
+
render(
|
|
609
|
+
<ToggleButtonGroup value={null} onChange={handleChange} label="View mode">
|
|
610
|
+
<ToggleButton value="list" label="List" isDisabled />
|
|
611
|
+
<ToggleButton value="grid" label="Grid" />
|
|
612
|
+
</ToggleButtonGroup>,
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
expect(screen.getByRole('button', {name: 'Grid'})).toBeEnabled();
|
|
616
|
+
|
|
617
|
+
await user.click(screen.getByRole('button', {name: 'Grid'}));
|
|
618
|
+
expect(handleChange).toHaveBeenCalledWith('grid');
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it('still disables a member that says nothing when the group is disabled', async () => {
|
|
622
|
+
const user = userEvent.setup();
|
|
623
|
+
const handleChange = vi.fn();
|
|
624
|
+
render(
|
|
625
|
+
<ToggleButtonGroup
|
|
626
|
+
value={null}
|
|
627
|
+
onChange={handleChange}
|
|
628
|
+
label="View mode"
|
|
629
|
+
isDisabled>
|
|
630
|
+
<ToggleButton value="list" label="List" />
|
|
631
|
+
</ToggleButtonGroup>,
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
expect(screen.getByRole('button', {name: 'List'})).toBeDisabled();
|
|
635
|
+
|
|
636
|
+
await user.click(screen.getByRole('button', {name: 'List'}));
|
|
637
|
+
expect(handleChange).not.toHaveBeenCalled();
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('keeps a tooltip-bearing disabled member focusable but inert', async () => {
|
|
641
|
+
const user = userEvent.setup();
|
|
642
|
+
const handleChange = vi.fn();
|
|
643
|
+
render(
|
|
644
|
+
<ToggleButtonGroup value={null} onChange={handleChange} label="View mode">
|
|
645
|
+
<ToggleButton
|
|
646
|
+
value="list"
|
|
647
|
+
label="List"
|
|
648
|
+
tooltip="List view is unavailable for this dataset"
|
|
649
|
+
isDisabled
|
|
650
|
+
/>
|
|
651
|
+
</ToggleButtonGroup>,
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
const member = screen.getByRole('button', {name: 'List'});
|
|
655
|
+
// The tooltip is the disabled reason, so the control stays reachable to
|
|
656
|
+
// read it: aria-disabled rather than the native attribute, which would
|
|
657
|
+
// take it out of the tab order along with its own explanation.
|
|
658
|
+
expect(member).toHaveAttribute('aria-disabled', 'true');
|
|
659
|
+
expect(member).not.toBeDisabled();
|
|
660
|
+
|
|
661
|
+
await user.click(member);
|
|
662
|
+
expect(handleChange).not.toHaveBeenCalled();
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it('does not toggle a standalone disabled toggle that carries a tooltip', async () => {
|
|
666
|
+
const user = userEvent.setup();
|
|
667
|
+
const handleChange = vi.fn();
|
|
668
|
+
render(
|
|
669
|
+
<ToggleButton
|
|
670
|
+
label="Bold"
|
|
671
|
+
tooltip="Formatting is locked for this document"
|
|
672
|
+
isPressed={false}
|
|
673
|
+
onPressedChange={handleChange}
|
|
674
|
+
isDisabled
|
|
675
|
+
/>,
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
const toggle = screen.getByRole('button', {name: 'Bold'});
|
|
679
|
+
expect(toggle).toHaveAttribute('aria-disabled', 'true');
|
|
680
|
+
|
|
681
|
+
await user.click(toggle);
|
|
682
|
+
expect(handleChange).not.toHaveBeenCalled();
|
|
683
|
+
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it('still toggles an enabled toggle that carries the same tooltip', async () => {
|
|
687
|
+
const user = userEvent.setup();
|
|
688
|
+
const handleChange = vi.fn();
|
|
689
|
+
render(
|
|
690
|
+
<ToggleButton
|
|
691
|
+
label="Bold"
|
|
692
|
+
tooltip="Bold the selected text"
|
|
693
|
+
isPressed={false}
|
|
694
|
+
onPressedChange={handleChange}
|
|
695
|
+
/>,
|
|
696
|
+
);
|
|
697
|
+
|
|
698
|
+
const toggle = screen.getByRole('button', {name: 'Bold'});
|
|
699
|
+
expect(toggle).not.toHaveAttribute('aria-disabled');
|
|
700
|
+
|
|
701
|
+
await user.click(toggle);
|
|
702
|
+
expect(handleChange).toHaveBeenCalledWith(true, expect.anything());
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
|
|
573
706
|
// jsdom cannot emulate forced-colors rendering, so these assert that the
|
|
574
707
|
// compiled output includes the forced-colors rules; visual behavior needs
|
|
575
708
|
// manual verification under Windows High Contrast.
|
|
@@ -154,7 +154,8 @@ export interface ToggleButtonProps extends BaseProps<HTMLButtonElement> {
|
|
|
154
154
|
|
|
155
155
|
/**
|
|
156
156
|
* Whether the button is disabled.
|
|
157
|
-
* When used inside ToggleButtonGroup,
|
|
157
|
+
* When used inside ToggleButtonGroup, a disabled group disables this button
|
|
158
|
+
* too, but an enabled group does not re-enable a button that disables itself.
|
|
158
159
|
* @default false
|
|
159
160
|
*/
|
|
160
161
|
isDisabled?: boolean;
|
|
@@ -263,7 +264,13 @@ export function ToggleButton({
|
|
|
263
264
|
? group.selectedValues.has(value)
|
|
264
265
|
: (isPressedProp ?? false);
|
|
265
266
|
const size = sizeProp ?? group?.size ?? 'md';
|
|
266
|
-
|
|
267
|
+
// Either source disabling this button is enough. `??` could not express that:
|
|
268
|
+
// the group always supplies a boolean (its own prop defaults to false), so the
|
|
269
|
+
// fallback never ran and an enabled group handed a member that had disabled
|
|
270
|
+
// itself its availability back. A group still disables everything it contains
|
|
271
|
+
// — that is the half `??` got right — but it cannot re-enable a member
|
|
272
|
+
// (family:buttons FR3).
|
|
273
|
+
const isDisabled = (group?.isDisabled ?? false) || isDisabledProp;
|
|
267
274
|
|
|
268
275
|
// Track the pressed state optimistically so the button reflects the intended
|
|
269
276
|
// state immediately while an async action is pending. The optimistic update
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file ToggleButton.a11y.chromium.spec.ts
|
|
5
|
+
* @input Uses @playwright/test, the a11y-spec static Storybook server, and the
|
|
6
|
+
* checked-in ToggleButtonDisabledA11y stories
|
|
7
|
+
* @output Real-browser evidence for `docs/families/buttons.md` FR3 — disabled
|
|
8
|
+
* means non-operable — across the two ways a ToggleButton becomes disabled,
|
|
9
|
+
* plus the enabled control that catches a fix which disables too much.
|
|
10
|
+
* @position The half of FR3 that jsdom cannot honestly reach. A disabled toggle
|
|
11
|
+
* kept focusable by a tooltip carries `aria-disabled` instead of the native
|
|
12
|
+
* `disabled` attribute, so whether a real mouse press is refused is an engine
|
|
13
|
+
* fact — jsdom's synthetic click does not settle it
|
|
14
|
+
* (`docs/specs/AST-013/spec.md`: a browser claim is made in a browser).
|
|
15
|
+
*
|
|
16
|
+
* Each state drives a checked-in story rather than a page this file builds, so
|
|
17
|
+
* the reproduction path for any failure is a URL a person can open
|
|
18
|
+
* (`docs/specs/AST-009/spec.md` FR30). Each story counts its own activations,
|
|
19
|
+
* which is what `activationsOn` reads — a toggle that refuses a press leaves no
|
|
20
|
+
* trace on itself.
|
|
21
|
+
*
|
|
22
|
+
* Presses go through `page.mouse` at the control's own centre rather than
|
|
23
|
+
* `locator.click()`. Playwright's actionability wait would refuse to press a
|
|
24
|
+
* natively disabled control at all, which would prove nothing about what the
|
|
25
|
+
* engine does with a press that really lands.
|
|
26
|
+
*
|
|
27
|
+
* SYNC: Fixtures live in
|
|
28
|
+
* /apps/storybook/stories/ToggleButtonDisabledA11y.stories.tsx.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import {expect, test, type Page} from '@playwright/test';
|
|
32
|
+
import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
|
|
33
|
+
import {
|
|
34
|
+
DEFAULT_STORYBOOK_DIR,
|
|
35
|
+
serveStorybook,
|
|
36
|
+
type StaticServer,
|
|
37
|
+
} from '@astryxdesign/a11y-spec/storybook';
|
|
38
|
+
|
|
39
|
+
const STORY_BASE = 'a11y-togglebutton-disabled-state';
|
|
40
|
+
|
|
41
|
+
let storybook: StaticServer;
|
|
42
|
+
|
|
43
|
+
test.beforeAll(async () => {
|
|
44
|
+
storybook = await serveStorybook(
|
|
45
|
+
process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test.afterAll(async () => {
|
|
50
|
+
await storybook?.close();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** Load a story and wait for the control the assertions press. */
|
|
54
|
+
async function mountStory(page: Page, storyId: string): Promise<void> {
|
|
55
|
+
await page.goto(
|
|
56
|
+
`${storybook.origin}/iframe.html?id=${STORY_BASE}--${storyId}&viewMode=story`,
|
|
57
|
+
{waitUntil: 'load'},
|
|
58
|
+
);
|
|
59
|
+
await holdMotionStill(page);
|
|
60
|
+
await page
|
|
61
|
+
.locator('#storybook-root')
|
|
62
|
+
.getByRole('button')
|
|
63
|
+
.first()
|
|
64
|
+
.waitFor({state: 'attached'});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* How many times this story's own handler has run.
|
|
69
|
+
*
|
|
70
|
+
* The story publishes the count as an attribute, so this reads a fact the story
|
|
71
|
+
* renders rather than instrumenting the page from the test — a person opening
|
|
72
|
+
* the story sees the same number the spec does.
|
|
73
|
+
*/
|
|
74
|
+
async function activationsOn(page: Page): Promise<number> {
|
|
75
|
+
const raw = await page
|
|
76
|
+
.locator('[data-a11y-activations]')
|
|
77
|
+
.first()
|
|
78
|
+
.getAttribute('data-a11y-activations');
|
|
79
|
+
return Number(raw ?? '0');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A real mouse press at the control's own centre, landing whether or not
|
|
84
|
+
* Playwright would consider the control actionable. This is the press the
|
|
85
|
+
* expectations are about: a user aiming at a control that looks unavailable.
|
|
86
|
+
*/
|
|
87
|
+
async function pressWithMouse(page: Page, name: string): Promise<void> {
|
|
88
|
+
const control = page
|
|
89
|
+
.locator('#storybook-root')
|
|
90
|
+
.getByRole('button', {name, includeHidden: true})
|
|
91
|
+
.first();
|
|
92
|
+
const box = await control.boundingBox();
|
|
93
|
+
if (box == null) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`"${name}" has no layout box, so no mouse press could land on it`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether the engine reports this control as unavailable, by either of the two
|
|
103
|
+
* spellings FR3 permits: the native attribute, or focusable `aria-disabled`.
|
|
104
|
+
*/
|
|
105
|
+
async function isUnavailable(page: Page, name: string): Promise<boolean> {
|
|
106
|
+
return page
|
|
107
|
+
.locator('#storybook-root')
|
|
108
|
+
.getByRole('button', {name, includeHidden: true})
|
|
109
|
+
.first()
|
|
110
|
+
.evaluate(
|
|
111
|
+
element =>
|
|
112
|
+
(element as HTMLButtonElement).disabled ||
|
|
113
|
+
element.getAttribute('aria-disabled') === 'true',
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
test('a member that disables itself stays disabled inside an enabled group', async ({
|
|
118
|
+
page,
|
|
119
|
+
}) => {
|
|
120
|
+
await mountStory(page, 'group-enabled-member-disabled');
|
|
121
|
+
|
|
122
|
+
expect(
|
|
123
|
+
await isUnavailable(page, 'List'),
|
|
124
|
+
'the member sets isDisabled, so it must be exposed as unavailable even though the group disables nothing',
|
|
125
|
+
).toBe(true);
|
|
126
|
+
|
|
127
|
+
await pressWithMouse(page, 'List');
|
|
128
|
+
expect(
|
|
129
|
+
await activationsOn(page),
|
|
130
|
+
'a disabled member must not invoke the group selection callback (family:buttons FR3)',
|
|
131
|
+
).toBe(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('an enabled member of the same group still toggles', async ({page}) => {
|
|
135
|
+
await mountStory(page, 'group-enabled-member-disabled');
|
|
136
|
+
|
|
137
|
+
expect(await isUnavailable(page, 'Grid')).toBe(false);
|
|
138
|
+
|
|
139
|
+
await pressWithMouse(page, 'Grid');
|
|
140
|
+
expect(
|
|
141
|
+
await activationsOn(page),
|
|
142
|
+
'the sibling says nothing about its own availability and the group disables nothing, so it must still select',
|
|
143
|
+
).toBe(1);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('a group disabled state still disables a member that says nothing', async ({
|
|
147
|
+
page,
|
|
148
|
+
}) => {
|
|
149
|
+
await mountStory(page, 'group-disabled-member-silent');
|
|
150
|
+
|
|
151
|
+
expect(await isUnavailable(page, 'List')).toBe(true);
|
|
152
|
+
|
|
153
|
+
await pressWithMouse(page, 'List');
|
|
154
|
+
expect(
|
|
155
|
+
await activationsOn(page),
|
|
156
|
+
'a group disabled state overrides member availability (family:buttons FR3)',
|
|
157
|
+
).toBe(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('a disabled toggle kept focusable by its tooltip refuses a mouse press', async ({
|
|
161
|
+
page,
|
|
162
|
+
}) => {
|
|
163
|
+
await mountStory(page, 'disabled-with-tooltip');
|
|
164
|
+
|
|
165
|
+
const control = page.locator('#storybook-root').getByRole('button').first();
|
|
166
|
+
|
|
167
|
+
// The tooltip is why this control is focusable rather than natively disabled,
|
|
168
|
+
// so the press has to be made the way a user reaches it: hover first, let the
|
|
169
|
+
// tip come up, then press. Pressing cold would skip the hover state the
|
|
170
|
+
// tooltip's own pointer listeners run in.
|
|
171
|
+
await control.hover();
|
|
172
|
+
await expect(page.getByRole('tooltip')).toBeVisible();
|
|
173
|
+
|
|
174
|
+
expect(
|
|
175
|
+
await control.evaluate(element => element.getAttribute('aria-disabled')),
|
|
176
|
+
'a tooltip keeps the reason reachable by making the control focusable rather than natively disabled',
|
|
177
|
+
).toBe('true');
|
|
178
|
+
expect(await control.getAttribute('aria-pressed')).toBe('false');
|
|
179
|
+
|
|
180
|
+
await pressWithMouse(page, 'Bold');
|
|
181
|
+
|
|
182
|
+
expect(
|
|
183
|
+
await activationsOn(page),
|
|
184
|
+
'focusable is not operable: a disabled toggle must not invoke onPressedChange from a mouse press (family:buttons FR3)',
|
|
185
|
+
).toBe(0);
|
|
186
|
+
expect(
|
|
187
|
+
await control.getAttribute('aria-pressed'),
|
|
188
|
+
'the pressed state must not flip',
|
|
189
|
+
).toBe('false');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('an enabled toggle with the same tooltip still toggles on a mouse press', async ({
|
|
193
|
+
page,
|
|
194
|
+
}) => {
|
|
195
|
+
await mountStory(page, 'enabled-with-tooltip');
|
|
196
|
+
|
|
197
|
+
const control = page.locator('#storybook-root').getByRole('button').first();
|
|
198
|
+
await control.hover();
|
|
199
|
+
await expect(page.getByRole('tooltip')).toBeVisible();
|
|
200
|
+
expect(await control.getAttribute('aria-pressed')).toBe('false');
|
|
201
|
+
|
|
202
|
+
await pressWithMouse(page, 'Bold');
|
|
203
|
+
|
|
204
|
+
expect(
|
|
205
|
+
await activationsOn(page),
|
|
206
|
+
'the enabled path must be untouched by the disabled-state fix',
|
|
207
|
+
).toBe(1);
|
|
208
|
+
expect(await control.getAttribute('aria-pressed')).toBe('true');
|
|
209
|
+
});
|
|
@@ -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
|
|
@@ -987,12 +987,17 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
|
|
|
987
987
|
const selectedKey =
|
|
988
988
|
value == null ? null : getKey(value.id, () => results.indexOf(value));
|
|
989
989
|
|
|
990
|
-
//
|
|
990
|
+
// A replaced or unmounted source ends its search lifetime: drop the pending
|
|
991
|
+
// debounce, cancel in-flight work where the source supports it, and advance
|
|
992
|
+
// the generation so a late response from the old source can no longer
|
|
993
|
+
// commit results into a typeahead that is now reading a different source.
|
|
991
994
|
useEffect(() => {
|
|
992
995
|
return () => {
|
|
993
996
|
if (searchTimeoutRef.current) {
|
|
994
997
|
clearTimeout(searchTimeoutRef.current);
|
|
995
998
|
}
|
|
999
|
+
// eslint-disable-next-line @eslint-react/exhaustive-deps -- a counter, not a node: the cleanup must advance the live generation
|
|
1000
|
+
searchGenRef.current++;
|
|
996
1001
|
searchSource.cancel?.();
|
|
997
1002
|
};
|
|
998
1003
|
}, [searchSource]);
|
|
@@ -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', () => {
|