@multiplatform.one/table 6.4.3 → 6.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 (219) hide show
  1. package/dist/cjs/components/ColumnHeaderFilter/index.cjs +1 -0
  2. package/dist/cjs/components/ColumnHeaderFilter/index.native.js +1 -0
  3. package/dist/cjs/components/ColumnHeaderFilter/index.native.js.map +1 -1
  4. package/dist/cjs/components/DataTable/DataTableMobileCards.cjs +9 -3
  5. package/dist/cjs/components/DataTable/DataTableMobileCards.native.js +12 -3
  6. package/dist/cjs/components/DataTable/DataTableMobileCards.native.js.map +1 -1
  7. package/dist/cjs/components/DataTable/DataTableRoot.cjs +133 -13
  8. package/dist/cjs/components/DataTable/DataTableRoot.native.js +159 -13
  9. package/dist/cjs/components/DataTable/DataTableRoot.native.js.map +1 -1
  10. package/dist/cjs/components/DataTable/DataTableVirtualized.cjs +3 -1
  11. package/dist/cjs/components/DataTable/DataTableVirtualized.native.js +3 -1
  12. package/dist/cjs/components/DataTable/DataTableVirtualized.native.js.map +1 -1
  13. package/dist/cjs/components/DataTable/index.native.js +39 -2
  14. package/dist/cjs/components/DataTable/index.native.js.map +1 -1
  15. package/dist/cjs/components/DataTableBulkActions/index.cjs +16 -1
  16. package/dist/cjs/components/DataTableBulkActions/index.native.js +20 -1
  17. package/dist/cjs/components/DataTableBulkActions/index.native.js.map +1 -1
  18. package/dist/cjs/components/DataTableCell/CellModalEditor.cjs +4 -8
  19. package/dist/cjs/components/DataTableCell/index.cjs +1 -33
  20. package/dist/cjs/components/DataTableCell/index.native.js +1 -34
  21. package/dist/cjs/components/DataTableCell/index.native.js.map +1 -1
  22. package/dist/cjs/components/DataTableExport/index.cjs +1 -0
  23. package/dist/cjs/components/DataTableExport/index.native.js +1 -0
  24. package/dist/cjs/components/DataTableExport/index.native.js.map +1 -1
  25. package/dist/cjs/components/DataTableFilterButton/index.cjs +1 -1
  26. package/dist/cjs/components/DataTableFilterButton/index.native.js +4 -1
  27. package/dist/cjs/components/DataTableFilterButton/index.native.js.map +1 -1
  28. package/dist/cjs/components/DataTableGrouping/index.cjs +1 -0
  29. package/dist/cjs/components/DataTableGrouping/index.native.js +1 -0
  30. package/dist/cjs/components/DataTableGrouping/index.native.js.map +1 -1
  31. package/dist/cjs/components/DataTableToolbar/index.cjs +1 -0
  32. package/dist/cjs/components/DataTableToolbar/index.native.js +1 -0
  33. package/dist/cjs/components/DataTableToolbar/index.native.js.map +1 -1
  34. package/dist/cjs/components/FilterManager.cjs +18 -14
  35. package/dist/cjs/components/FilterManager.native.js +18 -14
  36. package/dist/cjs/components/FilterManager.native.js.map +1 -1
  37. package/dist/cjs/components/TableInput/index.native.js +3 -0
  38. package/dist/cjs/components/TableInput/index.native.js.map +1 -1
  39. package/dist/cjs/hooks/useDataTable.cjs +17 -1
  40. package/dist/cjs/hooks/useDataTable.native.js +19 -1
  41. package/dist/cjs/hooks/useDataTable.native.js.map +1 -1
  42. package/dist/cjs/utils/rowPress.cjs +44 -0
  43. package/dist/cjs/utils/rowPress.native.js +56 -0
  44. package/dist/cjs/utils/rowPress.native.js.map +1 -0
  45. package/dist/esm/components/ColumnHeaderFilter/index.mjs +1 -0
  46. package/dist/esm/components/ColumnHeaderFilter/index.mjs.map +1 -1
  47. package/dist/esm/components/ColumnHeaderFilter/index.native.js +1 -0
  48. package/dist/esm/components/ColumnHeaderFilter/index.native.js.map +1 -1
  49. package/dist/esm/components/DataTable/DataTableMobileCards.mjs +9 -3
  50. package/dist/esm/components/DataTable/DataTableMobileCards.mjs.map +1 -1
  51. package/dist/esm/components/DataTable/DataTableMobileCards.native.js +12 -3
  52. package/dist/esm/components/DataTable/DataTableMobileCards.native.js.map +1 -1
  53. package/dist/esm/components/DataTable/DataTableRoot.mjs +133 -13
  54. package/dist/esm/components/DataTable/DataTableRoot.mjs.map +1 -1
  55. package/dist/esm/components/DataTable/DataTableRoot.native.js +159 -13
  56. package/dist/esm/components/DataTable/DataTableRoot.native.js.map +1 -1
  57. package/dist/esm/components/DataTable/DataTableVirtualized.mjs +3 -1
  58. package/dist/esm/components/DataTable/DataTableVirtualized.mjs.map +1 -1
  59. package/dist/esm/components/DataTable/DataTableVirtualized.native.js +3 -1
  60. package/dist/esm/components/DataTable/DataTableVirtualized.native.js.map +1 -1
  61. package/dist/esm/components/DataTable/index.native.js +39 -2
  62. package/dist/esm/components/DataTable/index.native.js.map +1 -1
  63. package/dist/esm/components/DataTableBulkActions/index.mjs +16 -1
  64. package/dist/esm/components/DataTableBulkActions/index.mjs.map +1 -1
  65. package/dist/esm/components/DataTableBulkActions/index.native.js +20 -1
  66. package/dist/esm/components/DataTableBulkActions/index.native.js.map +1 -1
  67. package/dist/esm/components/DataTableCell/CellModalEditor.mjs +4 -8
  68. package/dist/esm/components/DataTableCell/CellModalEditor.mjs.map +1 -1
  69. package/dist/esm/components/DataTableCell/index.mjs +1 -33
  70. package/dist/esm/components/DataTableCell/index.mjs.map +1 -1
  71. package/dist/esm/components/DataTableCell/index.native.js +1 -34
  72. package/dist/esm/components/DataTableCell/index.native.js.map +1 -1
  73. package/dist/esm/components/DataTableExport/index.mjs +1 -0
  74. package/dist/esm/components/DataTableExport/index.mjs.map +1 -1
  75. package/dist/esm/components/DataTableExport/index.native.js +1 -0
  76. package/dist/esm/components/DataTableExport/index.native.js.map +1 -1
  77. package/dist/esm/components/DataTableFilterButton/index.mjs +1 -1
  78. package/dist/esm/components/DataTableFilterButton/index.mjs.map +1 -1
  79. package/dist/esm/components/DataTableFilterButton/index.native.js +4 -1
  80. package/dist/esm/components/DataTableFilterButton/index.native.js.map +1 -1
  81. package/dist/esm/components/DataTableGrouping/index.mjs +1 -0
  82. package/dist/esm/components/DataTableGrouping/index.mjs.map +1 -1
  83. package/dist/esm/components/DataTableGrouping/index.native.js +1 -0
  84. package/dist/esm/components/DataTableGrouping/index.native.js.map +1 -1
  85. package/dist/esm/components/DataTableToolbar/index.mjs +1 -0
  86. package/dist/esm/components/DataTableToolbar/index.mjs.map +1 -1
  87. package/dist/esm/components/DataTableToolbar/index.native.js +1 -0
  88. package/dist/esm/components/DataTableToolbar/index.native.js.map +1 -1
  89. package/dist/esm/components/FilterManager.mjs +18 -14
  90. package/dist/esm/components/FilterManager.mjs.map +1 -1
  91. package/dist/esm/components/FilterManager.native.js +18 -14
  92. package/dist/esm/components/FilterManager.native.js.map +1 -1
  93. package/dist/esm/components/TableInput/index.native.js +3 -0
  94. package/dist/esm/components/TableInput/index.native.js.map +1 -1
  95. package/dist/esm/hooks/useDataTable.mjs +17 -1
  96. package/dist/esm/hooks/useDataTable.mjs.map +1 -1
  97. package/dist/esm/hooks/useDataTable.native.js +19 -1
  98. package/dist/esm/hooks/useDataTable.native.js.map +1 -1
  99. package/dist/esm/utils/rowPress.mjs +19 -0
  100. package/dist/esm/utils/rowPress.mjs.map +1 -0
  101. package/dist/esm/utils/rowPress.native.js +28 -0
  102. package/dist/esm/utils/rowPress.native.js.map +1 -0
  103. package/dist/jsx/components/ColumnHeaderFilter/index.mjs +1 -0
  104. package/dist/jsx/components/ColumnHeaderFilter/index.mjs.map +1 -1
  105. package/dist/jsx/components/ColumnHeaderFilter/index.native.js +1 -0
  106. package/dist/jsx/components/ColumnHeaderFilter/index.native.js.map +1 -1
  107. package/dist/jsx/components/DataTable/DataTableMobileCards.mjs +9 -3
  108. package/dist/jsx/components/DataTable/DataTableMobileCards.mjs.map +1 -1
  109. package/dist/jsx/components/DataTable/DataTableMobileCards.native.js +12 -3
  110. package/dist/jsx/components/DataTable/DataTableMobileCards.native.js.map +1 -1
  111. package/dist/jsx/components/DataTable/DataTableRoot.mjs +133 -13
  112. package/dist/jsx/components/DataTable/DataTableRoot.mjs.map +1 -1
  113. package/dist/jsx/components/DataTable/DataTableRoot.native.js +159 -13
  114. package/dist/jsx/components/DataTable/DataTableRoot.native.js.map +1 -1
  115. package/dist/jsx/components/DataTable/DataTableVirtualized.mjs +3 -1
  116. package/dist/jsx/components/DataTable/DataTableVirtualized.mjs.map +1 -1
  117. package/dist/jsx/components/DataTable/DataTableVirtualized.native.js +3 -1
  118. package/dist/jsx/components/DataTable/DataTableVirtualized.native.js.map +1 -1
  119. package/dist/jsx/components/DataTable/index.native.js +39 -2
  120. package/dist/jsx/components/DataTable/index.native.js.map +1 -1
  121. package/dist/jsx/components/DataTableBulkActions/index.mjs +16 -1
  122. package/dist/jsx/components/DataTableBulkActions/index.mjs.map +1 -1
  123. package/dist/jsx/components/DataTableBulkActions/index.native.js +20 -1
  124. package/dist/jsx/components/DataTableBulkActions/index.native.js.map +1 -1
  125. package/dist/jsx/components/DataTableCell/CellModalEditor.mjs +4 -8
  126. package/dist/jsx/components/DataTableCell/CellModalEditor.mjs.map +1 -1
  127. package/dist/jsx/components/DataTableCell/index.mjs +1 -33
  128. package/dist/jsx/components/DataTableCell/index.mjs.map +1 -1
  129. package/dist/jsx/components/DataTableCell/index.native.js +1 -34
  130. package/dist/jsx/components/DataTableCell/index.native.js.map +1 -1
  131. package/dist/jsx/components/DataTableExport/index.mjs +1 -0
  132. package/dist/jsx/components/DataTableExport/index.mjs.map +1 -1
  133. package/dist/jsx/components/DataTableExport/index.native.js +1 -0
  134. package/dist/jsx/components/DataTableExport/index.native.js.map +1 -1
  135. package/dist/jsx/components/DataTableFilterButton/index.mjs +1 -1
  136. package/dist/jsx/components/DataTableFilterButton/index.mjs.map +1 -1
  137. package/dist/jsx/components/DataTableFilterButton/index.native.js +4 -1
  138. package/dist/jsx/components/DataTableFilterButton/index.native.js.map +1 -1
  139. package/dist/jsx/components/DataTableGrouping/index.mjs +1 -0
  140. package/dist/jsx/components/DataTableGrouping/index.mjs.map +1 -1
  141. package/dist/jsx/components/DataTableGrouping/index.native.js +1 -0
  142. package/dist/jsx/components/DataTableGrouping/index.native.js.map +1 -1
  143. package/dist/jsx/components/DataTableToolbar/index.mjs +1 -0
  144. package/dist/jsx/components/DataTableToolbar/index.mjs.map +1 -1
  145. package/dist/jsx/components/DataTableToolbar/index.native.js +1 -0
  146. package/dist/jsx/components/DataTableToolbar/index.native.js.map +1 -1
  147. package/dist/jsx/components/FilterManager.mjs +18 -14
  148. package/dist/jsx/components/FilterManager.mjs.map +1 -1
  149. package/dist/jsx/components/FilterManager.native.js +18 -14
  150. package/dist/jsx/components/FilterManager.native.js.map +1 -1
  151. package/dist/jsx/components/TableInput/index.native.js +3 -0
  152. package/dist/jsx/components/TableInput/index.native.js.map +1 -1
  153. package/dist/jsx/hooks/useDataTable.mjs +17 -1
  154. package/dist/jsx/hooks/useDataTable.mjs.map +1 -1
  155. package/dist/jsx/hooks/useDataTable.native.js +19 -1
  156. package/dist/jsx/hooks/useDataTable.native.js.map +1 -1
  157. package/dist/jsx/utils/rowPress.mjs +19 -0
  158. package/dist/jsx/utils/rowPress.mjs.map +1 -0
  159. package/dist/jsx/utils/rowPress.native.js +56 -0
  160. package/dist/jsx/utils/rowPress.native.js.map +1 -0
  161. package/package.json +13 -13
  162. package/src/components/ColumnHeaderFilter/index.tsx +1 -0
  163. package/src/components/DataTable/DataTable.spec.tsx +4 -1
  164. package/src/components/DataTable/DataTable.stories.tsx +190 -5
  165. package/src/components/DataTable/DataTableMobileCards.tsx +30 -4
  166. package/src/components/DataTable/DataTableRoot.tsx +195 -8
  167. package/src/components/DataTable/DataTableVirtualized.tsx +8 -0
  168. package/src/components/DataTable/cardLayout.spec.tsx +4 -1
  169. package/src/components/DataTable/customCellAlign.spec.tsx +124 -0
  170. package/src/components/DataTable/disabledContract.spec.tsx +4 -3
  171. package/src/components/DataTable/index.native.tsx +47 -1
  172. package/src/components/DataTable/rowPress.spec.tsx +229 -0
  173. package/src/components/DataTable/selectionSemantics.spec.tsx +156 -0
  174. package/src/components/DataTableBulkActions/index.tsx +16 -2
  175. package/src/components/DataTableBulkActions/selectionCountI18n.spec.tsx +94 -0
  176. package/src/components/DataTableCell/CellModalEditor.tsx +3 -1
  177. package/src/components/DataTableCell/cellDraftPolicy.spec.tsx +10 -2
  178. package/src/components/DataTableCell/dataTableCell.spec.tsx +10 -2
  179. package/src/components/DataTableCell/index.tsx +5 -36
  180. package/src/components/DataTableColumnPinning/DataTableColumnPinning.stories.tsx +49 -17
  181. package/src/components/DataTableDateFilter/DataTableDateFilter.stories.tsx +4 -3
  182. package/src/components/DataTableExport/DataTableExport.stories.tsx +3 -2
  183. package/src/components/DataTableExport/index.tsx +1 -0
  184. package/src/components/DataTableFilterButton/index.tsx +4 -1
  185. package/src/components/DataTableGrouping/DataTableGrouping.spec.tsx +59 -2
  186. package/src/components/DataTableGrouping/DataTableGrouping.stories.tsx +17 -5
  187. package/src/components/DataTableGrouping/index.tsx +1 -0
  188. package/src/components/DataTablePagination/DataTablePagination.stories.tsx +34 -20
  189. package/src/components/DataTableToolbar/index.tsx +1 -0
  190. package/src/components/DataTableViewOptions/DataTableViewOptions.stories.tsx +2 -1
  191. package/src/components/FilterManager.tsx +21 -13
  192. package/src/components/TableInput/index.native.tsx +3 -0
  193. package/src/components/__scratch__/scratch.spec.tsx +46 -0
  194. package/src/hooks/useDataTable.ts +27 -1
  195. package/src/types.ts +25 -0
  196. package/src/utils/rowPress.ts +58 -0
  197. package/types/components/ColumnHeaderFilter/index.d.ts.map +1 -1
  198. package/types/components/DataTable/DataTableMobileCards.d.ts +13 -1
  199. package/types/components/DataTable/DataTableMobileCards.d.ts.map +1 -1
  200. package/types/components/DataTable/DataTableRoot.d.ts +1 -1
  201. package/types/components/DataTable/DataTableRoot.d.ts.map +1 -1
  202. package/types/components/DataTable/DataTableVirtualized.d.ts +7 -1
  203. package/types/components/DataTable/DataTableVirtualized.d.ts.map +1 -1
  204. package/types/components/DataTable/index.native.d.ts +1 -1
  205. package/types/components/DataTable/index.native.d.ts.map +1 -1
  206. package/types/components/DataTableBulkActions/index.d.ts.map +1 -1
  207. package/types/components/DataTableCell/CellModalEditor.d.ts.map +1 -1
  208. package/types/components/DataTableCell/index.d.ts.map +1 -1
  209. package/types/components/DataTableFilterButton/index.d.ts.map +1 -1
  210. package/types/components/DataTableGrouping/index.d.ts.map +1 -1
  211. package/types/components/DataTableToolbar/index.d.ts.map +1 -1
  212. package/types/components/FilterManager.d.ts +7 -1
  213. package/types/components/FilterManager.d.ts.map +1 -1
  214. package/types/components/TableInput/index.native.d.ts.map +1 -1
  215. package/types/hooks/useDataTable.d.ts.map +1 -1
  216. package/types/types.d.ts +23 -0
  217. package/types/types.d.ts.map +1 -1
  218. package/types/utils/rowPress.d.ts +20 -0
  219. package/types/utils/rowPress.d.ts.map +1 -0
