@multiplatform.one/table-primitives 6.0.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 (40) hide show
  1. package/LICENSE +201 -0
  2. package/dist/cjs/index.cjs +416 -0
  3. package/dist/cjs/index.native.js +462 -0
  4. package/dist/cjs/index.native.js.map +1 -0
  5. package/dist/cjs/table.spec.cjs +183 -0
  6. package/dist/cjs/table.spec.native.js +186 -0
  7. package/dist/cjs/table.spec.native.js.map +1 -0
  8. package/dist/cjs/tableCellContext.cjs +45 -0
  9. package/dist/cjs/tableCellContext.native.js +48 -0
  10. package/dist/cjs/tableCellContext.native.js.map +1 -0
  11. package/dist/esm/index.mjs +388 -0
  12. package/dist/esm/index.mjs.map +1 -0
  13. package/dist/esm/index.native.js +431 -0
  14. package/dist/esm/index.native.js.map +1 -0
  15. package/dist/esm/table.spec.mjs +184 -0
  16. package/dist/esm/table.spec.mjs.map +1 -0
  17. package/dist/esm/table.spec.native.js +184 -0
  18. package/dist/esm/table.spec.native.js.map +1 -0
  19. package/dist/esm/tableCellContext.mjs +18 -0
  20. package/dist/esm/tableCellContext.mjs.map +1 -0
  21. package/dist/esm/tableCellContext.native.js +18 -0
  22. package/dist/esm/tableCellContext.native.js.map +1 -0
  23. package/dist/jsx/index.js +388 -0
  24. package/dist/jsx/index.js.map +1 -0
  25. package/dist/jsx/index.mjs +388 -0
  26. package/dist/jsx/index.mjs.map +1 -0
  27. package/dist/jsx/index.native.js +462 -0
  28. package/dist/jsx/index.native.js.map +1 -0
  29. package/dist/jsx/table.spec.mjs +184 -0
  30. package/dist/jsx/table.spec.mjs.map +1 -0
  31. package/dist/jsx/table.spec.native.js +186 -0
  32. package/dist/jsx/table.spec.native.js.map +1 -0
  33. package/dist/jsx/tableCellContext.mjs +18 -0
  34. package/dist/jsx/tableCellContext.mjs.map +1 -0
  35. package/dist/jsx/tableCellContext.native.js +48 -0
  36. package/dist/jsx/tableCellContext.native.js.map +1 -0
  37. package/package.json +78 -0
  38. package/src/index.tsx +364 -0
  39. package/src/table.spec.tsx +177 -0
  40. package/src/tableCellContext.ts +34 -0
