@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.
Files changed (125) hide show
  1. package/CHANGELOG.md +186 -0
  2. package/dist/BottomSheet/BottomSheet.d.ts +5 -4
  3. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheetPanel.d.ts +1 -1
  5. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  6. package/dist/BottomSheet/BottomSheetPanel.js +1 -1
  7. package/dist/Calendar/styles.d.ts +6 -6
  8. package/dist/Chat/ChatComposerInput.d.ts +0 -11
  9. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  10. package/dist/Chat/ChatComposerInput.js +0 -11
  11. package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
  12. package/dist/Chat/useTriggerMenu.js +1 -1
  13. package/dist/CommandPalette/CommandPaletteList.d.ts +3 -2
  14. package/dist/CommandPalette/CommandPaletteList.d.ts.map +1 -1
  15. package/dist/CommandPalette/CommandPaletteList.js +3 -2
  16. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  17. package/dist/DateInput/TouchDateField.js +4 -1
  18. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  19. package/dist/DateTimeInput/TouchDateTimeField.js +29 -13
  20. package/dist/DropdownMenu/DropdownMenu.d.ts +11 -0
  21. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  22. package/dist/DropdownMenu/DropdownMenu.js +173 -11
  23. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  24. package/dist/DropdownMenu/DropdownMenuSubMenu.js +13 -2
  25. package/dist/DropdownMenu/menuRegionsContext.d.ts +2 -0
  26. package/dist/DropdownMenu/menuRegionsContext.d.ts.map +1 -0
  27. package/dist/DropdownMenu/menuRegionsContext.js +17 -0
  28. package/dist/Field/inputStyles.stylex.d.ts +3 -3
  29. package/dist/Layout/container.stylex.d.ts +18 -20
  30. package/dist/Layout/container.stylex.d.ts.map +1 -1
  31. package/dist/Layout/container.stylex.js +15 -25
  32. package/dist/Layout/padding.stylex.d.ts +231 -231
  33. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  34. package/dist/Lightbox/Lightbox.js +15 -6
  35. package/dist/Markdown/plugins/protocol.d.ts.map +1 -1
  36. package/dist/Markdown/plugins/protocol.js +33 -8
  37. package/dist/NavItem/navItemStyles.stylex.d.ts +5 -5
  38. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  39. package/dist/PowerSearch/PowerSearch.js +21 -1
  40. package/dist/PowerSearch/PowerSearchEditPopover.d.ts.map +1 -1
  41. package/dist/PowerSearch/PowerSearchEditPopover.js +30 -15
  42. package/dist/Stack/stack.stylex.d.ts +22 -22
  43. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  44. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +89 -14
  45. package/dist/Table/table.stylex.d.ts +6 -6
  46. package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
  47. package/dist/TimeInput/TouchTimeField.js +4 -1
  48. package/dist/Tokenizer/Tokenizer.d.ts +1 -12
  49. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  50. package/dist/Tokenizer/Tokenizer.js +1 -13
  51. package/dist/Typeahead/BaseTypeahead.d.ts +0 -11
  52. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  53. package/dist/Typeahead/BaseTypeahead.js +1 -9
  54. package/dist/Typeahead/Typeahead.d.ts +1 -12
  55. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  56. package/dist/Typeahead/Typeahead.js +1 -13
  57. package/dist/astryx.css +17 -12
  58. package/dist/hooks/useFocusTrap.d.ts +8 -0
  59. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  60. package/dist/hooks/useFocusTrap.js +5 -1
  61. package/dist/hooks/useRenamedProp.d.ts +6 -6
  62. package/dist/hooks/useRenamedProp.js +6 -6
  63. package/dist/reset.a11y.chromium.spec.d.ts +2 -0
  64. package/dist/reset.a11y.chromium.spec.d.ts.map +1 -0
  65. package/dist/reset.a11y.chromium.spec.js +176 -0
  66. package/dist/theme/tokens.stylex.d.ts +30 -30
  67. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  68. package/dist/theme/tokens.stylex.js +18 -15
  69. package/dist/theme/useTheme.d.ts +1 -1
  70. package/package.json +4 -4
  71. package/src/BottomSheet/BottomSheet.doc.mjs +4 -4
  72. package/src/BottomSheet/BottomSheet.tsx +5 -4
  73. package/src/BottomSheet/BottomSheetPanel.test.tsx +6 -9
  74. package/src/BottomSheet/BottomSheetPanel.tsx +2 -3
  75. package/src/Chat/ChatComposerInput.doc.mjs +3 -3
  76. package/src/Chat/ChatComposerInput.test.tsx +7 -55
  77. package/src/Chat/ChatComposerInput.tsx +0 -36
  78. package/src/Chat/useTriggerMenu.tsx +1 -4
  79. package/src/CommandPalette/CommandPalette.spec.md +2 -0
  80. package/src/CommandPalette/CommandPaletteList.spec.md +189 -0
  81. package/src/CommandPalette/CommandPaletteList.test.tsx +47 -0
  82. package/src/CommandPalette/CommandPaletteList.tsx +3 -2
  83. package/src/DateInput/TouchDateField.tsx +3 -1
  84. package/src/DateTimeInput/DateTimeInputTouch.test.tsx +60 -1
  85. package/src/DateTimeInput/TouchDateTimeField.tsx +31 -14
  86. package/src/DropdownMenu/DropdownMenu.doc.mjs +12 -0
  87. package/src/DropdownMenu/DropdownMenu.spec.md +65 -40
  88. package/src/DropdownMenu/DropdownMenu.test.tsx +457 -0
  89. package/src/DropdownMenu/DropdownMenu.tsx +278 -49
  90. package/src/DropdownMenu/DropdownMenuCheckboxItem.doc.mjs +6 -3
  91. package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +2 -1
  92. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +11 -1
  93. package/src/DropdownMenu/__tests__/MenuHeaderFooter.a11y.chromium.spec.ts +195 -0
  94. package/src/DropdownMenu/menuRegionsContext.ts +17 -0
  95. package/src/Layout/container.stylex.ts +11 -21
  96. package/src/Lightbox/Lightbox.test.tsx +58 -0
  97. package/src/Lightbox/Lightbox.tsx +12 -6
  98. package/src/Markdown/plugins/protocol.ts +45 -12
  99. package/src/Markdown/plugins/sourceLines.test.tsx +121 -0
  100. package/src/PowerSearch/PowerSearch.tsx +28 -1
  101. package/src/PowerSearch/PowerSearchEditPopover.test.tsx +284 -0
  102. package/src/PowerSearch/PowerSearchEditPopover.tsx +41 -13
  103. package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +33 -9
  104. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +81 -18
  105. package/src/TimeInput/TouchTimeField.tsx +3 -1
  106. package/src/Tokenizer/Tokenizer.doc.mjs +0 -16
  107. package/src/Tokenizer/Tokenizer.test.tsx +12 -63
  108. package/src/Tokenizer/Tokenizer.tsx +1 -25
  109. package/src/Toolbar/Toolbar.test.tsx +29 -0
  110. package/src/Typeahead/BaseTypeahead.doc.mjs +0 -16
  111. package/src/Typeahead/BaseTypeahead.test.tsx +1 -1
  112. package/src/Typeahead/BaseTypeahead.tsx +1 -21
  113. package/src/Typeahead/Typeahead.doc.mjs +0 -7
  114. package/src/Typeahead/Typeahead.test.tsx +24 -42
  115. package/src/Typeahead/Typeahead.tsx +1 -25
  116. package/src/hooks/useFocusTrap.ts +5 -1
  117. package/src/hooks/useRenamedProp.ts +6 -6
  118. package/src/reset.a11y.chromium.spec.ts +178 -0
  119. package/src/reset.css +8 -8
  120. package/src/reset.test.ts +112 -0
  121. package/src/theme/__tests__/SpacingRootFontSize.a11y.chromium.spec.ts +198 -0
  122. package/src/theme/tokens.stylex.ts +18 -15
  123. package/src/theme/tokens.test.ts +1 -1
  124. package/src/theme/useTheme.test.tsx +4 -4
  125. 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
