@astryxdesign/core 0.7.0-canary.fe57f54 → 0.7.0
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/CHANGELOG.md +186 -0
- package/dist/BottomSheet/BottomSheet.d.ts +5 -4
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetPanel.d.ts +1 -1
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetPanel.js +1 -1
- package/dist/Calendar/styles.d.ts +6 -6
- package/dist/Chat/ChatComposerInput.d.ts +0 -11
- package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
- package/dist/Chat/ChatComposerInput.js +0 -11
- package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
- package/dist/Chat/useTriggerMenu.js +1 -1
- package/dist/CommandPalette/CommandPaletteList.d.ts +3 -2
- package/dist/CommandPalette/CommandPaletteList.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteList.js +3 -2
- package/dist/DateInput/TouchDateField.d.ts.map +1 -1
- package/dist/DateInput/TouchDateField.js +4 -1
- package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
- package/dist/DateTimeInput/TouchDateTimeField.js +29 -13
- package/dist/DropdownMenu/DropdownMenu.d.ts +11 -0
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +173 -11
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +13 -2
- package/dist/DropdownMenu/menuRegionsContext.d.ts +2 -0
- package/dist/DropdownMenu/menuRegionsContext.d.ts.map +1 -0
- package/dist/DropdownMenu/menuRegionsContext.js +17 -0
- package/dist/Field/inputStyles.stylex.d.ts +3 -3
- package/dist/Layout/container.stylex.d.ts +18 -20
- package/dist/Layout/container.stylex.d.ts.map +1 -1
- package/dist/Layout/container.stylex.js +15 -25
- package/dist/Layout/padding.stylex.d.ts +231 -231
- package/dist/Lightbox/Lightbox.d.ts.map +1 -1
- package/dist/Lightbox/Lightbox.js +15 -6
- package/dist/Markdown/plugins/protocol.d.ts.map +1 -1
- package/dist/Markdown/plugins/protocol.js +33 -8
- package/dist/NavItem/navItemStyles.stylex.d.ts +5 -5
- package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearch.js +21 -1
- package/dist/PowerSearch/PowerSearchEditPopover.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearchEditPopover.js +30 -15
- package/dist/Stack/stack.stylex.d.ts +22 -22
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +89 -14
- package/dist/Table/table.stylex.d.ts +6 -6
- package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
- package/dist/TimeInput/TouchTimeField.js +4 -1
- package/dist/Tokenizer/Tokenizer.d.ts +1 -12
- package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
- package/dist/Tokenizer/Tokenizer.js +1 -13
- package/dist/Typeahead/BaseTypeahead.d.ts +0 -11
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +1 -9
- package/dist/Typeahead/Typeahead.d.ts +1 -12
- package/dist/Typeahead/Typeahead.d.ts.map +1 -1
- package/dist/Typeahead/Typeahead.js +1 -13
- package/dist/astryx.css +17 -12
- package/dist/hooks/useFocusTrap.d.ts +8 -0
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +5 -1
- package/dist/hooks/useRenamedProp.d.ts +6 -6
- package/dist/hooks/useRenamedProp.js +6 -6
- package/dist/reset.a11y.chromium.spec.d.ts +2 -0
- package/dist/reset.a11y.chromium.spec.d.ts.map +1 -0
- package/dist/reset.a11y.chromium.spec.js +176 -0
- package/dist/theme/tokens.stylex.d.ts +30 -30
- package/dist/theme/tokens.stylex.d.ts.map +1 -1
- package/dist/theme/tokens.stylex.js +18 -15
- package/dist/theme/useTheme.d.ts +1 -1
- package/package.json +4 -4
- package/src/BottomSheet/BottomSheet.doc.mjs +4 -4
- package/src/BottomSheet/BottomSheet.tsx +5 -4
- package/src/BottomSheet/BottomSheetPanel.test.tsx +6 -9
- package/src/BottomSheet/BottomSheetPanel.tsx +2 -3
- package/src/Chat/ChatComposerInput.doc.mjs +3 -3
- package/src/Chat/ChatComposerInput.test.tsx +7 -55
- package/src/Chat/ChatComposerInput.tsx +0 -36
- package/src/Chat/useTriggerMenu.tsx +1 -4
- package/src/CommandPalette/CommandPalette.spec.md +2 -0
- package/src/CommandPalette/CommandPaletteList.spec.md +189 -0
- package/src/CommandPalette/CommandPaletteList.test.tsx +47 -0
- package/src/CommandPalette/CommandPaletteList.tsx +3 -2
- package/src/DateInput/TouchDateField.tsx +3 -1
- package/src/DateTimeInput/DateTimeInputTouch.test.tsx +60 -1
- package/src/DateTimeInput/TouchDateTimeField.tsx +31 -14
- package/src/DropdownMenu/DropdownMenu.doc.mjs +12 -0
- package/src/DropdownMenu/DropdownMenu.spec.md +65 -40
- package/src/DropdownMenu/DropdownMenu.test.tsx +457 -0
- package/src/DropdownMenu/DropdownMenu.tsx +278 -49
- package/src/DropdownMenu/DropdownMenuCheckboxItem.doc.mjs +6 -3
- package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +2 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +11 -1
- package/src/DropdownMenu/__tests__/MenuHeaderFooter.a11y.chromium.spec.ts +195 -0
- package/src/DropdownMenu/menuRegionsContext.ts +17 -0
- package/src/Layout/container.stylex.ts +11 -21
- package/src/Lightbox/Lightbox.test.tsx +58 -0
- package/src/Lightbox/Lightbox.tsx +12 -6
- package/src/Markdown/plugins/protocol.ts +45 -12
- package/src/Markdown/plugins/sourceLines.test.tsx +121 -0
- package/src/PowerSearch/PowerSearch.tsx +28 -1
- package/src/PowerSearch/PowerSearchEditPopover.test.tsx +284 -0
- package/src/PowerSearch/PowerSearchEditPopover.tsx +41 -13
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +33 -9
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +81 -18
- package/src/TimeInput/TouchTimeField.tsx +3 -1
- package/src/Tokenizer/Tokenizer.doc.mjs +0 -16
- package/src/Tokenizer/Tokenizer.test.tsx +12 -63
- package/src/Tokenizer/Tokenizer.tsx +1 -25
- package/src/Toolbar/Toolbar.test.tsx +29 -0
- package/src/Typeahead/BaseTypeahead.doc.mjs +0 -16
- package/src/Typeahead/BaseTypeahead.test.tsx +1 -1
- package/src/Typeahead/BaseTypeahead.tsx +1 -21
- package/src/Typeahead/Typeahead.doc.mjs +0 -7
- package/src/Typeahead/Typeahead.test.tsx +24 -42
- package/src/Typeahead/Typeahead.tsx +1 -25
- package/src/hooks/useFocusTrap.ts +5 -1
- package/src/hooks/useRenamedProp.ts +6 -6
- package/src/reset.a11y.chromium.spec.ts +178 -0
- package/src/reset.css +8 -8
- package/src/reset.test.ts +112 -0
- package/src/theme/__tests__/SpacingRootFontSize.a11y.chromium.spec.ts +198 -0
- package/src/theme/tokens.stylex.ts +18 -15
- package/src/theme/tokens.test.ts +1 -1
- package/src/theme/useTheme.test.tsx +4 -4
- package/src/theme/useTheme.ts +1 -1
|
@@ -826,3 +826,287 @@ describe('value save', () => {
|
|
|
826
826
|
});
|
|
827
827
|
});
|
|
828
828
|
});
|
|
829
|
+
|
|
830
|
+
describe('empty-operator save', () => {
|
|
831
|
+
const emptyConfig: PowerSearchConfig = {
|
|
832
|
+
name: 'test-empty',
|
|
833
|
+
fields: [
|
|
834
|
+
{
|
|
835
|
+
key: 'title',
|
|
836
|
+
label: 'Title',
|
|
837
|
+
operators: [
|
|
838
|
+
{key: 'contains', label: 'contains', value: {type: 'string'}},
|
|
839
|
+
{key: 'is_empty', label: 'is empty', value: {type: 'empty'}},
|
|
840
|
+
],
|
|
841
|
+
},
|
|
842
|
+
{
|
|
843
|
+
key: 'assignee',
|
|
844
|
+
label: 'Assignee',
|
|
845
|
+
defaultOperator: 'is_unset',
|
|
846
|
+
operators: [
|
|
847
|
+
{key: 'is_unset', label: 'is unset', value: {type: 'empty'}},
|
|
848
|
+
{key: 'is', label: 'is', value: {type: 'string'}},
|
|
849
|
+
],
|
|
850
|
+
},
|
|
851
|
+
],
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
type HarnessProps = {
|
|
855
|
+
filter: React.ComponentProps<typeof PowerSearchEditPopover>['filter'];
|
|
856
|
+
onSave: (filter: PowerSearchFilter | null) => void;
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
// A parent that keeps the editor mounted and re-renders with a fresh
|
|
860
|
+
// onSave identity on every render, as an un-memoized consumer does.
|
|
861
|
+
function Harness({filter, onSave}: HarnessProps) {
|
|
862
|
+
const config = useInternalConfig(emptyConfig);
|
|
863
|
+
const [, setTick] = useState(0);
|
|
864
|
+
return (
|
|
865
|
+
<>
|
|
866
|
+
<button type="button" onClick={() => setTick(n => n + 1)}>
|
|
867
|
+
Rerender
|
|
868
|
+
</button>
|
|
869
|
+
<PowerSearchEditPopover
|
|
870
|
+
config={config}
|
|
871
|
+
filter={filter}
|
|
872
|
+
mode="edit"
|
|
873
|
+
onSave={saved => onSave(saved)}
|
|
874
|
+
onCancel={() => {}}
|
|
875
|
+
/>
|
|
876
|
+
</>
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// The save is queued as a microtask after the choice; flush it.
|
|
881
|
+
const selectOption = async (comboboxName: string, optionName: string) => {
|
|
882
|
+
fireEvent.click(screen.getByRole('combobox', {name: comboboxName}));
|
|
883
|
+
fireEvent.click(
|
|
884
|
+
screen.getByRole('option', {name: optionName, hidden: true}),
|
|
885
|
+
);
|
|
886
|
+
await act(async () => {});
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
it('saves exactly once when an empty operator is chosen', async () => {
|
|
890
|
+
const onSave = vi.fn();
|
|
891
|
+
render(
|
|
892
|
+
<React.StrictMode>
|
|
893
|
+
<Harness
|
|
894
|
+
filter={{field: 'title', operator: 'contains', value: undefined}}
|
|
895
|
+
onSave={onSave}
|
|
896
|
+
/>
|
|
897
|
+
</React.StrictMode>,
|
|
898
|
+
);
|
|
899
|
+
expect(onSave).not.toHaveBeenCalled();
|
|
900
|
+
|
|
901
|
+
await selectOption('Operator', 'is empty');
|
|
902
|
+
fireEvent.click(screen.getByRole('button', {name: 'Rerender'}));
|
|
903
|
+
fireEvent.click(screen.getByRole('button', {name: 'Rerender'}));
|
|
904
|
+
|
|
905
|
+
expect(onSave).toHaveBeenCalledTimes(1);
|
|
906
|
+
expect(onSave).toHaveBeenCalledWith({
|
|
907
|
+
field: 'title',
|
|
908
|
+
operator: 'is_empty',
|
|
909
|
+
value: {type: 'empty'},
|
|
910
|
+
});
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
it('saves exactly once when the field switches to one whose default operator is empty', async () => {
|
|
914
|
+
const onSave = vi.fn();
|
|
915
|
+
render(
|
|
916
|
+
<React.StrictMode>
|
|
917
|
+
<Harness
|
|
918
|
+
filter={{field: 'title', operator: 'contains', value: undefined}}
|
|
919
|
+
onSave={onSave}
|
|
920
|
+
/>
|
|
921
|
+
</React.StrictMode>,
|
|
922
|
+
);
|
|
923
|
+
|
|
924
|
+
await selectOption('Field', 'Assignee');
|
|
925
|
+
fireEvent.click(screen.getByRole('button', {name: 'Rerender'}));
|
|
926
|
+
|
|
927
|
+
expect(onSave).toHaveBeenCalledTimes(1);
|
|
928
|
+
expect(onSave).toHaveBeenCalledWith({
|
|
929
|
+
field: 'assignee',
|
|
930
|
+
operator: 'is_unset',
|
|
931
|
+
value: {type: 'empty'},
|
|
932
|
+
});
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
it('does not save again when the current empty operator or field is re-chosen', async () => {
|
|
936
|
+
const onSave = vi.fn();
|
|
937
|
+
render(
|
|
938
|
+
<React.StrictMode>
|
|
939
|
+
<Harness
|
|
940
|
+
filter={{field: 'title', operator: 'contains', value: undefined}}
|
|
941
|
+
onSave={onSave}
|
|
942
|
+
/>
|
|
943
|
+
</React.StrictMode>,
|
|
944
|
+
);
|
|
945
|
+
|
|
946
|
+
await selectOption('Operator', 'is empty');
|
|
947
|
+
expect(onSave).toHaveBeenCalledTimes(1);
|
|
948
|
+
await selectOption('Operator', 'is empty');
|
|
949
|
+
expect(onSave).toHaveBeenCalledTimes(1);
|
|
950
|
+
|
|
951
|
+
await selectOption('Field', 'Assignee');
|
|
952
|
+
expect(onSave).toHaveBeenCalledTimes(2);
|
|
953
|
+
await selectOption('Field', 'Assignee');
|
|
954
|
+
expect(onSave).toHaveBeenCalledTimes(2);
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
it('does not save just because it opens on a complete empty-operator filter', async () => {
|
|
958
|
+
const onSave = vi.fn();
|
|
959
|
+
render(
|
|
960
|
+
<React.StrictMode>
|
|
961
|
+
<Harness
|
|
962
|
+
filter={{field: 'title', operator: 'is_empty', value: undefined}}
|
|
963
|
+
onSave={onSave}
|
|
964
|
+
/>
|
|
965
|
+
</React.StrictMode>,
|
|
966
|
+
);
|
|
967
|
+
fireEvent.click(screen.getByRole('button', {name: 'Rerender'}));
|
|
968
|
+
await act(async () => {});
|
|
969
|
+
|
|
970
|
+
expect(onSave).not.toHaveBeenCalled();
|
|
971
|
+
});
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
describe('empty-operator choices inside PowerSearch', () => {
|
|
975
|
+
const emptyConfig: PowerSearchConfig = {
|
|
976
|
+
name: 'test-empty-ps',
|
|
977
|
+
fields: [
|
|
978
|
+
{
|
|
979
|
+
key: 'title',
|
|
980
|
+
label: 'Title',
|
|
981
|
+
operators: [
|
|
982
|
+
{key: 'contains', label: 'contains', value: {type: 'string'}},
|
|
983
|
+
{key: 'is_empty', label: 'is empty', value: {type: 'empty'}},
|
|
984
|
+
],
|
|
985
|
+
},
|
|
986
|
+
],
|
|
987
|
+
};
|
|
988
|
+
|
|
989
|
+
function Harness({
|
|
990
|
+
initial,
|
|
991
|
+
onChange,
|
|
992
|
+
}: {
|
|
993
|
+
initial: PowerSearchFilter[];
|
|
994
|
+
onChange: (
|
|
995
|
+
filters: ReadonlyArray<PowerSearchFilter>,
|
|
996
|
+
type: string,
|
|
997
|
+
index: number,
|
|
998
|
+
) => void;
|
|
999
|
+
}) {
|
|
1000
|
+
const [filters, setFilters] = useState(initial);
|
|
1001
|
+
return (
|
|
1002
|
+
<PowerSearch
|
|
1003
|
+
config={emptyConfig}
|
|
1004
|
+
filters={filters}
|
|
1005
|
+
onChange={(next, type, index) => {
|
|
1006
|
+
onChange(next, type, index);
|
|
1007
|
+
setFilters([...next]);
|
|
1008
|
+
}}
|
|
1009
|
+
/>
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
it('returns focus to the search input after an empty operator is chosen', async () => {
|
|
1014
|
+
const onChange = vi.fn();
|
|
1015
|
+
render(
|
|
1016
|
+
<Harness
|
|
1017
|
+
initial={[
|
|
1018
|
+
{
|
|
1019
|
+
field: 'title',
|
|
1020
|
+
operator: 'contains',
|
|
1021
|
+
value: {type: 'string', value: 'x'},
|
|
1022
|
+
},
|
|
1023
|
+
]}
|
|
1024
|
+
onChange={onChange}
|
|
1025
|
+
/>,
|
|
1026
|
+
);
|
|
1027
|
+
act(() => {
|
|
1028
|
+
fireEvent.click(screen.getByText('Title: contains'));
|
|
1029
|
+
flushRAF();
|
|
1030
|
+
});
|
|
1031
|
+
fireEvent.click(
|
|
1032
|
+
screen.getByRole('combobox', {name: 'Operator', hidden: true}),
|
|
1033
|
+
);
|
|
1034
|
+
fireEvent.click(
|
|
1035
|
+
screen.getByRole('option', {name: 'is empty', hidden: true}),
|
|
1036
|
+
);
|
|
1037
|
+
await act(async () => {});
|
|
1038
|
+
act(() => flushRAF());
|
|
1039
|
+
|
|
1040
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
1041
|
+
expect(onChange.mock.calls[0][1]).toBe('edit');
|
|
1042
|
+
expect(document.activeElement).not.toBe(document.body);
|
|
1043
|
+
expect(screen.getAllByRole('combobox')[0]).toHaveFocus();
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
it('commits a clicked empty-operator token once without opening the editor', () => {
|
|
1047
|
+
const onChange = vi.fn();
|
|
1048
|
+
const {container} = render(
|
|
1049
|
+
<Harness
|
|
1050
|
+
initial={[
|
|
1051
|
+
{field: 'title', operator: 'is_empty', value: {type: 'empty'}},
|
|
1052
|
+
]}
|
|
1053
|
+
onChange={onChange}
|
|
1054
|
+
/>,
|
|
1055
|
+
);
|
|
1056
|
+
act(() => {
|
|
1057
|
+
fireEvent.click(screen.getByText(/Title: is empty/));
|
|
1058
|
+
flushRAF();
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
1062
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
1063
|
+
[{field: 'title', operator: 'is_empty', value: {type: 'empty'}}],
|
|
1064
|
+
'edit',
|
|
1065
|
+
0,
|
|
1066
|
+
);
|
|
1067
|
+
expect(getEditPopoverText(container)).toBe('');
|
|
1068
|
+
});
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
describe('empty-operator token with an Editor override', () => {
|
|
1072
|
+
it('opens the consumer empty Editor instead of committing', () => {
|
|
1073
|
+
const onChange = vi.fn();
|
|
1074
|
+
const EmptyEditor = ({onCancel}: {onCancel: () => void}) => (
|
|
1075
|
+
<div data-testid="custom-empty-editor">
|
|
1076
|
+
<button type="button" onClick={onCancel}>
|
|
1077
|
+
Close custom
|
|
1078
|
+
</button>
|
|
1079
|
+
</div>
|
|
1080
|
+
);
|
|
1081
|
+
const overrideConfig: PowerSearchConfig = {
|
|
1082
|
+
name: 'test-empty-override',
|
|
1083
|
+
fields: [
|
|
1084
|
+
{
|
|
1085
|
+
key: 'title',
|
|
1086
|
+
label: 'Title',
|
|
1087
|
+
operators: [
|
|
1088
|
+
{key: 'contains', label: 'contains', value: {type: 'string'}},
|
|
1089
|
+
{key: 'is_empty', label: 'is empty', value: {type: 'empty'}},
|
|
1090
|
+
],
|
|
1091
|
+
},
|
|
1092
|
+
],
|
|
1093
|
+
};
|
|
1094
|
+
render(
|
|
1095
|
+
<PowerSearch
|
|
1096
|
+
config={overrideConfig}
|
|
1097
|
+
filters={[
|
|
1098
|
+
{field: 'title', operator: 'is_empty', value: {type: 'empty'}},
|
|
1099
|
+
]}
|
|
1100
|
+
onChange={onChange}
|
|
1101
|
+
components={{empty: {Editor: EmptyEditor}}}
|
|
1102
|
+
/>,
|
|
1103
|
+
);
|
|
1104
|
+
act(() => {
|
|
1105
|
+
fireEvent.click(screen.getByText(/Title: is empty/));
|
|
1106
|
+
flushRAF();
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
expect(screen.getByTestId('custom-empty-editor')).toBeInTheDocument();
|
|
1110
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
1111
|
+
});
|
|
1112
|
+
});
|
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
PartialFilter,
|
|
34
34
|
FilterValue,
|
|
35
35
|
OperatorValue,
|
|
36
|
+
PowerSearchOperator,
|
|
36
37
|
} from './types';
|
|
37
38
|
|
|
38
39
|
// Below the popover layer's 400px floor (see popoverLayerStyles in
|
|
@@ -689,6 +690,26 @@ export function PowerSearchEditPopover({
|
|
|
689
690
|
}));
|
|
690
691
|
}, [config, partialFilter.field, t]);
|
|
691
692
|
|
|
693
|
+
// An "empty" operator takes no value, so choosing one completes the filter:
|
|
694
|
+
// save it from the choice itself, once, rather than reacting to the state.
|
|
695
|
+
// The save is queued as a microtask so it runs after the Selector reporting
|
|
696
|
+
// the choice has finished closing and refocusing its trigger; a parent that
|
|
697
|
+
// closes the editor on save can then move focus without the Selector taking
|
|
698
|
+
// it back onto a node that is about to unmount.
|
|
699
|
+
const saveIfEmptyOperator = useCallback(
|
|
700
|
+
(fieldKey: string, operator: PowerSearchOperator | undefined) => {
|
|
701
|
+
if (fieldKey && operator?.value.type === 'empty') {
|
|
702
|
+
const filter: PowerSearchFilter = {
|
|
703
|
+
field: fieldKey,
|
|
704
|
+
operator: operator.key,
|
|
705
|
+
value: {type: 'empty'},
|
|
706
|
+
};
|
|
707
|
+
queueMicrotask(() => onSave(filter));
|
|
708
|
+
}
|
|
709
|
+
},
|
|
710
|
+
[onSave],
|
|
711
|
+
);
|
|
712
|
+
|
|
692
713
|
const handleFieldChange = useCallback(
|
|
693
714
|
(fieldKey: string) => {
|
|
694
715
|
const defaultOp = config.getDefaultOperator(fieldKey);
|
|
@@ -697,8 +718,16 @@ export function PowerSearchEditPopover({
|
|
|
697
718
|
operator: defaultOp?.key,
|
|
698
719
|
value: undefined,
|
|
699
720
|
});
|
|
721
|
+
// Re-choosing the current field with its current operator changes
|
|
722
|
+
// nothing, so it saves nothing.
|
|
723
|
+
if (
|
|
724
|
+
fieldKey !== partialFilter.field ||
|
|
725
|
+
defaultOp?.key !== partialFilter.operator
|
|
726
|
+
) {
|
|
727
|
+
saveIfEmptyOperator(fieldKey, defaultOp);
|
|
728
|
+
}
|
|
700
729
|
},
|
|
701
|
-
[config],
|
|
730
|
+
[config, partialFilter.field, partialFilter.operator, saveIfEmptyOperator],
|
|
702
731
|
);
|
|
703
732
|
|
|
704
733
|
const handleOperatorChange = useCallback(
|
|
@@ -712,8 +741,18 @@ export function PowerSearchEditPopover({
|
|
|
712
741
|
operator: operatorKey,
|
|
713
742
|
value: keepValue ? prev.value : undefined,
|
|
714
743
|
}));
|
|
744
|
+
// Re-choosing the current operator changes nothing, so it saves nothing.
|
|
745
|
+
if (operatorKey !== partialFilter.operator) {
|
|
746
|
+
saveIfEmptyOperator(partialFilter.field, newOp);
|
|
747
|
+
}
|
|
715
748
|
},
|
|
716
|
-
[
|
|
749
|
+
[
|
|
750
|
+
config,
|
|
751
|
+
partialFilter.field,
|
|
752
|
+
partialFilter.operator,
|
|
753
|
+
currentOperator,
|
|
754
|
+
saveIfEmptyOperator,
|
|
755
|
+
],
|
|
717
756
|
);
|
|
718
757
|
|
|
719
758
|
const handleValueChange = useCallback(
|
|
@@ -769,17 +808,6 @@ export function PowerSearchEditPopover({
|
|
|
769
808
|
const isEmptyType = operatorValue?.type === 'empty';
|
|
770
809
|
const isNestedType = operatorValue?.type === 'nested';
|
|
771
810
|
|
|
772
|
-
// For empty type, auto-save on mount
|
|
773
|
-
useEffect(() => {
|
|
774
|
-
if (isEmptyType && partialFilter.field && partialFilter.operator) {
|
|
775
|
-
onSave({
|
|
776
|
-
field: partialFilter.field,
|
|
777
|
-
operator: partialFilter.operator,
|
|
778
|
-
value: {type: 'empty'},
|
|
779
|
-
});
|
|
780
|
-
}
|
|
781
|
-
}, [isEmptyType, partialFilter.field, partialFilter.operator, onSave]);
|
|
782
|
-
|
|
783
811
|
const showOperatorSelector = operatorOptions.length > 1 || !isEmptyType;
|
|
784
812
|
|
|
785
813
|
// Nested filter editing
|
|
@@ -6,7 +6,6 @@ import {useState} from 'react';
|
|
|
6
6
|
import {Table} from '../../Table';
|
|
7
7
|
import type {TableColumn} from '../../types';
|
|
8
8
|
import {useTableRowExpansion} from './useTableRowExpansion';
|
|
9
|
-
import {spacingDefaults} from '../../../theme/tokens.stylex';
|
|
10
9
|
import {InternationalizationProvider} from '../../../i18n';
|
|
11
10
|
|
|
12
11
|
// popover mock for context-menu tests
|
|
@@ -236,25 +235,50 @@ describe('useTableRowExpansion (detail panel)', () => {
|
|
|
236
235
|
// content does: the chevron column's width plus a cell's own padding.
|
|
237
236
|
render(<Harness initialExpanded={new Set(['a'])} />);
|
|
238
237
|
expect(screen.getByTestId('panel').closest('td')).toHaveStyle({
|
|
239
|
-
paddingInlineStart: 'calc(
|
|
238
|
+
paddingInlineStart: 'calc(40px + var(--spacing-3))',
|
|
240
239
|
});
|
|
241
240
|
});
|
|
242
241
|
|
|
243
242
|
it('tracks the table density it is rendered at', () => {
|
|
244
243
|
render(<Harness initialExpanded={new Set(['a'])} density="spacious" />);
|
|
245
244
|
expect(screen.getByTestId('panel').closest('td')).toHaveStyle({
|
|
246
|
-
paddingInlineStart: 'calc(
|
|
245
|
+
paddingInlineStart: 'calc(40px + var(--spacing-4))',
|
|
247
246
|
});
|
|
248
247
|
});
|
|
249
248
|
|
|
250
|
-
it(
|
|
251
|
-
// The column width is a number the layout does arithmetic on, so it
|
|
252
|
-
//
|
|
253
|
-
// the first column,
|
|
254
|
-
//
|
|
249
|
+
it("indents the panel by the chevron column's own width", () => {
|
|
250
|
+
// The column width is a number the layout does arithmetic on, so it stays
|
|
251
|
+
// in pixels at every root font size. The panel's indent has to line up
|
|
252
|
+
// with the first column, so it spells the same pixels, not a rem spacing
|
|
253
|
+
// token. Pin the two together so neither can move without the other.
|
|
255
254
|
render(<Harness initialExpanded={new Set(['a'])} />);
|
|
256
255
|
const chevronHeader = screen.getAllByRole('columnheader')[0];
|
|
257
|
-
|
|
256
|
+
const columnWidth = getComputedStyle(chevronHeader).width;
|
|
257
|
+
const panelCell = screen
|
|
258
|
+
.getByTestId('panel')
|
|
259
|
+
.closest('td') as HTMLTableCellElement;
|
|
260
|
+
expect(columnWidth).toBe('40px');
|
|
261
|
+
expect(getComputedStyle(panelCell).paddingInlineStart).toMatch(
|
|
262
|
+
new RegExp(`^calc\\(${columnWidth} \\+ `),
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('keeps the chevron column free of the cell insets', () => {
|
|
267
|
+
// The column is fixed pixels while a cell's insets follow the spacing
|
|
268
|
+
// scale; laid out after them, the button ran past the column's edge. The
|
|
269
|
+
// body cell drops its own inline-start inset (the button then centers
|
|
270
|
+
// itself; the browser spec measures that), and the header cell drops both.
|
|
271
|
+
render(<Harness initialExpanded={new Set(['a'])} />);
|
|
272
|
+
const button = screen.getAllByRole('button', {name: /collapse row/i})[0];
|
|
273
|
+
expect(
|
|
274
|
+
getComputedStyle(button.closest('td') as HTMLTableCellElement)
|
|
275
|
+
.paddingInlineStart,
|
|
276
|
+
).toMatch(/^0(px)?$/);
|
|
277
|
+
// The header cell holds only a hidden label; its insets alone would
|
|
278
|
+
// outgrow the 40px column at a large root font size.
|
|
279
|
+
const header = getComputedStyle(screen.getAllByRole('columnheader')[0]);
|
|
280
|
+
expect(header.paddingInlineStart).toMatch(/^0(px)?$/);
|
|
281
|
+
expect(header.paddingInlineEnd).toMatch(/^0(px)?$/);
|
|
258
282
|
});
|
|
259
283
|
|
|
260
284
|
describe('row divider placement', () => {
|
|
@@ -44,6 +44,7 @@ import type {
|
|
|
44
44
|
TableColumn,
|
|
45
45
|
BodyRowRenderProps,
|
|
46
46
|
BodyCellRenderProps,
|
|
47
|
+
HeaderCellRenderProps,
|
|
47
48
|
} from '../../types';
|
|
48
49
|
|
|
49
50
|
// =============================================================================
|
|
@@ -116,12 +117,16 @@ export interface UseTableRowExpansionConfig<T extends Record<string, unknown>> {
|
|
|
116
117
|
* The chevron column's width, in pixels.
|
|
117
118
|
*
|
|
118
119
|
* A column width is a number the layout does arithmetic on, not a CSS value,
|
|
119
|
-
* so this
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
120
|
+
* so this stays a fixed pixel width at every root font size. The panel's
|
|
121
|
+
* indent, which has to line up with the first real column, spells the same
|
|
122
|
+
* pixels rather than a spacing token: spacing is rem and grows with the root
|
|
123
|
+
* font size, which would push the panel past the column it lines up with. The
|
|
124
|
+
* unit test pins the two together so neither can move without the other.
|
|
123
125
|
*/
|
|
124
126
|
const EXPANSION_COLUMN_WIDTH_PX = 40;
|
|
127
|
+
const EXPANSION_COLUMN_KEY = '__expansion';
|
|
128
|
+
/** The chevron button: the spacing step, never wider than its column. */
|
|
129
|
+
const CHEVRON_SIZE = `min(${spacingVars['--spacing-6']}, ${EXPANSION_COLUMN_WIDTH_PX}px)`;
|
|
125
130
|
const EXPANSION_COLUMN_WIDTH = {
|
|
126
131
|
type: 'pixel' as const,
|
|
127
132
|
value: EXPANSION_COLUMN_WIDTH_PX,
|
|
@@ -132,8 +137,8 @@ const expansionStyles = stylex.create({
|
|
|
132
137
|
display: 'inline-flex',
|
|
133
138
|
alignItems: 'center',
|
|
134
139
|
justifyContent: 'center',
|
|
135
|
-
width:
|
|
136
|
-
height:
|
|
140
|
+
width: CHEVRON_SIZE,
|
|
141
|
+
height: CHEVRON_SIZE,
|
|
137
142
|
backgroundColor: 'transparent',
|
|
138
143
|
borderWidth: 0,
|
|
139
144
|
borderStyle: 'none',
|
|
@@ -234,17 +239,56 @@ const dividerStyles = stylex.create({
|
|
|
234
239
|
* gives its own content.
|
|
235
240
|
*
|
|
236
241
|
* Written as a logical property so RTL mirrors it, and kept in `calc` so the
|
|
237
|
-
* padding half still tracks the spacing scale.
|
|
242
|
+
* padding half still tracks the spacing scale, as the cell's own padding does.
|
|
238
243
|
*/
|
|
239
244
|
const panelIndentStyles = stylex.create({
|
|
240
245
|
compact: {
|
|
241
|
-
paddingInlineStart: `calc(${
|
|
246
|
+
paddingInlineStart: `calc(${EXPANSION_COLUMN_WIDTH_PX}px + ${spacingVars['--spacing-2']})`,
|
|
242
247
|
},
|
|
243
248
|
balanced: {
|
|
244
|
-
paddingInlineStart: `calc(${
|
|
249
|
+
paddingInlineStart: `calc(${EXPANSION_COLUMN_WIDTH_PX}px + ${spacingVars['--spacing-3']})`,
|
|
245
250
|
},
|
|
246
251
|
spacious: {
|
|
247
|
-
paddingInlineStart: `calc(${
|
|
252
|
+
paddingInlineStart: `calc(${EXPANSION_COLUMN_WIDTH_PX}px + ${spacingVars['--spacing-4']})`,
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The chevron sits centered in its column, as the selection checkbox does.
|
|
258
|
+
*
|
|
259
|
+
* The column is a fixed pixel width, but its cells' insets follow the spacing
|
|
260
|
+
* scale: the first column's edge inset, plus the density padding a body cell
|
|
261
|
+
* moves onto its right-click trigger. Laid out after both, the button ran past
|
|
262
|
+
* the column's edge, which clips its hover chip and, under a larger spacing
|
|
263
|
+
* scale or root font size, pushes the glyph out of view; and the header cell's
|
|
264
|
+
* insets alone outgrow 40px at a large root, widening the column. So the
|
|
265
|
+
* header cell, which holds only a visually hidden label, drops its inline
|
|
266
|
+
* insets, the body cell drops its inline-start inset, and the button cancels
|
|
267
|
+
* the trigger's padding (the same density step the panel indent uses) and
|
|
268
|
+
* centers itself.
|
|
269
|
+
*/
|
|
270
|
+
const chevronHeaderCellStyles = stylex.create({
|
|
271
|
+
cell: {
|
|
272
|
+
paddingInlineStart: 0,
|
|
273
|
+
paddingInlineEnd: 0,
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const chevronCellStyles = stylex.create({
|
|
278
|
+
cell: {
|
|
279
|
+
paddingInlineStart: 0,
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const chevronOffsetStyles = stylex.create({
|
|
284
|
+
compact: {
|
|
285
|
+
marginInlineStart: `calc((${EXPANSION_COLUMN_WIDTH_PX}px - ${CHEVRON_SIZE}) / 2 - ${spacingVars['--spacing-2']})`,
|
|
286
|
+
},
|
|
287
|
+
balanced: {
|
|
288
|
+
marginInlineStart: `calc((${EXPANSION_COLUMN_WIDTH_PX}px - ${CHEVRON_SIZE}) / 2 - ${spacingVars['--spacing-3']})`,
|
|
289
|
+
},
|
|
290
|
+
spacious: {
|
|
291
|
+
marginInlineStart: `calc((${EXPANSION_COLUMN_WIDTH_PX}px - ${CHEVRON_SIZE}) / 2 - ${spacingVars['--spacing-4']})`,
|
|
248
292
|
},
|
|
249
293
|
});
|
|
250
294
|
|
|
@@ -260,10 +304,14 @@ function ExpansionChevron({
|
|
|
260
304
|
onToggle: () => void;
|
|
261
305
|
}) {
|
|
262
306
|
const t = useTranslator();
|
|
307
|
+
const ctx = useTableContext();
|
|
263
308
|
return (
|
|
264
309
|
<button
|
|
265
310
|
type="button"
|
|
266
|
-
{...stylex.props(
|
|
311
|
+
{...stylex.props(
|
|
312
|
+
expansionStyles.chevronButton,
|
|
313
|
+
chevronOffsetStyles[ctx?.density ?? 'balanced'],
|
|
314
|
+
)}
|
|
267
315
|
onClick={e => {
|
|
268
316
|
e.stopPropagation();
|
|
269
317
|
onToggle();
|
|
@@ -369,7 +417,7 @@ export function useTableRowExpansion<T extends Record<string, unknown>>(
|
|
|
369
417
|
|
|
370
418
|
const expansionColumn = useMemo(
|
|
371
419
|
(): TableColumn<T> => ({
|
|
372
|
-
key:
|
|
420
|
+
key: EXPANSION_COLUMN_KEY,
|
|
373
421
|
// A `<th>` with no discernible text is announced as an unlabelled
|
|
374
422
|
// column; the label is hidden because the column shows only chevrons.
|
|
375
423
|
header: (
|
|
@@ -406,9 +454,19 @@ export function useTableRowExpansion<T extends Record<string, unknown>>(
|
|
|
406
454
|
return withExpansion;
|
|
407
455
|
},
|
|
408
456
|
|
|
457
|
+
transformHeaderCell(
|
|
458
|
+
props: HeaderCellRenderProps,
|
|
459
|
+
column: TableColumn<T>,
|
|
460
|
+
): HeaderCellRenderProps {
|
|
461
|
+
// Keep the chevron column at its fixed width (chevronCellStyles).
|
|
462
|
+
return column.key === EXPANSION_COLUMN_KEY
|
|
463
|
+
? {...props, xstyle: [...props.xstyle, chevronHeaderCellStyles.cell]}
|
|
464
|
+
: props;
|
|
465
|
+
},
|
|
466
|
+
|
|
409
467
|
transformBodyCell(
|
|
410
468
|
props: BodyCellRenderProps,
|
|
411
|
-
|
|
469
|
+
column: TableColumn<T>,
|
|
412
470
|
item: T,
|
|
413
471
|
): BodyCellRenderProps {
|
|
414
472
|
// Contribute the expand/collapse action on every cell; BaseTable
|
|
@@ -423,11 +481,16 @@ export function useTableRowExpansion<T extends Record<string, unknown>>(
|
|
|
423
481
|
const isExpanded = expandedKeys.has(key);
|
|
424
482
|
return {
|
|
425
483
|
...props,
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
484
|
+
xstyle: [
|
|
485
|
+
...props.xstyle,
|
|
486
|
+
// The chevron centers itself in its column (chevronCellStyles).
|
|
487
|
+
...(column.key === EXPANSION_COLUMN_KEY
|
|
488
|
+
? [chevronCellStyles.cell]
|
|
489
|
+
: []),
|
|
490
|
+
// Hand the row divider to the panel below, so the line closes the
|
|
491
|
+
// row-plus-panel pair instead of splitting it.
|
|
492
|
+
...(isExpanded ? [dividerStyles.expandedRowCell] : []),
|
|
493
|
+
],
|
|
431
494
|
contextMenuActions: () => [
|
|
432
495
|
...resolveContextActions(props.contextMenuActions),
|
|
433
496
|
{
|
|
@@ -506,7 +506,9 @@ export function TouchTimeField({
|
|
|
506
506
|
isOpen={isSheetOpen}
|
|
507
507
|
onOpenChange={setIsSheetOpen}
|
|
508
508
|
label={label}
|
|
509
|
-
height="hug"
|
|
509
|
+
height="hug"
|
|
510
|
+
// sheetBody owns the inset, including the band under the grab handle.
|
|
511
|
+
padding={0}>
|
|
510
512
|
<div {...stylex.props(styles.sheetBody)}>
|
|
511
513
|
<div {...stylex.props(styles.wheels)}>
|
|
512
514
|
<Wheel
|
|
@@ -180,13 +180,6 @@ export const docs = {
|
|
|
180
180
|
'Content shown when the query matched nothing. Takes a ReactNode, so the dead end can carry a link or a create row. Announced in a polite live region as the text it renders, read from the DOM; aria-hidden parts stay out of both, and content that renders no text announces nothing. null counts as not given, like undefined, and falls through to the default; pass an empty string to render nothing.',
|
|
181
181
|
default: "'No results found'",
|
|
182
182
|
},
|
|
183
|
-
{
|
|
184
|
-
name: 'emptySearchResultsText',
|
|
185
|
-
type: 'string',
|
|
186
|
-
description:
|
|
187
|
-
'Deprecated: renamed to emptySearchText, which takes a ReactNode rather than a string, so every existing value stays valid. Still works exactly as released; emptySearchText wins when both are set.',
|
|
188
|
-
default: "'No results found'",
|
|
189
|
-
},
|
|
190
183
|
{
|
|
191
184
|
name: 'hasAutoFocus',
|
|
192
185
|
type: 'boolean',
|
|
@@ -559,13 +552,6 @@ export const docsZh = {
|
|
|
559
552
|
'查询无匹配结果时显示的内容。接受 ReactNode,因此可在无结果处放置链接或创建入口。会从 DOM 读取其渲染出的文本在礼貌性实时区域中播报;aria-hidden 的部分两处都不包含,不渲染任何文本的内容则不会播报。null 与 undefined 同样视为未提供,将回退到默认值;若要不渲染任何内容,请传入空字符串。',
|
|
560
553
|
default: "'No results found'",
|
|
561
554
|
},
|
|
562
|
-
{
|
|
563
|
-
name: 'emptySearchResultsText',
|
|
564
|
-
type: 'string',
|
|
565
|
-
description:
|
|
566
|
-
'已弃用:改名为 emptySearchText,其类型由 string 放宽为 ReactNode,原有取值全部仍然有效。仍按已发布行为工作;两者同时设置时以 emptySearchText 为准。',
|
|
567
|
-
default: "'No results found'",
|
|
568
|
-
},
|
|
569
555
|
{
|
|
570
556
|
name: 'hasAutoFocus',
|
|
571
557
|
type: 'boolean',
|
|
@@ -831,8 +817,6 @@ export const docsDense = {
|
|
|
831
817
|
minQueryLength:
|
|
832
818
|
'Min query length before searching. Menu stays closed below it, except the hasCreate entry.',
|
|
833
819
|
emptySearchText: 'Content when the query matched nothing. ReactNode.',
|
|
834
|
-
emptySearchResultsText:
|
|
835
|
-
'deprecated, use emptySearchText (ReactNode). still works as released; emptySearchText wins if both set.',
|
|
836
820
|
hasAutoFocus: 'Auto-focus input on mount.',
|
|
837
821
|
size: 'Input+token size.',
|
|
838
822
|
debounceMs: 'Search debounce delay ms. 0 for sync sources.',
|