@@ -0,0 +1,229 @@
1
+ /**
2
+ * onRowPress — first-class row activation (LC-49 ONE-TAB-STOP, LC-50
3
+ * KEY-ACTIVATION, DG-A11Y-06 names).
4
+ *
5
+ * The row is ONE pressable unit: click/tap anywhere activates, Enter AND
6
+ * Space activate from the keyboard, the row set exposes exactly one tab
7
+ * stop (roving tabindex, arrows/Home/End move it), and presses that
8
+ * originate on inner interactive controls (selection checkboxes, buttons)
9
+ * never reach the row handler. Cards (layout="cards") take the same
10
+ * contract with role="button".
11
+ *
12
+ * layout="table" pins the grid below the $sm test-env breakpoint.
13
+ */
14
+
15
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
16
+ import { fireEvent, screen, waitFor } from "@testing-library/react";
17
+ import { describe, expect, it, vi } from "vitest";
18
+ import { Button } from "@multiplatform.one/forms";
19
+ import type { DataTableColumn } from "../../types";
20
+ import { DataTable } from "./index";
21
+
22
+ type Person = {
23
+ id: number;
24
+ firstName: string;
25
+ age: number;
26
+ };
27
+
28
+ const sampleData: Person[] = [
29
+ { id: 1, firstName: "Alice", age: 30 },
30
+ { id: 2, firstName: "Bob", age: 40 },
31
+ { id: 3, firstName: "Carol", age: 20 },
32
+ ];
33
+
34
+ // Explicit ids: DataTable seeds columnOrder from `col.id || ""`, so mixing
35
+ // id-less accessor columns with an id'd display column would reorder the
36
+ // display column first (and identity fallbacks would read it as the "first
37
+ // visible cell").
38
+ const columns: DataTableColumn<Person>[] = [
39
+ { id: "firstName", accessorKey: "firstName", header: "First Name" },
40
+ { id: "age", accessorKey: "age", header: "Age" },
41
+ ];
42
+
43
+ function pressRows(container: HTMLElement): HTMLElement[] {
44
+ return Array.from(container.querySelectorAll('[data-row-press="true"]')) as HTMLElement[];
45
+ }
46
+
47
+ function renderRowPressTable(extraProps: Record<string, unknown> = {}) {
48
+ const onRowPress = vi.fn();
49
+ const result = renderWithProviders(
50
+ <DataTable
51
+ data={sampleData}
52
+ columns={columns as DataTableColumn<Record<string, any>>[]}
53
+ layout="table"
54
+ enablePagination={false}
55
+ onRowPress={onRowPress}
56
+ {...extraProps}
57
+ />,
58
+ );
59
+ return { ...result, onRowPress };
60
+ }
61
+
62
+ describe("DataTable onRowPress (grid)", () => {
63
+ it("activates on a click anywhere in the row with the row data and index", () => {
64
+ const { container, onRowPress } = renderRowPressTable();
65
+ const rows = pressRows(container);
66
+ expect(rows).toHaveLength(3);
67
+
68
+ fireEvent.click(screen.getByText("Bob"));
69
+ expect(onRowPress).toHaveBeenCalledTimes(1);
70
+ expect(onRowPress).toHaveBeenCalledWith(
71
+ expect.objectContaining({ firstName: "Bob", id: 2 }),
72
+ 1,
73
+ );
74
+ });
75
+
76
+ it("exposes exactly one tab stop; arrows and Home/End rove it (LC-49)", async () => {
77
+ const { container } = renderRowPressTable();
78
+ const rows = pressRows(container);
79
+
80
+ const tabStops = () => rows.map((row) => row.getAttribute("tabindex"));
81
+ expect(tabStops()).toEqual(["0", "-1", "-1"]);
82
+
83
+ rows[0].focus();
84
+ expect(document.activeElement).toBe(rows[0]);
85
+
86
+ fireEvent.keyDown(rows[0], { key: "ArrowDown" });
87
+ expect(document.activeElement).toBe(rows[1]);
88
+ // The anchor follows focus: still exactly one tabindex=0.
89
+ await waitFor(() => {
90
+ const stops = pressRows(container).map((row) => row.getAttribute("tabindex"));
91
+ expect(stops).toEqual(["-1", "0", "-1"]);
92
+ });
93
+
94
+ fireEvent.keyDown(rows[1], { key: "End" });
95
+ expect(document.activeElement).toBe(rows[2]);
96
+ fireEvent.keyDown(rows[2], { key: "Home" });
97
+ expect(document.activeElement).toBe(rows[0]);
98
+ fireEvent.keyDown(rows[0], { key: "ArrowUp" });
99
+ // Clamped at the first row — no wrap.
100
+ expect(document.activeElement).toBe(rows[0]);
101
+ });
102
+
103
+ it("activates on Enter AND Space, once per physical key press (LC-50)", () => {
104
+ const { container, onRowPress } = renderRowPressTable();
105
+ const rows = pressRows(container);
106
+ rows[0].focus();
107
+
108
+ fireEvent.keyDown(rows[0], { key: "Enter" });
109
+ expect(onRowPress).toHaveBeenCalledTimes(1);
110
+ expect(onRowPress).toHaveBeenLastCalledWith(expect.objectContaining({ firstName: "Alice" }), 0);
111
+
112
+ fireEvent.keyDown(rows[0], { key: " " });
113
+ expect(onRowPress).toHaveBeenCalledTimes(2);
114
+
115
+ // Key auto-repeat never machine-guns activations.
116
+ fireEvent.keyDown(rows[0], { key: "Enter", repeat: true });
117
+ expect(onRowPress).toHaveBeenCalledTimes(2);
118
+ });
119
+
120
+ it("ignores presses that originate on inner interactive controls", () => {
121
+ const buttonPress = vi.fn();
122
+ const interactiveColumns: DataTableColumn<Person>[] = [
123
+ ...columns,
124
+ {
125
+ id: "row_actions",
126
+ header: "Actions",
127
+ cell: ({ row }) => (
128
+ <Button size="$2" onPress={() => buttonPress(row.original.id)}>
129
+ Edit
130
+ </Button>
131
+ ),
132
+ },
133
+ ];
134
+ const { container, onRowPress } = renderRowPressTable({
135
+ columns: interactiveColumns as DataTableColumn<Record<string, any>>[],
136
+ enableRowSelection: true,
137
+ });
138
+
139
+ // Selection checkbox: toggles selection, never fires the row press.
140
+ const rowCheckbox = screen.getByRole("checkbox", { name: "Select row Alice" });
141
+ fireEvent.click(rowCheckbox);
142
+ expect(rowCheckbox.getAttribute("aria-checked")).toBe("true");
143
+ expect(onRowPress).not.toHaveBeenCalled();
144
+
145
+ // Inner button: its own handler fires, the row press does not.
146
+ fireEvent.click(screen.getAllByText("Edit")[0]);
147
+ expect(buttonPress).toHaveBeenCalledWith(1);
148
+ expect(onRowPress).not.toHaveBeenCalled();
149
+
150
+ // A plain cell in the same row still activates the row.
151
+ fireEvent.click(screen.getByText("Alice"));
152
+ expect(onRowPress).toHaveBeenCalledTimes(1);
153
+ expect(container.querySelectorAll('[data-row-press="true"]').length).toBe(3);
154
+ });
155
+
156
+ it("names rows: rowLabel accessor wins; first cell value is the fallback", () => {
157
+ const { container, unmount } = renderRowPressTable({
158
+ rowLabel: (row: Person) => `Open ${row.firstName}`,
159
+ });
160
+ expect(pressRows(container).map((row) => row.getAttribute("aria-label"))).toEqual([
161
+ "Open Alice",
162
+ "Open Bob",
163
+ "Open Carol",
164
+ ]);
165
+ unmount();
166
+
167
+ const { container: fallbackContainer } = renderRowPressTable();
168
+ expect(pressRows(fallbackContainer)[0].getAttribute("aria-label")).toBe("Alice");
169
+ });
170
+ });
171
+
172
+ describe("DataTable onRowPress (cards layout)", () => {
173
+ it("cards are pressable buttons with the same activation + isolation contract", async () => {
174
+ const onRowPress = vi.fn();
175
+ renderWithProviders(
176
+ <DataTable
177
+ data={sampleData}
178
+ columns={columns as DataTableColumn<Record<string, any>>[]}
179
+ layout="cards"
180
+ enablePagination={false}
181
+ enableRowSelection
182
+ onRowPress={onRowPress}
183
+ rowLabel={(row) => (row as Person).firstName}
184
+ />,
185
+ );
186
+
187
+ const card = screen.getByRole("button", { name: "Bob" });
188
+ expect(card.getAttribute("data-row-press")).toBe("true");
189
+
190
+ // Inner selection checkbox stays isolated from the card press.
191
+ const checkbox = screen.getByRole("checkbox", { name: "Select row Bob" });
192
+ fireEvent.click(checkbox);
193
+ expect(checkbox.getAttribute("aria-checked")).toBe("true");
194
+ expect(onRowPress).not.toHaveBeenCalled();
195
+
196
+ fireEvent.click(card);
197
+ expect(onRowPress).toHaveBeenCalledTimes(1);
198
+ expect(onRowPress).toHaveBeenCalledWith(expect.objectContaining({ firstName: "Bob" }), 1);
199
+
200
+ // Enter activates the focused card (LC-50).
201
+ card.focus();
202
+ fireEvent.keyDown(card, { key: "Enter" });
203
+ await waitFor(() => expect(onRowPress).toHaveBeenCalledTimes(2));
204
+ });
205
+
206
+ it("card list exposes one roving tab stop scoped to the list (LC-49)", () => {
207
+ const { container } = renderWithProviders(
208
+ <DataTable
209
+ data={sampleData}
210
+ columns={columns as DataTableColumn<Record<string, any>>[]}
211
+ layout="cards"
212
+ enablePagination={false}
213
+ onRowPress={vi.fn()}
214
+ />,
215
+ );
216
+ const cards = pressRows(container as HTMLElement);
217
+ expect(cards).toHaveLength(3);
218
+ expect(cards.map((card) => card.getAttribute("tabindex"))).toEqual(["0", "-1", "-1"]);
219
+ expect(
220
+ (container as HTMLElement).querySelector(
221
+ '[data-row-press-scope="true"] [data-row-press="true"]',
222
+ ),
223
+ ).not.toBeNull();
224
+
225
+ cards[0].focus();
226
+ fireEvent.keyDown(cards[0], { key: "ArrowDown" });
227
+ expect(document.activeElement).toBe(cards[1]);
228
+ });
229
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Row-selection semantics (desk-parity audit fix).
3
+ *
4
+ * The audit finding: list-row selection toggles rendered as anonymous
5
+ * pressables — no role=checkbox, no aria-checked, invisible to AT and
6
+ * automation; the bulk-actions bar was unreachable without a mouse. These
7
+ * specs pin the REAL checkbox contract on the package selection column:
8
+ * every toggle is role="checkbox" with aria-checked (header select-all
9
+ * supports "mixed"), named by row identity ("Select row {name}"), meets
10
+ * the 44px press floor through the control's own box (LC-54), toggles
11
+ * from the keyboard, and keyboard-only selection reaches the bulk bar.
12
+ */
13
+
14
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
15
+ import { fireEvent, screen, waitFor } from "@testing-library/react";
16
+ import userEvent from "@testing-library/user-event";
17
+ import { describe, expect, it, vi } from "vitest";
18
+ import type { BulkAction, DataTableColumn } from "../../types";
19
+ import { DataTable } from "./index";
20
+
21
+ type Person = {
22
+ id: number;
23
+ firstName: string;
24
+ age: number;
25
+ };
26
+
27
+ const sampleData: Person[] = [
28
+ { id: 1, firstName: "Alice", age: 30 },
29
+ { id: 2, firstName: "Bob", age: 40 },
30
+ { id: 3, firstName: "Carol", age: 20 },
31
+ ];
32
+
33
+ const columns: DataTableColumn<Person>[] = [
34
+ { accessorKey: "firstName", header: "First Name" },
35
+ { accessorKey: "age", header: "Age" },
36
+ ];
37
+
38
+ function renderSelectionTable(extraProps: Record<string, unknown> = {}) {
39
+ const bulkExecute = vi.fn();
40
+ const bulkActions: BulkAction<Person>[] = [
41
+ { id: "archive", label: "Archive", onExecute: bulkExecute },
42
+ ];
43
+ const result = renderWithProviders(
44
+ <DataTable
45
+ data={sampleData}
46
+ columns={columns as DataTableColumn<Record<string, any>>[]}
47
+ layout="table"
48
+ enablePagination={false}
49
+ enableRowSelection
50
+ bulkActions={bulkActions as BulkAction<Record<string, any>>[]}
51
+ {...extraProps}
52
+ />,
53
+ );
54
+ return { ...result, bulkExecute };
55
+ }
56
+
57
+ describe("DataTable selection semantics", () => {
58
+ it("row toggles are real checkboxes named by row identity", () => {
59
+ renderSelectionTable();
60
+
61
+ const rowCheckboxes = [
62
+ screen.getByRole("checkbox", { name: "Select row Alice" }),
63
+ screen.getByRole("checkbox", { name: "Select row Bob" }),
64
+ screen.getByRole("checkbox", { name: "Select row Carol" }),
65
+ ];
66
+ for (const checkbox of rowCheckboxes) {
67
+ expect(checkbox.getAttribute("aria-checked")).toBe("false");
68
+ // Real disclosure for automation: state is machine-readable, and the
69
+ // toggle is a focusable control (not an anonymous pressable View).
70
+ expect(checkbox.getAttribute("role")).toBe("checkbox");
71
+ expect((checkbox as HTMLElement).tagName).toBe("BUTTON");
72
+ }
73
+ });
74
+
75
+ it("rowLabel drives the checkbox names when provided", () => {
76
+ renderSelectionTable({
77
+ rowLabel: (row: Person) => `${row.firstName} #${row.id}`,
78
+ });
79
+ expect(screen.getByRole("checkbox", { name: "Select row Alice #1" })).toBeDefined();
80
+ expect(screen.getByRole("checkbox", { name: "Select row Carol #3" })).toBeDefined();
81
+ });
82
+
83
+ it("meets the 44px press floor through the control's own box (LC-54)", () => {
84
+ renderSelectionTable();
85
+ const checkbox = screen.getByRole("checkbox", { name: "Select row Alice" });
86
+ const style = getComputedStyle(checkbox);
87
+ expect(parseFloat(style.minWidth)).toBeGreaterThanOrEqual(44);
88
+ expect(parseFloat(style.minHeight)).toBeGreaterThanOrEqual(44);
89
+ });
90
+
91
+ it("header select-all exposes aria-checked including the mixed state", async () => {
92
+ renderSelectionTable();
93
+ const selectAll = screen.getByRole("checkbox", { name: "Select all rows on page" });
94
+ expect(selectAll.getAttribute("aria-checked")).toBe("false");
95
+
96
+ fireEvent.click(screen.getByRole("checkbox", { name: "Select row Alice" }));
97
+ await waitFor(() => expect(selectAll.getAttribute("aria-checked")).toBe("mixed"));
98
+
99
+ fireEvent.click(selectAll);
100
+ await waitFor(() => expect(selectAll.getAttribute("aria-checked")).toBe("true"));
101
+ for (const name of ["Alice", "Bob", "Carol"]) {
102
+ expect(
103
+ screen.getByRole("checkbox", { name: `Select row ${name}` }).getAttribute("aria-checked"),
104
+ ).toBe("true");
105
+ }
106
+
107
+ fireEvent.click(selectAll);
108
+ await waitFor(() => expect(selectAll.getAttribute("aria-checked")).toBe("false"));
109
+ });
110
+
111
+ it("keyboard-only selection reaches the bulk-actions bar", async () => {
112
+ const user = userEvent.setup();
113
+ const { bulkExecute } = renderSelectionTable();
114
+ expect(screen.queryByText("1 selected")).toBeNull();
115
+
116
+ // Space on the focused checkbox (real keystroke; the role=checkbox
117
+ // frame is a native button, so Space activates it).
118
+ const alice = screen.getByRole("checkbox", { name: "Select row Alice" });
119
+ (alice as HTMLElement).focus();
120
+ expect(document.activeElement).toBe(alice);
121
+ await user.keyboard(" ");
122
+ await waitFor(() => expect(alice.getAttribute("aria-checked")).toBe("true"));
123
+
124
+ // The bulk bar appears with the count and reachable actions.
125
+ expect(await screen.findByText("1 selected")).toBeDefined();
126
+
127
+ const carol = screen.getByRole("checkbox", { name: "Select row Carol" });
128
+ (carol as HTMLElement).focus();
129
+ await user.keyboard(" ");
130
+ expect(await screen.findByText("2 selected")).toBeDefined();
131
+
132
+ // The bar's actions are real buttons — Enter activates without a pointer.
133
+ const archive = screen.getByRole("button", { name: "Archive" });
134
+ (archive as HTMLElement).focus();
135
+ await user.keyboard("{Enter}");
136
+ await waitFor(() => expect(bulkExecute).toHaveBeenCalledTimes(1));
137
+ expect(bulkExecute.mock.calls[0][0]).toHaveLength(2);
138
+ });
139
+
140
+ it("cards layout carries the same checkbox semantics", () => {
141
+ renderWithProviders(
142
+ <DataTable
143
+ data={sampleData}
144
+ columns={columns as DataTableColumn<Record<string, any>>[]}
145
+ layout="cards"
146
+ enablePagination={false}
147
+ enableRowSelection
148
+ />,
149
+ );
150
+ const checkbox = screen.getByRole("checkbox", { name: "Select row Bob" });
151
+ expect(checkbox.getAttribute("aria-checked")).toBe("false");
152
+ fireEvent.click(checkbox);
153
+ expect(checkbox.getAttribute("aria-checked")).toBe("true");
154
+ expect(screen.getByRole("checkbox", { name: "Select all rows" })).toBeDefined();
155
+ });
156
+ });
@@ -1,10 +1,12 @@
1
1
  import { DotsThreeIcon } from "@phosphor-icons/react";
2
2
  import { useAccentOnSurface, useResolvedKnobs } from "@multiplatform.one/theme";
3
3
  import { useState } from "react";
4
+ import { useTranslation } from "react-i18next";
4
5
  import { Button, zIndex } from "@multiplatform.one/forms";
5
6
  import { ConfirmDialog, DropdownMenu } from "@multiplatform.one/components";
6
7
  import { Text, View, XStack } from "tamagui";
7
8
  import type { BulkAction } from "../../types";
9
+ import { withInterp } from "../../utils/withInterp";
8
10
  import { useDataTableState } from "../DataTable/DataTableStateContext";
9
11
 
10
12
  export type { BulkAction };
@@ -43,6 +45,7 @@ export function DataTableBulkActions<T = any>(props: DataTableBulkActionsProps<T
43
45
 
44
46
  const [confirmAction, setConfirmAction] = useState<BulkAction<T> | null>(null);
45
47
  const [isExecuting, setIsExecuting] = useState<string | null>(null);
48
+ const { t } = useTranslation();
46
49
  const { knobProps } = useResolvedKnobs();
47
50
  // UX-P04 / Axiom 12: the selection count is accent-hued text on the bar
48
51
  // surface (page), so it needs the page-readable accent — `$accentColor` (the
@@ -135,9 +138,20 @@ export function DataTableBulkActions<T = any>(props: DataTableBulkActionsProps<T
135
138
  >
136
139
  {showSelectionCount && (
137
140
  <Text fontSize="$3" fontWeight="600" color={countColor}>
141
+ {/* D-15: one whole-sentence key per variant (never concatenated
142
+ fragments — word order is locale-owned); withInterp fills the
143
+ params when no i18n instance is mounted (house table pattern). */}
138
144
  {filteredOutCount > 0
139
- ? `${selectedItems.length} selected (+${filteredOutCount} filtered out)`
140
- : `${selectedItems.length} selected`}
145
+ ? withInterp(
146
+ t("{{n}} selected (+{{hidden}} filtered out)", {
147
+ n: selectedItems.length,
148
+ hidden: filteredOutCount,
149
+ }),
150
+ { n: selectedItems.length, hidden: filteredOutCount },
151
+ )
152
+ : withInterp(t("{{n}} selected", { n: selectedItems.length }), {
153
+ n: selectedItems.length,
154
+ })}
141
155
  </Text>
142
156
  )}
143
157
 
@@ -0,0 +1,94 @@
1
+ // D-15: the selection-count label is i18n-safe — each variant is ONE
2
+ // whole-sentence t() key ("{{n}} selected", "{{n}} selected (+{{hidden}}
3
+ // filtered out)"), never concatenated English fragments, so locales own the
4
+ // word order. This spec runs with an initialized i18next instance whose
5
+ // translations deliberately differ from the keys (reordered words), proving
6
+ // the label routes through t() — the sibling DataTableBulkActions.spec.tsx
7
+ // covers the untranslated fallback (withInterp fills the raw key).
8
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
9
+ import { cleanup, screen } from "@testing-library/react";
10
+ import i18next from "i18next";
11
+ import { initReactI18next } from "react-i18next";
12
+ import React from "react";
13
+ import {
14
+ getCoreRowModel,
15
+ getFilteredRowModel,
16
+ getPaginationRowModel,
17
+ useReactTable,
18
+ } from "@tanstack/react-table";
19
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
20
+ import { DataTableStateContext } from "../DataTable/DataTableStateContext";
21
+ import { type BulkAction, DataTableBulkActions } from "./index";
22
+
23
+ beforeAll(async () => {
24
+ // Same natural-language keys registered in packages/i18n/en/common.json,
25
+ // mapped to a pseudo-locale that reorders the sentence.
26
+ await i18next.use(initReactI18next).init({
27
+ lng: "en",
28
+ interpolation: { escapeValue: false },
29
+ resources: {
30
+ en: {
31
+ translation: {
32
+ "{{n}} selected": "selected: {{n}}",
33
+ "{{n}} selected (+{{hidden}} filtered out)":
34
+ "selected: {{n}} (hidden by filter: {{hidden}})",
35
+ },
36
+ },
37
+ },
38
+ });
39
+ });
40
+
41
+ afterEach(cleanup);
42
+
43
+ type Row = { id: string; name: string; status: string };
44
+
45
+ const rows: Row[] = [
46
+ { id: "a", name: "Alpha", status: "ready" },
47
+ { id: "b", name: "Bravo", status: "blocked" },
48
+ ];
49
+
50
+ const actions: BulkAction<Row>[] = [{ id: "x", label: "X", onExecute: vi.fn() }];
51
+
52
+ function Harness({ columnFilters = [] }: { columnFilters?: Array<{ id: string; value: any }> }) {
53
+ const [rowSelection, setRowSelection] = React.useState<Record<string, boolean>>({
54
+ a: true,
55
+ b: true,
56
+ });
57
+ const table = useReactTable({
58
+ data: rows,
59
+ columns: [
60
+ { id: "name", accessorKey: "name", header: "Name" },
61
+ { id: "status", accessorKey: "status", header: "Status" },
62
+ ] as any,
63
+ state: { rowSelection, columnFilters, pagination: { pageIndex: 0, pageSize: 10 } },
64
+ onRowSelectionChange: setRowSelection,
65
+ getCoreRowModel: getCoreRowModel(),
66
+ getFilteredRowModel: getFilteredRowModel(),
67
+ getPaginationRowModel: getPaginationRowModel(),
68
+ getRowId: (row) => row.id,
69
+ enableRowSelection: true,
70
+ });
71
+ const ctxValue = React.useMemo(
72
+ () => ({ table, state: {} as any, actions: {} as any, computed: {} as any }) as any,
73
+ [table, rowSelection, columnFilters],
74
+ );
75
+ return (
76
+ <DataTableStateContext.Provider value={ctxValue}>
77
+ <DataTableBulkActions actions={actions} />
78
+ </DataTableStateContext.Provider>
79
+ );
80
+ }
81
+
82
+ describe("selection count label i18n (D-15)", () => {
83
+ it("base count renders through the {{n}} selected key", () => {
84
+ renderWithProviders(<Harness />);
85
+ expect(screen.getByText("selected: 2")).toBeInTheDocument();
86
+ expect(screen.queryByText(/\d selected/)).not.toBeInTheDocument();
87
+ });
88
+
89
+ it("filtered-out annotation renders through its own whole-sentence key", () => {
90
+ renderWithProviders(<Harness columnFilters={[{ id: "status", value: "ready" }]} />);
91
+ expect(screen.getByText("selected: 2 (hidden by filter: 1)")).toBeInTheDocument();
92
+ expect(screen.queryByText(/filtered out/)).not.toBeInTheDocument();
93
+ });
94
+ });
@@ -12,6 +12,7 @@
12
12
  * footer stays within the two-action dialog budget (LC-31).
13
13
  */
14
14
 
15
+ import { DialogOverlay } from "@multiplatform.one/components";
15
16
  import { FieldDisplay, type FieldDisplayType } from "@multiplatform.one/forms";
16
17
  import { TableCellContext } from "@multiplatform.one/table-primitives";
17
18
  import { useResolvedKnobs } from "@multiplatform.one/theme";
@@ -118,11 +119,12 @@ export function CellModalEditor({
118
119
  </View>
119
120
  <Dialog open={open} onOpenChange={handleOpenChange} modal>
120
121
  <Dialog.Portal>
121
- <Dialog.Overlay opacity={0.5} enterStyle={{ opacity: 0 }} exitStyle={{ opacity: 0 }} />
122
+ <DialogOverlay opacity={0.5} />
122
123
  <Dialog.Content
123
124
  borderWidth={1}
124
125
  borderColor="$borderColor"
125
126
  elevation={knobProps.elevation}
127
+ transition={knobProps.transition}
126
128
  enterStyle={{ y: -10, opacity: 0, scale: 0.96 }}
127
129
  exitStyle={{ y: 10, opacity: 0, scale: 0.96 }}
128
130
  maxWidth={720}
@@ -188,7 +188,11 @@ function MockMultiDatePicker({ value, onChange }: any) {
188
188
  );
189
189
  }
190
190
 
191
- vi.mock("@multiplatform.one/forms", () => ({
191
+ // Partial mock: real module first (module-scope exports like `zIndex` keep
192
+ // resolving for transitive imports), field components faked on top — the
193
+ // spec asserts draft/commit policy per field, not the fields themselves.
194
+ vi.mock("@multiplatform.one/forms", async (importOriginal) => ({
195
+ ...(await importOriginal<typeof import("@multiplatform.one/forms")>()),
192
196
  Checkbox: createMock("Checkbox"),
193
197
  Switch: createMock("Switch"),
194
198
  Select: MockSelect,
@@ -231,7 +235,11 @@ vi.mock("@multiplatform.one/rich-text", () => ({
231
235
  RichTextEditor: createMock("RichTextEditor"),
232
236
  }));
233
237
 
234
- vi.mock("@multiplatform.one/theme", () => ({
238
+ // Partial mock (importOriginal): only the knobs hook is stubbed — real
239
+ // module-scope exports (MIN_PRESS_TARGET, press-target helpers, formatters)
240
+ // keep resolving for every component this spec transitively imports.
241
+ vi.mock("@multiplatform.one/theme", async (importOriginal) => ({
242
+ ...(await importOriginal<typeof import("@multiplatform.one/theme")>()),
235
243
  useResolvedKnobs: () => ({
236
244
  knobProps: {
237
245
  sizeToken: "$3",
@@ -30,7 +30,11 @@ function createMock(name: string) {
30
30
  return Comp;
31
31
  }
32
32
 
33
- vi.mock("@multiplatform.one/forms", () => ({
33
+ // Partial mock: real module first (module-scope exports like `zIndex` keep
34
+ // resolving for transitive imports), field components faked on top — the
35
+ // spec asserts ROUTING to fields, not the fields themselves.
36
+ vi.mock("@multiplatform.one/forms", async (importOriginal) => ({
37
+ ...(await importOriginal<typeof import("@multiplatform.one/forms")>()),
34
38
  Checkbox: createMock("Checkbox"),
35
39
  Switch: createMock("Switch"),
36
40
  Select: createMock("Select"),
@@ -94,7 +98,11 @@ vi.mock("@multiplatform.one/rich-text", () => ({
94
98
  RichTextEditor: createMock("RichTextEditor"),
95
99
  }));
96
100
 
97
- vi.mock("@multiplatform.one/theme", () => ({
101
+ // Partial mock (importOriginal): only the knobs hook is stubbed — real
102
+ // module-scope exports (MIN_PRESS_TARGET, press-target helpers, formatters)
103
+ // keep resolving for every component this spec transitively imports.
104
+ vi.mock("@multiplatform.one/theme", async (importOriginal) => ({
105
+ ...(await importOriginal<typeof import("@multiplatform.one/theme")>()),
98
106
  useResolvedKnobs: () => ({
99
107
  knobProps: {
100
108
  sizeToken: "$3",
@@ -175,19 +175,16 @@ export function DataTableCell({
175
175
  // large tables performant and gives heavy fields a purpose-built compact
176
176
  // representation. Edit-mode (below) still mounts the full field component.
177
177
  if (readOnly) {
178
- // FieldDisplay formats currency via Intl.NumberFormat, which requires an
179
- // ISO-4217 code — a raw symbol ("$") throws RangeError and crashes the
180
- // whole table. Normalize symbols to codes and drop anything still invalid.
181
- const displayFieldProps =
182
- fieldProps.currency != null
183
- ? { ...fieldProps, currency: normalizeCurrencyCode(fieldProps.currency) }
184
- : fieldProps;
178
+ // Currency symbols ride through untouched: the money register in
179
+ // `@multiplatform.one/theme` owns symbol→ISO-code resolution (LC-12 one
180
+ // channel per data class) and prefixes a symbol it has no code for, so a
181
+ // cell never has to sanitize a value on the way to the formatter.
185
182
  return (
186
183
  <CellWrapper justify={cellJustify}>
187
184
  <FieldDisplay
188
185
  field={field}
189
186
  value={value}
190
- fieldProps={displayFieldProps}
187
+ fieldProps={fieldProps}
191
188
  linkedValue={_linkedValue}
192
189
  />
193
190
  </CellWrapper>
@@ -1182,34 +1179,6 @@ function DraftFieldEditor({
1182
1179
  );
1183
1180
  }
1184
1181
 
1185
- const CURRENCY_SYMBOL_TO_CODE: Record<string, string> = {
1186
- $: "USD",
1187
- "€": "EUR",
1188
- "£": "GBP",
1189
- "¥": "JPY",
1190
- "₹": "INR",
1191
- "₩": "KRW",
1192
- "₽": "RUB",
1193
- R$: "BRL",
1194
- };
1195
-
1196
- const currencyCodeCache = new Map<string, string | undefined>();
1197
-
1198
- function normalizeCurrencyCode(currency: unknown): string | undefined {
1199
- if (typeof currency !== "string" || !currency) return undefined;
1200
- if (currencyCodeCache.has(currency)) return currencyCodeCache.get(currency);
1201
- const mapped = CURRENCY_SYMBOL_TO_CODE[currency] ?? currency;
1202
- let resolved: string | undefined;
1203
- try {
1204
- new Intl.NumberFormat(undefined, { style: "currency", currency: mapped });
1205
- resolved = mapped;
1206
- } catch {
1207
- resolved = undefined;
1208
- }
1209
- currencyCodeCache.set(currency, resolved);
1210
- return resolved;
1211
- }
1212
-
1213
1182
  function CellWrapper({
1214
1183
  children,
1215
1184
  justify = "flex-start",