- [config, partialFilter.field, currentOperator],
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(var(--spacing-10) + var(--spacing-3))',
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(var(--spacing-10) + var(--spacing-4))',
245
+ paddingInlineStart: 'calc(40px + var(--spacing-4))',
247
246
  });
248
247
  });
249
248
 
250
- it('sizes the chevron column at the spacing token the panel indents by', () => {
251
- // The column width is a number the layout does arithmetic on, so it cannot
252
- // be a token reference — but the panel's indent, which has to line up with
253
- // the first column, spells it as `--spacing-10`. Pin the two together so a
254
- // change to the scale cannot silently unalign them.
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
- expect(chevronHeader).toHaveStyle({width: spacingDefaults['--spacing-10']});
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 cannot be a token reference — but it is the pixel value of
120
- * `--spacing-10`, and the panel's indent (which has to line up with the first
121
- * real column) spells it as the token. The unit test pins the two together so
122
- * a change to the scale cannot silently unalign them.
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: spacingVars['--spacing-6'],
136
- height: spacingVars['--spacing-6'],
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(${spacingVars['--spacing-10']} + ${spacingVars['--spacing-2']})`,
246
+ paddingInlineStart: `calc(${EXPANSION_COLUMN_WIDTH_PX}px + ${spacingVars['--spacing-2']})`,
242
247
  },
243
248
  balanced: {
244
- paddingInlineStart: `calc(${spacingVars['--spacing-10']} + ${spacingVars['--spacing-3']})`,
249
+ paddingInlineStart: `calc(${EXPANSION_COLUMN_WIDTH_PX}px + ${spacingVars['--spacing-3']})`,
245
250
  },
246
251
  spacious: {
247
- paddingInlineStart: `calc(${spacingVars['--spacing-10']} + ${spacingVars['--spacing-4']})`,
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(expansionStyles.chevronButton)}
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: '__expansion',
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
- _column: TableColumn<T>,
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
- // Hand the row divider to the panel below, so the line closes the
427
- // row-plus-panel pair instead of splitting it.
428
- xstyle: isExpanded
429
- ? [...props.xstyle, dividerStyles.expandedRowCell]
430
- : props.xstyle,
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.',