@12-apps/ui 6.24.8 → 6.26.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 (151) hide show
  1. package/dist/_internal/components/data-display/Alert/Alert.helpers.js +5 -1
  2. package/dist/_internal/components/data-display/Alert/Alert.js +3 -3
  3. package/dist/_internal/components/data-display/Alert/Alert.styles.js +2 -2
  4. package/dist/_internal/components/data-display/CardKit/card-actions-context.js +4 -4
  5. package/dist/_internal/components/data-display/CardKit/use-remove-confirm.js +5 -5
  6. package/dist/_internal/components/data-display/DataViews/DataViewsGrid.js +19 -19
  7. package/dist/_internal/components/data-display/DataViews/DataViewsTableBase.js +24 -24
  8. package/dist/_internal/components/data-display/DataViews/data-views-category-pill.js +15 -15
  9. package/dist/_internal/components/data-display/DataViews/data-views-filter-panel.js +16 -16
  10. package/dist/_internal/components/data-display/DataViews/data-views-grid-parts.js +18 -18
  11. package/dist/_internal/components/data-display/DataViews/data-views-inline-bar.js +16 -16
  12. package/dist/_internal/components/data-display/DataViews/data-views-table-parts.js +4 -4
  13. package/dist/_internal/components/data-display/index.js +3 -3
  14. package/dist/_internal/components/form/CategorySelect/CategoryPanel.js +8 -8
  15. package/dist/_internal/components/form/CategorySelect/CategoryPanelFoot.js +2 -2
  16. package/dist/_internal/components/form/CategorySelect/CategoryPanelHead.js +2 -2
  17. package/dist/_internal/components/form/CategorySelect/CategoryPanelList.js +4 -4
  18. package/dist/_internal/components/form/CategorySelect/CategoryQuickActions.js +2 -2
  19. package/dist/_internal/components/form/CategorySelect/CategoryRows.js +7 -9
  20. package/dist/_internal/components/form/CategorySelect/CategorySelect.js +14 -14
  21. package/dist/_internal/components/form/CategorySelect/CategorySelect.styles.js +3 -1
  22. package/dist/_internal/components/form/CategorySelect/CategoryTrigger.js +2 -2
  23. package/dist/_internal/components/form/CategorySelect/category-keyboard.js +2 -2
  24. package/dist/_internal/components/form/CategorySelect/category-tree.js +11 -3
  25. package/dist/_internal/components/form/CategorySelect/useCategoryCommit.js +2 -2
  26. package/dist/_internal/components/form/CategorySelect/useCategoryKeyHandler.js +3 -3
  27. package/dist/_internal/components/form/CategorySelect/useCategorySelect.js +2 -2
  28. package/dist/_internal/components/form/total-form/Fields/CategoryField.js +15 -15
  29. package/dist/_internal/components/form/total-form/Fields/index.js +16 -16
  30. package/dist/_internal/components/index.js +3 -3
  31. package/dist/{chunk-WNMYWQ7D.js → chunk-4Y6SPFKW.js} +10 -8
  32. package/dist/chunk-4Y6SPFKW.js.map +1 -0
  33. package/dist/{chunk-KM5O4ICK.js → chunk-7LCXGJN5.js} +2 -2
  34. package/dist/{chunk-NLJV4CPF.js → chunk-7MN6I5NK.js} +2 -2
  35. package/dist/{chunk-PN5CQKA2.js → chunk-AXTLFK4T.js} +2 -2
  36. package/dist/{chunk-4TXIZ5WI.js → chunk-CJUOBTLL.js} +2 -2
  37. package/dist/{chunk-57Z2ND3N.js → chunk-CXLAJZSE.js} +2 -2
  38. package/dist/{chunk-E3PUH5IF.js → chunk-EQ7PWGBN.js} +2 -2
  39. package/dist/{chunk-EJQW2IYW.js → chunk-FPFUXRD2.js} +2 -2
  40. package/dist/{chunk-KIHQE5YA.js → chunk-GVDJQMFI.js} +2 -2
  41. package/dist/{chunk-N3QUEBNS.js → chunk-L7VH6IMR.js} +14 -1
  42. package/dist/chunk-L7VH6IMR.js.map +1 -0
  43. package/dist/{chunk-OFOYMZRY.js → chunk-NPN7JTRM.js} +22 -47
  44. package/dist/chunk-NPN7JTRM.js.map +1 -0
  45. package/dist/{chunk-OVLNF4AZ.js → chunk-P7IVAMAU.js} +24 -35
  46. package/dist/chunk-P7IVAMAU.js.map +1 -0
  47. package/dist/chunk-PN4HVM43.js +160 -0
  48. package/dist/chunk-PN4HVM43.js.map +1 -0
  49. package/dist/{chunk-WWM2YTW4.js → chunk-R2AZOWEZ.js} +21 -27
  50. package/dist/chunk-R2AZOWEZ.js.map +1 -0
  51. package/dist/{chunk-2GB5CIN6.js → chunk-RZVUY2MO.js} +8 -4
  52. package/dist/chunk-RZVUY2MO.js.map +1 -0
  53. package/dist/{chunk-FBI6QB3O.js → chunk-TVPPA4RD.js} +3 -3
  54. package/dist/{chunk-KFWIOFMS.js → chunk-TWY5FOBY.js} +3 -3
  55. package/dist/{chunk-6ZY5XU4J.js → chunk-UIZH4DAE.js} +2 -2
  56. package/dist/{chunk-ISH42UC7.js → chunk-USJPZ5Q7.js} +2 -2
  57. package/dist/{chunk-ZUIJ3N7N.js → chunk-V6Z4XS3I.js} +5 -3
  58. package/dist/chunk-V6Z4XS3I.js.map +1 -0
  59. package/dist/{chunk-SYJMI2NP.js → chunk-VNTOBLIB.js} +2 -2
  60. package/dist/{chunk-JM64OEU6.js → chunk-WAPJ2DL3.js} +10 -13
  61. package/dist/chunk-WAPJ2DL3.js.map +1 -0
  62. package/dist/{chunk-ZAB65STX.js → chunk-XHHR6G35.js} +2 -2
  63. package/dist/{chunk-WIT3Y7JT.js → chunk-Y2B3I3IH.js} +2 -2
  64. package/dist/{chunk-R6SNYC3D.js → chunk-Y3KRUT7O.js} +2 -2
  65. package/dist/{chunk-TPLKJHNN.js → chunk-ZC7RQDZ5.js} +8 -7
  66. package/dist/chunk-ZC7RQDZ5.js.map +1 -0
  67. package/dist/{chunk-TFO6GBW7.js → chunk-ZS44PY7T.js} +5 -1
  68. package/dist/chunk-ZS44PY7T.js.map +1 -0
  69. package/dist/{chunk-AJ6XEO4E.js → chunk-ZUY65MFH.js} +2 -2
  70. package/dist/data-display/Alert.js +3 -3
  71. package/dist/data-display/CardKit.js +5 -5
  72. package/dist/data-display/DataViews.js +24 -24
  73. package/dist/form/CategorySelect.js +24 -16
  74. package/dist/form/total-form.js +16 -16
  75. package/dist/native/data-display/Alert.js +18 -3
  76. package/dist/native/data-display/Alert.js.map +1 -1
  77. package/dist/types/components/data-display/Alert/Alert.base.d.ts +28 -2
  78. package/dist/types/components/data-display/Alert/Alert.base.d.ts.map +1 -1
  79. package/dist/types/components/data-display/Alert/Alert.helpers.d.ts +39 -0
  80. package/dist/types/components/data-display/Alert/Alert.helpers.d.ts.map +1 -1
  81. package/dist/types/components/form/CategorySelect/CategoryPanel.d.ts.map +1 -1
  82. package/dist/types/components/form/CategorySelect/CategoryPanelList.d.ts +6 -0
  83. package/dist/types/components/form/CategorySelect/CategoryPanelList.d.ts.map +1 -1
  84. package/dist/types/components/form/CategorySelect/CategoryQuickActions.d.ts.map +1 -1
  85. package/dist/types/components/form/CategorySelect/CategoryRows.d.ts +12 -24
  86. package/dist/types/components/form/CategorySelect/CategoryRows.d.ts.map +1 -1
  87. package/dist/types/components/form/CategorySelect/CategorySelect.d.ts +10 -7
  88. package/dist/types/components/form/CategorySelect/CategorySelect.d.ts.map +1 -1
  89. package/dist/types/components/form/CategorySelect/CategorySelect.styles.d.ts +11 -0
  90. package/dist/types/components/form/CategorySelect/CategorySelect.styles.d.ts.map +1 -1
  91. package/dist/types/components/form/CategorySelect/CategorySelect.types.d.ts +10 -2
  92. package/dist/types/components/form/CategorySelect/CategorySelect.types.d.ts.map +1 -1
  93. package/dist/types/components/form/CategorySelect/category-keyboard.d.ts.map +1 -1
  94. package/dist/types/components/form/CategorySelect/category-tree.d.ts +52 -20
  95. package/dist/types/components/form/CategorySelect/category-tree.d.ts.map +1 -1
  96. package/dist/types/components/form/CategorySelect/index.d.ts +1 -1
  97. package/dist/types/components/form/CategorySelect/index.d.ts.map +1 -1
  98. package/dist/types/components/form/CategorySelect/useCategorySelect.d.ts +2 -0
  99. package/dist/types/components/form/CategorySelect/useCategorySelect.d.ts.map +1 -1
  100. package/dist/types-native/components/data-display/Alert/Alert.base.d.ts +28 -2
  101. package/dist/types-native/components/data-display/Alert/Alert.base.d.ts.map +1 -1
  102. package/dist/types-native/components/data-display/Alert/Alert.helpers.d.ts +39 -0
  103. package/dist/types-native/components/data-display/Alert/Alert.helpers.d.ts.map +1 -1
  104. package/dist/types-native/components/data-display/Alert/Alert.native.d.ts.map +1 -1
  105. package/package.json +1 -1
  106. package/src/components/data-display/Alert/Alert.base.ts +29 -2
  107. package/src/components/data-display/Alert/Alert.helpers.ts +50 -0
  108. package/src/components/data-display/Alert/Alert.md +29 -1
  109. package/src/components/data-display/Alert/Alert.native.tsx +8 -4
  110. package/src/components/data-display/Alert/Alert.tsx +5 -5
  111. package/src/components/form/CategorySelect/CategoryPanel.tsx +1 -0
  112. package/src/components/form/CategorySelect/CategoryPanelList.tsx +42 -34
  113. package/src/components/form/CategorySelect/CategoryQuickActions.tsx +6 -4
  114. package/src/components/form/CategorySelect/CategoryRows.tsx +45 -81
  115. package/src/components/form/CategorySelect/CategorySelect.md +47 -13
  116. package/src/components/form/CategorySelect/CategorySelect.styles.ts +13 -0
  117. package/src/components/form/CategorySelect/CategorySelect.tsx +16 -14
  118. package/src/components/form/CategorySelect/CategorySelect.types.ts +10 -2
  119. package/src/components/form/CategorySelect/category-keyboard.ts +27 -31
  120. package/src/components/form/CategorySelect/category-tree.ts +143 -63
  121. package/src/components/form/CategorySelect/index.ts +4 -0
  122. package/src/components/form/CategorySelect/useCategorySelect.ts +15 -1
  123. package/dist/chunk-2GB5CIN6.js.map +0 -1
  124. package/dist/chunk-JM64OEU6.js.map +0 -1
  125. package/dist/chunk-LR6MSKTT.js +0 -124
  126. package/dist/chunk-LR6MSKTT.js.map +0 -1
  127. package/dist/chunk-N3QUEBNS.js.map +0 -1
  128. package/dist/chunk-OFOYMZRY.js.map +0 -1
  129. package/dist/chunk-OVLNF4AZ.js.map +0 -1
  130. package/dist/chunk-TFO6GBW7.js.map +0 -1
  131. package/dist/chunk-TPLKJHNN.js.map +0 -1
  132. package/dist/chunk-WNMYWQ7D.js.map +0 -1
  133. package/dist/chunk-WWM2YTW4.js.map +0 -1
  134. package/dist/chunk-ZUIJ3N7N.js.map +0 -1
  135. /package/dist/{chunk-KM5O4ICK.js.map → chunk-7LCXGJN5.js.map} +0 -0
  136. /package/dist/{chunk-NLJV4CPF.js.map → chunk-7MN6I5NK.js.map} +0 -0
  137. /package/dist/{chunk-PN5CQKA2.js.map → chunk-AXTLFK4T.js.map} +0 -0
  138. /package/dist/{chunk-4TXIZ5WI.js.map → chunk-CJUOBTLL.js.map} +0 -0
  139. /package/dist/{chunk-57Z2ND3N.js.map → chunk-CXLAJZSE.js.map} +0 -0
  140. /package/dist/{chunk-E3PUH5IF.js.map → chunk-EQ7PWGBN.js.map} +0 -0
  141. /package/dist/{chunk-EJQW2IYW.js.map → chunk-FPFUXRD2.js.map} +0 -0
  142. /package/dist/{chunk-KIHQE5YA.js.map → chunk-GVDJQMFI.js.map} +0 -0
  143. /package/dist/{chunk-FBI6QB3O.js.map → chunk-TVPPA4RD.js.map} +0 -0
  144. /package/dist/{chunk-KFWIOFMS.js.map → chunk-TWY5FOBY.js.map} +0 -0
  145. /package/dist/{chunk-6ZY5XU4J.js.map → chunk-UIZH4DAE.js.map} +0 -0
  146. /package/dist/{chunk-ISH42UC7.js.map → chunk-USJPZ5Q7.js.map} +0 -0
  147. /package/dist/{chunk-SYJMI2NP.js.map → chunk-VNTOBLIB.js.map} +0 -0
  148. /package/dist/{chunk-ZAB65STX.js.map → chunk-XHHR6G35.js.map} +0 -0
  149. /package/dist/{chunk-WIT3Y7JT.js.map → chunk-Y2B3I3IH.js.map} +0 -0
  150. /package/dist/{chunk-R6SNYC3D.js.map → chunk-Y3KRUT7O.js.map} +0 -0
  151. /package/dist/{chunk-AJ6XEO4E.js.map → chunk-ZUY65MFH.js.map} +0 -0