package/src/index.tsx ADDED
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Table primitives — styled semantic table components.
3
+ *
4
+ * Layer 1 only: depends on theme + Tamagui (no forms, no data-table).
5
+ * Consumed by @multiplatform.one/table, @multiplatform.one/forms, etc.
6
+ */
7
+
8
+ import { useResolvedKnobs } from "@multiplatform.one/theme";
9
+ import { TableCellContext } from "./tableCellContext";
10
+ import type { SizeTokens } from "tamagui";
11
+ import { Theme, ThemeableStack, createStyledContext, styled, withStaticProperties } from "tamagui";
12
+
13
+ export { TableCellContext, useIsInTableCell, useTableCellContext } from "./tableCellContext";
14
+ export type { TableCellContextValue } from "./tableCellContext";
15
+
16
+ type AlignCells = {
17
+ y: "center" | "start" | "end";
18
+ x: "center" | "start" | "end";
19
+ };
20
+
21
+ type AlignHeaderCells = AlignCells;
22
+
23
+ const TableContext = createStyledContext<{
24
+ cellWidth: SizeTokens | number;
25
+ cellHeight: SizeTokens | number;
26
+ alignHeaderCells: {
27
+ y: "center" | "start" | "end";
28
+ x: "center" | "start" | "end";
29
+ };
30
+ alignCells: {
31
+ y: "center" | "start" | "end";
32
+ x: "center" | "start" | "end";
33
+ };
34
+ borderColor: string;
35
+ displayMode: "table" | "flex";
36
+ }>({
37
+ cellWidth: "$8",
38
+ cellHeight: "$8",
39
+ alignHeaderCells: { x: "start", y: "center" },
40
+ alignCells: { x: "start", y: "center" },
41
+ borderColor: "$color6",
42
+ displayMode: "table",
43
+ });
44
+
45
+ /** Table Components */
46
+ const Row = styled(ThemeableStack, {
47
+ name: "TableRow",
48
+ ...({ tag: "tr" } as { tag: string }),
49
+ role: "row",
50
+ context: TableContext,
51
+ display: "table-row" as any,
52
+ variants: {
53
+ displayMode: {
54
+ table: { display: "table-row" as any },
55
+ flex: {
56
+ display: "flex" as any,
57
+ flexDirection: "row" as any,
58
+ width: "100%",
59
+ alignItems: "center",
60
+ },
61
+ },
62
+ rowLocation: {
63
+ first: () => ({}),
64
+ last: () => ({}),
65
+ middle: () => ({}),
66
+ },
67
+ } as const,
68
+ });
69
+
70
+ const Cell = styled(ThemeableStack, {
71
+ name: "TableCell",
72
+ ...({ tag: "td" } as { tag: string }),
73
+ context: TableContext,
74
+ display: "table-cell" as any,
75
+ // verticalAlign goes via inline style; Tamagui forwards it to the DOM as a JSX
76
+ // prop otherwise, which React rejects ("React does not recognize verticalAlign").
77
+ style: { verticalAlign: "middle" },
78
+ alignItems: "center",
79
+ justifyContent: "flex-start",
80
+ paddingHorizontal: "$2",
81
+ paddingVertical: "$1",
82
+ overflow: "hidden",
83
+ variants: {
84
+ displayMode: {
85
+ table: { display: "table-cell" as any, style: { verticalAlign: "middle" } },
86
+ flex: { display: "flex" as any, overflow: "visible" as any },
87
+ },
88
+ cellWidth: {
89
+ /** Equal flex for consistency across rows (flex rows size independently) */
90
+ auto: () => ({
91
+ flex: 1,
92
+ }),
93
+ ":number": (val) => ({
94
+ width: val,
95
+ minWidth: val,
96
+ }),
97
+ "...size": (name, { tokens }) => ({
98
+ width: tokens.size[name],
99
+ minWidth: tokens.size[name],
100
+ }),
101
+ },
102
+ cellHeight: {
103
+ "...size": (name, { tokens }) => {
104
+ return {
105
+ minHeight: tokens.size[name],
106
+ };
107
+ },
108
+ },
109
+ alignCells: (val: AlignCells) => {
110
+ return {
111
+ alignItems: val.y === "center" ? "center" : `flex-${val.y}`,
112
+ justifyContent: val.x === "center" ? "center" : `flex-${val.x}`,
113
+ };
114
+ },
115
+ cellLocation: {
116
+ first: () => ({}),
117
+ last: () => ({}),
118
+ middle: () => ({}),
119
+ },
120
+ } as const,
121
+ });
122
+
123
+ const HeaderCell = styled(ThemeableStack, {
124
+ name: "TableHeaderCell",
125
+ ...({ tag: "th", scope: "col" } as { tag: string; scope: string }),
126
+ context: TableContext,
127
+ display: "table-cell" as any,
128
+ // verticalAlign goes via inline style; see TableCell above for context.
129
+ style: { verticalAlign: "middle" },
130
+ alignItems: "center",
131
+ justifyContent: "flex-start",
132
+ paddingHorizontal: "$2",
133
+ paddingVertical: "$1",
134
+ backgroundColor: "$color3",
135
+
136
+ variants: {
137
+ displayMode: {
138
+ table: { display: "table-cell" as any, style: { verticalAlign: "middle" } },
139
+ flex: { display: "flex" as any },
140
+ },
141
+ cellWidth: {
142
+ /** Equal flex for consistency across rows */
143
+ auto: () => ({
144
+ flex: 1,
145
+ }),
146
+ ":number": (val) => ({
147
+ width: val,
148
+ minWidth: val,
149
+ }),
150
+ "...size": (name, { tokens }) => ({
151
+ width: tokens.size[name],
152
+ minWidth: tokens.size[name],
153
+ }),
154
+ },
155
+ alignHeaderCells: (val: AlignHeaderCells) => {
156
+ return {
157
+ alignItems: val.y === "center" ? "center" : `flex-${val.y}`,
158
+ justifyContent: val.x === "center" ? "center" : `flex-${val.x}`,
159
+ };
160
+ },
161
+ cellHeight: {
162
+ "...size": (name, { tokens }) => ({
163
+ minHeight: tokens.size[name],
164
+ }),
165
+ },
166
+ cellLocation: {
167
+ first: () => ({}),
168
+ last: () => ({}),
169
+ middle: () => ({}),
170
+ },
171
+ } as const,
172
+ });
173
+
174
+ const TableBody = styled(ThemeableStack, {
175
+ name: "TableBody",
176
+ ...({ tag: "tbody" } as { tag: string }),
177
+ context: TableContext,
178
+ display: "table-row-group" as any,
179
+ variants: {
180
+ displayMode: {
181
+ table: { display: "table-row-group" as any },
182
+ flex: {
183
+ display: "flex" as any,
184
+ flexDirection: "column" as any,
185
+ width: "100%",
186
+ overflowY: "auto" as any,
187
+ flex: 1,
188
+ minHeight: 0,
189
+ },
190
+ },
191
+ } as const,
192
+ });
193
+
194
+ const TableHead = styled(ThemeableStack, {
195
+ name: "TableHead",
196
+ ...({ tag: "thead" } as { tag: string }),
197
+ context: TableContext,
198
+ display: "table-header-group" as any,
199
+ borderBottomWidth: 1,
200
+ borderBottomColor: "$color6",
201
+ variants: {
202
+ displayMode: {
203
+ table: { display: "table-header-group" as any },
204
+ flex: {
205
+ display: "flex" as any,
206
+ flexDirection: "column" as any,
207
+ width: "100%",
208
+ flexShrink: 0,
209
+ },
210
+ },
211
+ } as const,
212
+ });
213
+
214
+ const TableFoot = styled(ThemeableStack, {
215
+ name: "TableFoot",
216
+ ...({ tag: "tfoot" } as { tag: string }),
217
+ context: TableContext,
218
+ display: "table-footer-group" as any,
219
+ variants: {
220
+ displayMode: {
221
+ table: { display: "table-footer-group" as any },
222
+ flex: {
223
+ display: "flex" as any,
224
+ flexDirection: "column" as any,
225
+ width: "100%",
226
+ flexShrink: 0,
227
+ },
228
+ },
229
+ } as const,
230
+ });
231
+
232
+ const TableCaption = styled(ThemeableStack, {
233
+ name: "TableCaption",
234
+ ...({ tag: "caption" } as { tag: string }),
235
+ context: TableContext,
236
+ display: "table-caption" as any,
237
+ paddingVertical: "$2",
238
+ paddingHorizontal: "$2",
239
+ });
240
+
241
+ const TableComp = styled(ThemeableStack, {
242
+ name: "Table",
243
+ ...({ tag: "table" } as { tag: string }),
244
+ context: TableContext,
245
+ display: "table" as any,
246
+ borderWidth: 1,
247
+ borderColor: "$color6",
248
+ backgroundColor: "$background",
249
+ overflow: "hidden",
250
+ width: "100%",
251
+ variants: {
252
+ displayMode: {
253
+ table: { display: "table" as any },
254
+ flex: {
255
+ display: "flex" as any,
256
+ flexDirection: "column" as any,
257
+ flex: 1,
258
+ minHeight: 0,
259
+ },
260
+ },
261
+ /** Empty variants to satisfy context-driven props without ts errors on Table */
262
+ cellWidth: {
263
+ "...size": () => {
264
+ return {};
265
+ },
266
+ },
267
+ cellHeight: {
268
+ "...size": () => {
269
+ return {};
270
+ },
271
+ },
272
+ alignHeaderCells: (_val) => ({}),
273
+ alignCells: (_val) => ({}),
274
+ } as const,
275
+ });
276
+
277
+ /** Intent type for Table.Row */
278
+ type IntentName = "accent" | "error" | "warning" | "success";
279
+
280
+ export interface ThemedRowProps extends React.ComponentProps<typeof Row> {
281
+ accent?: boolean;
282
+ error?: boolean;
283
+ warning?: boolean;
284
+ success?: boolean;
285
+ }
286
+
287
+ function resolveIntent(props: ThemedRowProps): IntentName | undefined {
288
+ if (props.accent) return "accent";
289
+ if (props.error) return "error";
290
+ if (props.warning) return "warning";
291
+ if (props.success) return "success";
292
+ return undefined;
293
+ }
294
+
295
+ // Themed wrappers that inject resolved knob recipes
296
+ function ThemedTableComp(props: React.ComponentProps<typeof TableComp>) {
297
+ const { knobProps } = useResolvedKnobs();
298
+ const baseBorderWidth = knobProps.borderRadius.borderWidth;
299
+ // When outlined, ensure at least 1px border. When filled, use base or 0.
300
+ const resolvedBorderWidth = knobProps.outlined ? Math.max(baseBorderWidth, 1) : baseBorderWidth;
301
+
302
+ return (
303
+ <TableComp
304
+ {...knobProps.borderRadius}
305
+ borderWidth={resolvedBorderWidth}
306
+ borderColor="$color6"
307
+ backgroundColor="$background"
308
+ elevation={knobProps.elevation}
309
+ {...props}
310
+ style={[{ tableLayout: "fixed" as const }, props.style]}
311
+ />
312
+ );
313
+ }
314
+
315
+ function ThemedRow({ accent, error, warning, success, ...props }: ThemedRowProps) {
316
+ const intent = resolveIntent({ accent, error, warning, success } as ThemedRowProps);
317
+
318
+ const row = <Row {...(intent ? { backgroundColor: "$color3" } : {})} {...props} />;
319
+
320
+ if (intent) {
321
+ return <Theme name={intent}>{row}</Theme>;
322
+ }
323
+ return row;
324
+ }
325
+
326
+ function ThemedCell({ children, ...props }: React.ComponentProps<typeof Cell>) {
327
+ return (
328
+ <Cell {...props}>
329
+ <TableCellContext.Provider value={{ inTableCell: true, isHeader: false }}>
330
+ {children}
331
+ </TableCellContext.Provider>
332
+ </Cell>
333
+ );
334
+ }
335
+
336
+ function ThemedHeaderCell({ children, ...props }: React.ComponentProps<typeof HeaderCell>) {
337
+ return (
338
+ <HeaderCell {...props}>
339
+ <TableCellContext.Provider value={{ inTableCell: true, isHeader: true }}>
340
+ {children}
341
+ </TableCellContext.Provider>
342
+ </HeaderCell>
343
+ );
344
+ }
345
+
346
+ export const Table = withStaticProperties(ThemedTableComp, {
347
+ Head: TableHead,
348
+ Body: TableBody,
349
+ Row: ThemedRow,
350
+ Cell: ThemedCell,
351
+ HeaderCell: ThemedHeaderCell,
352
+ Foot: TableFoot,
353
+ Caption: TableCaption,
354
+ });
355
+
356
+ // Export prop types for all styled components
357
+ export type TableProps = React.ComponentProps<typeof TableComp>;
358
+ export type TableRowProps = ThemedRowProps;
359
+ export type TableCellProps = React.ComponentProps<typeof Cell>;
360
+ export type TableHeaderCellProps = React.ComponentProps<typeof HeaderCell>;
361
+ export type TableBodyProps = React.ComponentProps<typeof TableBody>;
362
+ export type TableHeadProps = React.ComponentProps<typeof TableHead>;
363
+ export type TableFootProps = React.ComponentProps<typeof TableFoot>;
364
+ export type TableCaptionProps = React.ComponentProps<typeof TableCaption>;
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Table primitives tests - 5 focused tests for Task Group 1.
3
+ *
4
+ * Note: Tamagui + React Native Web renders elements with `tag="table"` etc.
5
+ */
6
+
7
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
8
+ import { describe, expect, it } from "vitest";
9
+ import { Table } from "./index";
10
+
11
+ describe("Table Primitives", () => {
12
+ it("renders semantic HTML structure via tag attributes (table, thead, tbody, tfoot, tr, td, th, caption)", () => {
13
+ const { container } = renderWithProviders(
14
+ <Table>
15
+ <Table.Caption>Test Caption</Table.Caption>
16
+ <Table.Head>
17
+ <Table.Row>
18
+ <Table.HeaderCell>Header</Table.HeaderCell>
19
+ </Table.Row>
20
+ </Table.Head>
21
+ <Table.Body>
22
+ <Table.Row>
23
+ <Table.Cell>Cell</Table.Cell>
24
+ </Table.Row>
25
+ </Table.Body>
26
+ <Table.Foot>
27
+ <Table.Row>
28
+ <Table.Cell>Footer</Table.Cell>
29
+ </Table.Row>
30
+ </Table.Foot>
31
+ </Table>,
32
+ );
33
+
34
+ expect(container.querySelector('[tag="table"]')).not.toBeNull();
35
+ expect(container.querySelector('[tag="thead"]')).not.toBeNull();
36
+ expect(container.querySelector('[tag="tbody"]')).not.toBeNull();
37
+ expect(container.querySelector('[tag="tfoot"]')).not.toBeNull();
38
+ expect(container.querySelectorAll('[tag="tr"]').length).toBe(3);
39
+ expect(container.querySelector('[tag="td"]')).not.toBeNull();
40
+ expect(container.querySelector('[tag="th"]')).not.toBeNull();
41
+ expect(container.querySelector('[tag="caption"]')).not.toBeNull();
42
+
43
+ expect(container.querySelector(".is_Table")).not.toBeNull();
44
+ expect(container.querySelector(".is_TableHead")).not.toBeNull();
45
+ expect(container.querySelector(".is_TableBody")).not.toBeNull();
46
+ expect(container.querySelector(".is_TableFoot")).not.toBeNull();
47
+ expect(container.querySelector(".is_TableRow")).not.toBeNull();
48
+ expect(container.querySelector(".is_TableCell")).not.toBeNull();
49
+ expect(container.querySelector(".is_TableHeaderCell")).not.toBeNull();
50
+ expect(container.querySelector(".is_TableCaption")).not.toBeNull();
51
+ });
52
+
53
+ it("applies intent props on Table.Row with Theme wrapping", () => {
54
+ const { container: withIntent } = renderWithProviders(
55
+ <Table>
56
+ <Table.Body>
57
+ <Table.Row accent>
58
+ <Table.Cell>Accent row</Table.Cell>
59
+ </Table.Row>
60
+ <Table.Row error>
61
+ <Table.Cell>Error row</Table.Cell>
62
+ </Table.Row>
63
+ <Table.Row warning>
64
+ <Table.Cell>Warning row</Table.Cell>
65
+ </Table.Row>
66
+ <Table.Row success>
67
+ <Table.Cell>Success row</Table.Cell>
68
+ </Table.Row>
69
+ </Table.Body>
70
+ </Table>,
71
+ );
72
+
73
+ const { container: withoutIntent } = renderWithProviders(
74
+ <Table>
75
+ <Table.Body>
76
+ <Table.Row>
77
+ <Table.Cell>Normal row</Table.Cell>
78
+ </Table.Row>
79
+ </Table.Body>
80
+ </Table>,
81
+ );
82
+
83
+ const rows = withIntent.querySelectorAll('[tag="tr"]');
84
+ expect(rows.length).toBe(4);
85
+
86
+ expect(withIntent.textContent).toContain("Accent row");
87
+ expect(withIntent.textContent).toContain("Error row");
88
+ expect(withIntent.textContent).toContain("Warning row");
89
+ expect(withIntent.textContent).toContain("Success row");
90
+
91
+ const intentThemeWrappers = withIntent.querySelectorAll(".is_Theme");
92
+ const normalThemeWrappers = withoutIntent.querySelectorAll(".is_Theme");
93
+
94
+ expect(intentThemeWrappers.length).toBeGreaterThan(normalThemeWrappers.length);
95
+ });
96
+
97
+ it("spreads knobProps from useResolvedKnobs on Table root", () => {
98
+ const { container } = renderWithProviders(
99
+ <Table>
100
+ <Table.Body>
101
+ <Table.Row>
102
+ <Table.Cell>Content</Table.Cell>
103
+ </Table.Row>
104
+ </Table.Body>
105
+ </Table>,
106
+ );
107
+
108
+ const table = container.querySelector('[tag="table"]');
109
+ expect(table).not.toBeNull();
110
+ expect(table?.textContent).toContain("Content");
111
+
112
+ expect(table?.classList.contains("is_Table")).toBe(true);
113
+ });
114
+
115
+ it("renders Table.Caption as a caption element", () => {
116
+ const { container } = renderWithProviders(
117
+ <Table>
118
+ <Table.Caption>Monthly Sales Report</Table.Caption>
119
+ <Table.Body>
120
+ <Table.Row>
121
+ <Table.Cell>Data</Table.Cell>
122
+ </Table.Row>
123
+ </Table.Body>
124
+ </Table>,
125
+ );
126
+
127
+ const caption = container.querySelector('[tag="caption"]');
128
+ expect(caption).not.toBeNull();
129
+ expect(caption?.textContent).toContain("Monthly Sales Report");
130
+ expect(caption?.classList.contains("is_TableCaption")).toBe(true);
131
+
132
+ const table = container.querySelector('[tag="table"]');
133
+ expect(table?.querySelector('[tag="caption"]')).not.toBeNull();
134
+ });
135
+
136
+ it("exposes compound component API (Head, Body, Row, Cell, HeaderCell, Foot, Caption)", () => {
137
+ expect(Table.Head).toBeDefined();
138
+ expect(Table.Body).toBeDefined();
139
+ expect(Table.Row).toBeDefined();
140
+ expect(Table.Cell).toBeDefined();
141
+ expect(Table.HeaderCell).toBeDefined();
142
+ expect(Table.Foot).toBeDefined();
143
+ expect(Table.Caption).toBeDefined();
144
+
145
+ const { getByText } = renderWithProviders(
146
+ <Table>
147
+ <Table.Caption>Caption</Table.Caption>
148
+ <Table.Head>
149
+ <Table.Row>
150
+ <Table.HeaderCell>H1</Table.HeaderCell>
151
+ <Table.HeaderCell>H2</Table.HeaderCell>
152
+ </Table.Row>
153
+ </Table.Head>
154
+ <Table.Body>
155
+ <Table.Row>
156
+ <Table.Cell>A1</Table.Cell>
157
+ <Table.Cell>A2</Table.Cell>
158
+ </Table.Row>
159
+ </Table.Body>
160
+ <Table.Foot>
161
+ <Table.Row>
162
+ <Table.Cell>F1</Table.Cell>
163
+ <Table.Cell>F2</Table.Cell>
164
+ </Table.Row>
165
+ </Table.Foot>
166
+ </Table>,
167
+ );
168
+
169
+ expect(getByText("Caption")).toBeDefined();
170
+ expect(getByText("H1")).toBeDefined();
171
+ expect(getByText("H2")).toBeDefined();
172
+ expect(getByText("A1")).toBeDefined();
173
+ expect(getByText("A2")).toBeDefined();
174
+ expect(getByText("F1")).toBeDefined();
175
+ expect(getByText("F2")).toBeDefined();
176
+ });
177
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * TableCellContext — shared between table-primitives, @multiplatform.one/table, and forms.
3
+ *
4
+ * Forms re-exports this context so field components can detect table cells
5
+ * and use chromeless rendering.
6
+ */
7
+
8
+ import { createContext, useContext } from "react";
9
+
10
+ export interface TableCellContextValue {
11
+ /** Whether the component is rendered inside a table cell */
12
+ inTableCell: boolean;
13
+ /** Whether the cell is in a header row */
14
+ isHeader?: boolean;
15
+ /** Whether the cell is editable */
16
+ editable?: boolean;
17
+ }
18
+
19
+ const defaultValue: TableCellContextValue = {
20
+ inTableCell: false,
21
+ isHeader: false,
22
+ editable: false,
23
+ };
24
+
25
+ export const TableCellContext = createContext<TableCellContextValue>(defaultValue);
26
+
27
+ export function useTableCellContext(): TableCellContextValue {
28
+ return useContext(TableCellContext);
29
+ }
30
+
31
+ export function useIsInTableCell(): boolean {
32
+ const { inTableCell } = useTableCellContext();
33
+ return inTableCell;
34
+ }