@@ -5,12 +5,8 @@ import Box from '@mui/material/Box/index.js';
5
5
  import Button from '@mui/material/Button/index.js';
6
6
  import Skeleton from '@mui/material/Skeleton/index.js';
7
7
 
8
- import { categoryCheckState, isLeafCategory } from './category-tree';
9
- import {
10
- CategoryHeadRow,
11
- CategorySectionHeading,
12
- SubcategoryRow,
13
- } from './CategoryRows';
8
+ import { categoryCheckState, isLeafCategory, leavesOf } from './category-tree';
9
+ import { CategorySectionHeading, CategoryTreeRow } from './CategoryRows';
14
10
  import { emptySx, listSx, skeletonSx } from './CategorySelect.styles';
15
11
  import type { CategoryGroup } from './CategorySelect.types';
16
12
 
@@ -33,6 +29,12 @@ interface PanelListProps {
33
29
  loading: boolean;
34
30
  showCounts: boolean;
35
31
  allowParentSelection: boolean;
32
+ /**
33
+ * True when the tree has a level BELOW the subcategories. It turns the
34
+ * section-heading treatment off — see {@link CategoryNodeRows}. Derived from
35
+ * the options the panel state already holds, never a caller's decision.
36
+ */
37
+ deepTree: boolean;
36
38
  isExpanded: (categoryId: string) => boolean;
37
39
  onToggleExpanded: (categoryId: string) => void;
38
40
  onActivateCategory: (group: CategoryGroup) => void;
@@ -114,60 +116,66 @@ function NoResults({
114
116
  );
115
117
  }
116
118
 
117
- /** One group: its heading or head row, then whichever children are visible. */
118
- function CategoryGroupRows({
119
- group,
119
+ /**
120
+ * One node: its heading or its row, then whichever children are visible.
121
+ *
122
+ * The SECTION-HEADING treatment — an inert uppercase label with its children
123
+ * always on show — belongs to a two-level tree and only there. It is what makes
124
+ * the "mover para…" picker read as "category, then the subcategory you pick",
125
+ * and it works because a two-level category has nothing to fold TO: everything
126
+ * under it is already the thing you came for. A third level breaks both halves
127
+ * at once — the heading has no chevron, so the panel opens onto every item in
128
+ * the catalogue, which is the flat list this component exists to replace. So a
129
+ * deep tree drops the treatment and every frame becomes a foldable row instead.
130
+ */
131
+ function CategoryNodeRows({
132
+ node,
133
+ depth,
120
134
  props,
121
135
  }: {
122
- group: CategoryGroup;
136
+ node: CategoryGroup;
137
+ depth: number;
123
138
  props: PanelListProps;
124
139
  }): React.JSX.Element {
125
140
  const { draft, query, single, allowParentSelection, rowIds, activeIndex, sheet } = props;
126
- const categoryId = group.category.id;
141
+ const categoryId = node.category.id;
142
+ const leaf = isLeafCategory(node);
127
143
  const expanded = props.isExpanded(categoryId);
128
144
  // A childless category is the leaf, so it is selectable whatever the mode says
129
145
  // — the heading reading has no subcategory to point at, and would leave the
130
146
  // row inert.
131
- const selectable = allowParentSelection || isLeafCategory(group);
132
- const asHeading = single && !selectable;
133
- const selectedCount = group.subcategories.filter((sub) => draft.has(sub.id)).length;
147
+ const selectable = allowParentSelection || leaf;
148
+ const asHeading = single && !selectable && !props.deepTree;
149
+ const selectedCount = leaf ? 0 : leavesOf(node).filter((id) => draft.has(id)).length;
134
150
 
135
151
  return (
136
152
  <>
137
153
  {asHeading ? (
138
- <CategorySectionHeading option={group.category} query={query} />
154
+ <CategorySectionHeading option={node.category} query={query} />
139
155
  ) : (
140
- <CategoryHeadRow
141
- option={group.category}
156
+ <CategoryTreeRow
157
+ option={node.category}
142
158
  query={query}
159
+ depth={depth}
160
+ branch={!leaf}
143
161
  expanded={expanded}
144
162
  active={rowIds[activeIndex] === categoryId}
145
163
  sheet={sheet}
146
- checkState={selectable ? categoryCheckState(group, draft) : undefined}
164
+ {...(selectable ? { checkState: categoryCheckState(node, draft) } : {})}
147
165
  single={single}
148
- expandable={!isLeafCategory(group)}
149
166
  selectedCount={selectedCount}
150
167
  showCounts={props.showCounts}
151
168
  onToggleExpanded={() => props.onToggleExpanded(categoryId)}
152
- onActivate={() => props.onActivateCategory(group)}
169
+ onActivate={() =>
170
+ leaf ? props.onActivateSubcategory(categoryId) : props.onActivateCategory(node)
171
+ }
153
172
  dataTestId={props.dataTestId}
154
173
  copy={props.copy}
155
174
  />
156
175
  )}
157
176
  {(expanded || asHeading) &&
158
- group.subcategories.map((sub) => (
159
- <SubcategoryRow
160
- key={sub.id}
161
- option={sub}
162
- query={query}
163
- selected={draft.has(sub.id)}
164
- active={rowIds[activeIndex] === sub.id}
165
- sheet={sheet}
166
- single={single}
167
- showCounts={props.showCounts}
168
- onActivate={() => props.onActivateSubcategory(sub.id)}
169
- dataTestId={props.dataTestId}
170
- />
177
+ node.subcategories.map((sub) => (
178
+ <CategoryNodeRows key={sub.category.id} node={sub} depth={depth + 1} props={props} />
171
179
  ))}
172
180
  </>
173
181
  );
@@ -200,7 +208,7 @@ export function CategoryPanelList(props: PanelListProps): React.JSX.Element {
200
208
  return (
201
209
  <>
202
210
  {groups.map((group) => (
203
- <CategoryGroupRows key={group.category.id} group={group} props={props} />
211
+ <CategoryNodeRows key={group.category.id} node={group} depth={0} props={props} />
204
212
  ))}
205
213
  </>
206
214
  );
@@ -5,7 +5,7 @@ import { useMemo } from 'react';
5
5
 
6
6
  import type { CategorySelectCopy } from '../../../copy';
7
7
 
8
- import { collectLeafIds } from './category-tree';
8
+ import { collectBranchIds, collectLeafIds } from './category-tree';
9
9
  import type { CategorySelectState } from './useCategorySelect';
10
10
 
11
11
  /** The panel's text-button treatment. */
@@ -41,9 +41,11 @@ export function CategoryQuickActions({
41
41
  }): React.JSX.Element {
42
42
  const allLeafIds = useMemo(() => collectLeafIds(state.allGroups), [state.allGroups]);
43
43
  const everythingPicked = allLeafIds.length > 0 && state.draft.size >= allLeafIds.length;
44
- const allExpanded =
45
- state.allGroups.length > 0 &&
46
- state.allGroups.every((group) => state.isExpanded(group.category.id));
44
+ // Measured over every foldable node at every depth: on a deep tree the top
45
+ // storey can be open while the items under it are not, and a label reading
46
+ // "Recolher tudo" there would be describing a state the list is not in.
47
+ const branchIds = useMemo(() => collectBranchIds(state.allGroups), [state.allGroups]);
48
+ const allExpanded = branchIds.length > 0 && branchIds.every((id) => state.isExpanded(id));
47
49
 
48
50
  return (
49
51
  <>
@@ -12,6 +12,7 @@ import {
12
12
  markSx,
13
13
  METRICS,
14
14
  radioSx,
15
+ rowIndent,
15
16
  rowMetaSx,
16
17
  rowNameSx,
17
18
  rowSx,
@@ -57,18 +58,20 @@ export function CategoryRadio({ on }: { on: boolean }): React.JSX.Element {
57
58
  return <Box sx={(theme) => radioSx(theme, on)} aria-hidden="true" />;
58
59
  }
59
60
 
60
- interface CategoryHeadRowProps {
61
+ interface CategoryTreeRowProps {
61
62
  option: CategorySelectOption;
62
63
  query: string;
64
+ /** 0 for a top-level category; each level below indents one more step. */
65
+ depth: number;
66
+ /** True when there is something under this row to fold. */
67
+ branch: boolean;
63
68
  expanded: boolean;
64
69
  active: boolean;
65
70
  sheet: boolean;
66
- /** Absent in leaf-only mode, where the category is a heading with no checkbox. */
71
+ /** Absent where the row cannot be picked — a category acting as a frame. */
67
72
  checkState?: CategoryCheckState;
68
73
  /** Single-select draws a radio for the control: choosing a row is exclusive. */
69
74
  single: boolean;
70
- /** False for a childless category — there is nothing under it to disclose. */
71
- expandable: boolean;
72
75
  selectedCount: number;
73
76
  showCounts: boolean;
74
77
  onToggleExpanded: () => void;
@@ -77,7 +80,7 @@ interface CategoryHeadRowProps {
77
80
  copy: CategorySelectCopy;
78
81
  }
79
82
 
80
- /** The trailing number: how many children are picked, else the item count. */
83
+ /** The trailing number: how many leaves are picked below, else the item count. */
81
84
  function rowMeta(
82
85
  selectedCount: number,
83
86
  showCounts: boolean,
@@ -88,19 +91,19 @@ function rowMeta(
88
91
  return null;
89
92
  }
90
93
 
91
- /** The disclosure chevron, or the space it would have taken on a childless row. */
92
- function HeadRowDisclosure({
94
+ /** The disclosure chevron, or the space it would have taken on a leaf row. */
95
+ function RowDisclosure({
93
96
  option,
94
97
  expanded,
95
- expandable,
98
+ branch,
96
99
  onToggleExpanded,
97
100
  dataTestId,
98
101
  copy,
99
102
  }: Pick<
100
- CategoryHeadRowProps,
101
- 'option' | 'expanded' | 'expandable' | 'onToggleExpanded' | 'dataTestId' | 'copy'
103
+ CategoryTreeRowProps,
104
+ 'option' | 'expanded' | 'branch' | 'onToggleExpanded' | 'dataTestId' | 'copy'
102
105
  >): React.JSX.Element {
103
- if (!expandable) {
106
+ if (!branch) {
104
107
  return <Box sx={{ width: METRICS.chevronButton, flex: '0 0 auto' }} />;
105
108
  }
106
109
  return (
@@ -121,11 +124,11 @@ function HeadRowDisclosure({
121
124
  );
122
125
  }
123
126
 
124
- /** The row's selection control, or the space it would have taken on a heading. */
125
- function HeadRowControl({
127
+ /** The row's selection control, or the space it would have taken on a frame. */
128
+ function RowControl({
126
129
  checkState,
127
130
  single,
128
- }: Pick<CategoryHeadRowProps, 'checkState' | 'single'>): React.JSX.Element {
131
+ }: Pick<CategoryTreeRowProps, 'checkState' | 'single'>): React.JSX.Element {
129
132
  if (!checkState) return <Box sx={{ width: METRICS.boxSize, flex: '0 0 auto' }} />;
130
133
  // Single-select commits the moment a row is chosen, so the control is a radio
131
134
  // there: a checkbox would promise the accumulation this mode does not do.
@@ -134,51 +137,63 @@ function HeadRowControl({
134
137
  }
135
138
 
136
139
  /**
137
- * A top-level category row: disclosure chevron, optional checkbox, name, meta.
140
+ * The test id a row answers to.
138
141
  *
139
- * The chevron is its OWN button inside the row button — clicking it only folds,
140
- * while clicking the row does the row's job (expand as a heading, or tick when
141
- * the category is selectable). A CHILDLESS category has neither a fold nor a
142
- * heading to be: it draws no chevron, and it carries the control, because it is
143
- * itself the leaf.
142
+ * Keyed on DEPTH rather than on whether the row has children, because that is
143
+ * what the ids meant when the tree was two levels and specs were written against
144
+ * them: `-category-<id>` is a top-level row — childless or not — and `-option-`
145
+ * is anything filed under one.
144
146
  */
145
- export function CategoryHeadRow({
147
+ function rowTestId(dataTestId: string, depth: number, id: string): string {
148
+ return depth === 0 ? `${dataTestId}-category-${id}` : `${dataTestId}-option-${id}`;
149
+ }
150
+
151
+ /**
152
+ * One row of the tree: disclosure chevron, optional control, name, meta.
153
+ *
154
+ * The chevron is its OWN button inside the row — clicking it only folds, while
155
+ * clicking the row does the row's job (expand as a frame, or mark when the row
156
+ * is selectable). A LEAF has neither a fold nor a frame to be: it draws no
157
+ * chevron, and it always carries the control, because it is what you pick.
158
+ */
159
+ export function CategoryTreeRow({
146
160
  option,
147
161
  query,
162
+ depth,
163
+ branch,
148
164
  expanded,
149
165
  active,
150
166
  sheet,
151
167
  checkState,
152
168
  single,
153
- expandable,
154
169
  selectedCount,
155
170
  showCounts,
156
171
  onToggleExpanded,
157
172
  onActivate,
158
173
  dataTestId,
159
174
  copy,
160
- }: CategoryHeadRowProps): React.JSX.Element {
175
+ }: CategoryTreeRowProps): React.JSX.Element {
161
176
  const meta = rowMeta(selectedCount, showCounts, option.count);
162
177
  return (
163
178
  <Box
164
179
  component="div"
165
180
  role={checkState ? 'option' : 'button'}
166
181
  aria-selected={checkState ? checkState === 'on' : undefined}
167
- aria-expanded={expandable ? expanded : undefined}
168
- data-testid={`${dataTestId}-category-${option.id}`}
169
- sx={(theme) => rowSx(theme, active, sheet)}
182
+ aria-expanded={branch ? expanded : undefined}
183
+ data-testid={rowTestId(dataTestId, depth, option.id)}
184
+ sx={(theme) => ({ ...rowSx(theme, active, sheet), ...rowIndent(depth) })}
170
185
  onClick={onActivate}
171
186
  >
172
- <HeadRowDisclosure
187
+ <RowDisclosure
173
188
  option={option}
174
189
  expanded={expanded}
175
- expandable={expandable}
190
+ branch={branch}
176
191
  onToggleExpanded={onToggleExpanded}
177
192
  dataTestId={dataTestId}
178
193
  copy={copy}
179
194
  />
180
- <HeadRowControl checkState={checkState} single={single} />
181
- <Box component="span" sx={(theme) => rowNameSx(theme, true)}>
195
+ <RowControl checkState={checkState} single={single} />
196
+ <Box component="span" sx={(theme) => rowNameSx(theme, depth === 0)}>
182
197
  <HighlightedName text={option.name} query={query} />
183
198
  </Box>
184
199
  {meta && (
@@ -190,57 +205,6 @@ export function CategoryHeadRow({
190
205
  );
191
206
  }
192
207
 
193
- interface SubcategoryRowProps {
194
- option: CategorySelectOption;
195
- query: string;
196
- selected: boolean;
197
- active: boolean;
198
- sheet: boolean;
199
- /** Single-select mode draws a radio; multi-select draws a checkbox. */
200
- single: boolean;
201
- showCounts: boolean;
202
- onActivate: () => void;
203
- dataTestId: string;
204
- }
205
-
206
- export function SubcategoryRow({
207
- option,
208
- query,
209
- selected,
210
- active,
211
- sheet,
212
- single,
213
- showCounts,
214
- onActivate,
215
- dataTestId,
216
- }: SubcategoryRowProps): React.JSX.Element {
217
- return (
218
- <Box
219
- component="div"
220
- role="option"
221
- aria-selected={selected}
222
- data-testid={`${dataTestId}-option-${option.id}`}
223
- sx={(theme) => ({ ...rowSx(theme, active, sheet), paddingLeft: '14px' })}
224
- onClick={onActivate}
225
- >
226
- <Box sx={{ width: METRICS.chevronButton, flex: '0 0 auto' }} />
227
- {single ? (
228
- <CategoryRadio on={selected} />
229
- ) : (
230
- <CategoryCheckbox state={selected ? 'on' : 'off'} />
231
- )}
232
- <Box component="span" sx={(theme) => rowNameSx(theme, false)}>
233
- <HighlightedName text={option.name} query={query} />
234
- </Box>
235
- {showCounts && option.count !== undefined && (
236
- <Box component="span" sx={(theme) => rowMetaSx(theme, false)}>
237
- {option.count}
238
- </Box>
239
- )}
240
- </Box>
241
- );
242
- }
243
-
244
208
  /** The category as a non-interactive heading (single-select, leaf-only). */
245
209
  export function CategorySectionHeading({
246
210
  option,
@@ -1,7 +1,7 @@
1
1
  # CategorySelect
2
2
 
3
- A hierarchical category picker: **categories are the frame, subcategories are what
4
- you pick.**
3
+ A hierarchical picker: **the categories are the frame, whatever is filed at the
4
+ bottom is what you pick.** Two levels by default; as many as the payload has.
5
5
 
6
6
  ```tsx
7
7
  import { CategorySelect } from '@12-apps/ui/form/CategorySelect';
@@ -33,7 +33,32 @@ Never pre-indent `name` (`"— Águas"`). The row draws the nesting; a baked-in
33
33
  travels everywhere the label goes, including the closed trigger.
34
34
 
35
35
  An option whose `parentId` matches nothing is promoted to top level rather than
36
- dropped, so a partial payload never makes a category invisible.
36
+ dropped, so a partial payload never makes a category invisible. So is an option
37
+ on a `parentId` RING — two rows naming each other would otherwise build a loop
38
+ every walk of the tree follows until the stack gives out.
39
+
40
+ ### Depth
41
+
42
+ **Nothing caps it at two.** Point a `parentId` at a subcategory and you get a
43
+ third storey; the row indents one more step, folds, searches and reports its
44
+ path like every level above it.
45
+
46
+ ```tsx
47
+ const options = [
48
+ { id: 'beb', name: 'Bebidas' },
49
+ { id: 'beb.refri', name: 'Refrigerantes', parentId: 'beb' },
50
+ { id: 'p.coca', name: 'Coca-Cola', parentId: 'beb.refri' }, // an ITEM
51
+ ];
52
+ ```
53
+
54
+ Two things follow from a deeper tree, and both are the component's own doing:
55
+
56
+ - **A category stands for the leaves at the BOTTOM of it**, never for the
57
+ subcategories in between. `Bebidas` ticked means every drink under every one of
58
+ its subcategories, so the value a caller receives is uniformly leaves.
59
+ - **The section-heading treatment switches off** (see below), because a heading
60
+ has no chevron and a three-level tree drawn flat is the list this component is
61
+ adopted to replace.
37
62
 
38
63
  ## Which rows are selectable
39
64
 
@@ -42,14 +67,14 @@ parent that HAS children can be ticked in its own right.
42
67
 
43
68
  | `allowParentSelection` | has children | the row |
44
69
  | --- | --- | --- |
45
- | `false` (default) | yes | a **heading** — no control; its subcategories are what you tick |
70
+ | `false` (default) | yes | a **frame** — no control; what is filed under it is what you tick |
46
71
  | `false` | no | **selectable** — it is itself the leaf |
47
- | `true` | yes | **selectable** — ticking it selects **every subcategory under it** |
72
+ | `true` | yes | **selectable** — ticking it selects **every leaf under it** |
48
73
  | `true` | no | **selectable** |
49
74
 
50
75
  Two of those deserve their reasons written down.
51
76
 
52
- **A parent with children, flag on, selects its CHILDREN** — not an id of its own.
77
+ **A parent with children, flag on, selects its LEAVES** — not an id of its own.
53
78
  That is what makes the value a caller receives uniform: always leaves, never a
54
79
  mix of leaves and groups that every consumer would have to re-expand. The chip
55
80
  tray collapses a fully-selected category back to one chip bearing the category's
@@ -80,7 +105,8 @@ const [categories, setCategories] = useState<string[]>([]);
80
105
  ### `single` — the "move to…" picker
81
106
 
82
107
  Choosing a row commits immediately; there is nothing to batch. The trigger then
83
- reads `Pai › Filha`, so the chosen leaf keeps its context.
108
+ reads the whole path — `Pai › Filha`, or `Pai › Filha › Item` on a deeper tree —
109
+ so the chosen leaf keeps its context once the panel has closed over it.
84
110
 
85
111
  ```tsx
86
112
  <CategorySelect
@@ -94,8 +120,16 @@ reads `Pai › Filha`, so the chosen leaf keeps its context.
94
120
 
95
121
  ## Behaviour worth knowing
96
122
 
97
- - **Opens fully expanded.** Nothing a search could match hides behind a
98
- disclosure.
123
+ - **Opens with the top level unfolded.** On a two-level tree that is everything.
124
+ On a deeper one the items stay one click away, which is the whole point of
125
+ filing them under something. A **search reveals every hit regardless** — folded
126
+ or not — and clearing it restores exactly what you had folded.
127
+ - **A frame you cannot tick is still a foldable ROW in a deep tree.** In a
128
+ two-level tree, single-select draws it as an inert uppercase heading with its
129
+ subcategories always on show: there is nothing to fold to, since everything
130
+ under it is already the thing you came for. Add a third level and that reading
131
+ breaks twice over — the heading has no chevron, so the panel opens onto the
132
+ whole catalogue — so the treatment is dropped and every frame gets a chevron.
99
133
  - **Selected categories pin above the list.** They survive scrolling and
100
134
  searching — a tick made at the bottom of a ten-category tree does not vanish
101
135
  the moment you look for the next one.
@@ -112,9 +146,9 @@ reads `Pai › Filha`, so the chosen leaf keeps its context.
112
146
  | Key | Does |
113
147
  | --- | --- |
114
148
  | `↑` `↓` | Move the cursor |
115
- | `→` | Expand the category |
116
- | `←` | Collapse it, or jump from a subcategory to its parent |
117
- | `Space` | Mark the row (single-select: pick it) — a heading category expands |
149
+ | `→` | Expand the row, at any depth |
150
+ | `←` | Collapse it, or jump from a leaf to its parent |
151
+ | `Space` | Mark the row (single-select: pick it) — a frame expands instead |
118
152
  | `Enter` | Apply (single-select: pick, and expand on a heading, as `Space` does) |
119
153
  | `Esc` | Cancel, returning focus to the trigger |
120
154
 
@@ -126,7 +160,7 @@ the field.
126
160
  ## Styling
127
161
 
128
162
  Geometry is the design's absolute pixels — 38px trigger, 340px panel, 36px rows,
129
- 1.5px checkbox borders. Those are deliberately not derived from `theme.spacing`,
163
+ 14px of indent per level, 1.5px checkbox borders. Those are deliberately not derived from `theme.spacing`,
130
164
  which is an 8px grid and would round the design away.
131
165
 
132
166
  **Colours come from the theme.** A tenant can white-label the palette, and the
@@ -28,6 +28,8 @@ export const METRICS = {
28
28
  rowRadius: 8,
29
29
  boxSize: 18,
30
30
  chevronButton: 22,
31
+ /** Extra left padding per level of nesting. */
32
+ rowIndentStep: 14,
31
33
  footerButton: 32,
32
34
  sheetFooterButton: 38,
33
35
  /** Below this width the panel becomes a bottom sheet. */
@@ -239,6 +241,17 @@ export const rowSx = (theme: Theme, active: boolean, sheet: boolean): CSSObject
239
241
  '&:focus-visible': { outline: `2px solid ${theme.palette.primary.main}`, outlineOffset: 1 },
240
242
  });
241
243
 
244
+ /**
245
+ * How far a row sits in from the panel edge, by depth.
246
+ *
247
+ * One step per level, on top of {@link rowSx}'s own 8px gutter — depth 0 keeps
248
+ * that gutter, and every level below it adds `METRICS.rowIndentStep`. Absolute
249
+ * like the rest of this file: the step is the design's own 14px, not a spacing
250
+ * multiple that would round it.
251
+ */
252
+ export const rowIndent = (depth: number): CSSObject =>
253
+ depth === 0 ? {} : { paddingLeft: `${depth * METRICS.rowIndentStep}px` };
254
+
242
255
  export const rowNameSx = (theme: Theme, isCategory: boolean): CSSObject => ({
243
256
  flex: 1,
244
257
  minWidth: 0,
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef } from 'react';
4
4
  import { useTheme } from '@mui/material/styles/index.js';
5
5
  import useMediaQuery from '@mui/material/useMediaQuery/index.js';
6
6
 
7
- import { summarizeSelection } from './category-tree';
7
+ import { categoryPath, summarizeSelection } from './category-tree';
8
8
  import { CategoryPanel } from './CategoryPanel';
9
9
  import { CategoryPanelSurface } from './CategoryPanelSurface';
10
10
  import { CategoryTrigger } from './CategoryTrigger';
@@ -26,14 +26,13 @@ function draftChanged(draft: ReadonlySet<string>, applied: readonly string[]): b
26
26
  return draft.size !== applied.length || applied.some((id) => !draft.has(id));
27
27
  }
28
28
 
29
- /** "Pai › Filha" for a chosen id, so the trigger carries its context. */
29
+ /**
30
+ * "Pai › Filha" for a chosen id, so the trigger carries its context once the
31
+ * panel has closed over it. The whole path, at whatever depth the row sits.
32
+ */
30
33
  function singleLabel(groups: CategoryGroup[], value: string | null): string | undefined {
31
34
  if (!value) return undefined;
32
- const asCategory = groups.find((group) => group.category.id === value);
33
- if (asCategory) return asCategory.category.name;
34
- const owner = groups.find((group) => group.subcategories.some((sub) => sub.id === value));
35
- const sub = owner?.subcategories.find((candidate) => candidate.id === value);
36
- return sub && owner ? `${owner.category.name} › ${sub.name}` : undefined;
35
+ return categoryPath(groups, value)?.join(' › ');
37
36
  }
38
37
 
39
38
  /** The optional props, resolved once so the component body stays branch-free. */
@@ -85,19 +84,22 @@ function usePanelFocus(
85
84
  }
86
85
 
87
86
  /**
88
- * A hierarchical category picker: categories as the frame, subcategories as the
89
- * thing you choose.
87
+ * A hierarchical category picker: categories as the frame, whatever is filed
88
+ * under them as the thing you choose.
90
89
  *
91
90
  * Two modes off one component. `multi` is the FILTER — ticks accumulate in a
92
91
  * draft and reach `onChange` only on Apply, so the list behind the panel does not
93
92
  * reload on every click. `single` is the "move to…" picker, where choosing a row
94
93
  * commits immediately because there is nothing to batch.
95
94
  *
96
- * The tree opens fully expanded: nothing a search could match is hidden behind a
97
- * disclosure. Selected categories pin above the list so they survive scrolling
98
- * and searching, search is accent-insensitive (`agua` finds `Águas`), and the
99
- * whole tree is keyboard-drivable — ↑↓ to move, → to open, ← to close, Space to
100
- * mark, Enter to apply, Esc to cancel.
95
+ * The tree nests as deep as the payload does — a picker can file ITEMS under a
96
+ * subcategory and get a third storey with no extra configuration. Opening
97
+ * unfolds the top level, and a search reveals every hit whatever is folded.
98
+ *
99
+ * Selected categories pin above the list so they survive scrolling and
100
+ * searching, search is accent-insensitive (`agua` finds `Águas`), and the whole
101
+ * tree is keyboard-drivable — ↑↓ to move, → to open, ← to close, Space to mark,
102
+ * Enter to apply, Esc to cancel.
101
103
  *
102
104
  * Under {@link METRICS.sheetBreakpoint} the panel becomes a bottom sheet, which
103
105
  * is the only way a 290px-tall list stays usable one-handed.
@@ -22,10 +22,18 @@ export interface CategorySelectOption {
22
22
  count?: number;
23
23
  }
24
24
 
25
- /** A top-level category together with the subcategories nested under it. */
25
+ /**
26
+ * One node of the category tree: an option together with whatever sits under it.
27
+ *
28
+ * Recursive rather than "a category and its flat subcategories" because the tree
29
+ * a caller hands over is not always two deep — a picker can file ITEMS under a
30
+ * subcategory, and the level below still has to nest, fold and search like the
31
+ * one above it. A node with an empty `subcategories` is the leaf.
32
+ */
26
33
  export interface CategoryGroup {
27
34
  category: CategorySelectOption;
28
- subcategories: CategorySelectOption[];
35
+ /** The nodes directly below it, each a group in its own right. */
36
+ subcategories: CategoryGroup[];
29
37
  }
30
38
 
31
39
  /** One entry in the "selected" summary: a whole category, or a single leaf. */