@multiplatform.one/table-primitives 6.0.4 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/table-primitives",
3
- "version": "6.0.4",
3
+ "version": "6.3.0",
4
4
  "description": "Semantic Table primitives and cell context (no forms or data-table)",
5
5
  "keywords": [
6
6
  "multiplatform",
@@ -12,34 +12,37 @@
12
12
  "author": "BitSpur <support@risserlabs.com> (https://risserlabs.com)",
13
13
  "repository": {
14
14
  "type": "git",
15
- "url": "https://gitlab.com/bitspur/multiplatform.one/multiplatform.one.git"
15
+ "url": "git+https://gitlab.com/bitspur/frappe/multiplatform.one.git",
16
+ "directory": "public/table-primitives"
16
17
  },
17
18
  "source": "src/index.tsx",
18
19
  "files": [
19
20
  "dist",
20
21
  "src",
21
- "types"
22
+ "types",
23
+ "!types/.tsbuildinfo"
22
24
  ],
23
25
  "sideEffects": false,
24
26
  "main": "src/index.tsx",
25
27
  "module": "src/index.tsx",
26
- "types": "src/index.tsx",
28
+ "types": "types/index.d.ts",
27
29
  "exports": {
28
30
  "./package.json": "./package.json",
29
31
  ".": {
30
32
  "source": "./src/index.tsx",
31
33
  "react-native": {
34
+ "types": "./types/index.d.ts",
32
35
  "import": "./dist/esm/index.native.js",
33
36
  "require": "./dist/cjs/index.native.js"
34
37
  },
35
- "types": "./src/index.tsx",
38
+ "types": "./types/index.d.ts",
36
39
  "import": "./dist/esm/index.mjs",
37
40
  "require": "./dist/cjs/index.cjs",
38
41
  "default": "./dist/cjs/index.cjs"
39
42
  },
40
43
  "./tableCellContext": {
41
44
  "source": "./src/tableCellContext.ts",
42
- "types": "./src/tableCellContext.ts",
45
+ "types": "./types/tableCellContext.d.ts",
43
46
  "import": "./src/tableCellContext.ts",
44
47
  "require": "./src/tableCellContext.ts",
45
48
  "default": "./src/tableCellContext.ts"
@@ -50,7 +53,7 @@
50
53
  },
51
54
  "dependencies": {
52
55
  "tamagui": "2.0.0-rc.41",
53
- "@multiplatform.one/theme": "6.0.4"
56
+ "@multiplatform.one/theme": "6.3.0"
54
57
  },
55
58
  "devDependencies": {
56
59
  "@tamagui/build": "2.0.0-rc.41",
@@ -62,8 +65,8 @@
62
65
  "react": "19.2.5",
63
66
  "react-dom": "19.2.5",
64
67
  "vitest": "^4.1.5",
65
- "@multiplatform.one/config": "6.0.4",
66
- "@multiplatform.one/test-utils": "6.0.4"
68
+ "@multiplatform.one/test-utils": "6.3.0",
69
+ "@multiplatform.one/config": "6.3.0"
67
70
  },
68
71
  "peerDependencies": {
69
72
  "react": "^19.1.0",
@@ -71,7 +74,7 @@
71
74
  },
72
75
  "module:jsx": "dist/jsx",
73
76
  "scripts": {
74
- "build": "rm -rf dist types 2>/dev/null && tamagui-build --ts-project ./tsconfig.build.json",
77
+ "build": "rm -rf dist types 2>/dev/null && tamagui-build --ts-project ./tsconfig.build.json --exclude '\\.spec\\.|\\.test\\.|\\.stories\\.'",
75
78
  "test": "vitest run --coverage --coverage.provider=v8 --coverage.reporter=text --coverage.reporter=lcov --coverage.reporter=html",
76
79
  "typecheck": "tsc --noEmit"
77
80
  }
package/src/index.tsx CHANGED
@@ -5,10 +5,20 @@
5
5
  * Consumed by @multiplatform.one/table, @multiplatform.one/forms, etc.
6
6
  */
7
7
 
8
- import { useResolvedKnobs } from "@multiplatform.one/theme";
8
+ import { hairline, useResolvedKnobs } from "@multiplatform.one/theme";
9
9
  import { TableCellContext } from "./tableCellContext";
10
- import type { SizeTokens } from "tamagui";
11
- import { Theme, ThemeableStack, createStyledContext, styled, withStaticProperties } from "tamagui";
10
+ import { Children, Fragment, isValidElement } from "react";
11
+ import type { FontSizeTokens, SizeTokens } from "tamagui";
12
+ import {
13
+ SizableText,
14
+ Theme,
15
+ ThemeableStack,
16
+ createStyledContext,
17
+ isWeb,
18
+ styled,
19
+ useMedia,
20
+ withStaticProperties,
21
+ } from "tamagui";
12
22
 
13
23
  export { TableCellContext, useIsInTableCell, useTableCellContext } from "./tableCellContext";
14
24
  export type { TableCellContextValue } from "./tableCellContext";
@@ -39,13 +49,28 @@ const TableContext = createStyledContext<{
39
49
  alignHeaderCells: { x: "start", y: "center" },
40
50
  alignCells: { x: "start", y: "center" },
41
51
  borderColor: "$color6",
42
- displayMode: "table",
52
+ // CSS table display values ("table-row", "table-cell", …) are no-ops in
53
+ // React Native — cells stack vertically and bare Tables are unreadable.
54
+ // Native defaults to the flex composition; web keeps semantic table layout.
55
+ displayMode: isWeb ? "table" : "flex",
43
56
  });
44
57
 
58
+ /**
59
+ * Element rendering: tamagui 2.0.0-rc derives the DOM element from the
60
+ * `render` prop (`tag:` passes through as a plain attribute), so every
61
+ * primitive sets `render:` to emit real `<table>/<thead>/<tbody>/<tfoot>/
62
+ * <tr>/<th>/<td>/<caption>` elements on web. On native `render` is inert
63
+ * (skipProps) and the components stay RN Views in the flex displayMode.
64
+ *
65
+ * Explicit ARIA roles stay on every element: consumers restyle these
66
+ * elements (`displayMode="flex"`, sticky cells), and browsers drop the
67
+ * implicit table roles when a table element's display is overridden.
68
+ */
69
+
45
70
  /** Table Components */
46
71
  const Row = styled(ThemeableStack, {
47
72
  name: "TableRow",
48
- ...({ tag: "tr" } as { tag: string }),
73
+ ...({ render: "tr" } as { render?: string }),
49
74
  role: "row",
50
75
  context: TableContext,
51
76
  display: "table-row" as any,
@@ -69,16 +94,23 @@ const Row = styled(ThemeableStack, {
69
94
 
70
95
  const Cell = styled(ThemeableStack, {
71
96
  name: "TableCell",
72
- ...({ tag: "td" } as { tag: string }),
97
+ ...({ render: "td" } as { render?: string }),
98
+ role: "cell",
73
99
  context: TableContext,
74
100
  display: "table-cell" as any,
75
101
  // verticalAlign goes via inline style; Tamagui forwards it to the DOM as a JSX
76
102
  // prop otherwise, which React rejects ("React does not recognize verticalAlign").
77
103
  style: { verticalAlign: "middle" },
104
+ // LC-44 ROW-LINE: cells are ROW-flex so each axis does what it says —
105
+ // justifyContent is horizontal (start by default; align-by-type overrides
106
+ // land here) and alignItems is vertical (center). The previous column
107
+ // direction put alignItems:center on the HORIZONTAL axis, centering every
108
+ // cell's text and making variable-width columns ragged (A16-02).
109
+ flexDirection: "row",
78
110
  alignItems: "center",
79
111
  justifyContent: "flex-start",
80
112
  paddingHorizontal: "$2",
81
- paddingVertical: "$1",
113
+ paddingVertical: "$3",
82
114
  overflow: "hidden",
83
115
  variants: {
84
116
  displayMode: {
@@ -106,6 +138,8 @@ const Cell = styled(ThemeableStack, {
106
138
  };
107
139
  },
108
140
  },
141
+ // Row-flex axis map (LC-44): y → alignItems (vertical), x →
142
+ // justifyContent (horizontal).
109
143
  alignCells: (val: AlignCells) => {
110
144
  return {
111
145
  alignItems: val.y === "center" ? "center" : `flex-${val.y}`,
@@ -122,20 +156,30 @@ const Cell = styled(ThemeableStack, {
122
156
 
123
157
  const HeaderCell = styled(ThemeableStack, {
124
158
  name: "TableHeaderCell",
125
- ...({ tag: "th", scope: "col" } as { tag: string; scope: string }),
159
+ ...({ render: "th", scope: "col" } as { render?: string; scope: string }),
160
+ role: "columnheader",
126
161
  context: TableContext,
127
162
  display: "table-cell" as any,
128
163
  // verticalAlign goes via inline style; see TableCell above for context.
129
- style: { verticalAlign: "middle" },
164
+ // fontWeight/textAlign neutralize the UA styles a real <th> brings
165
+ // (bold + centered) so header content keeps its pre-migration look;
166
+ // logical `start` stays RTL-correct.
167
+ style: { verticalAlign: "middle", fontWeight: "normal", textAlign: "start" },
168
+ // LC-44 ROW-LINE: row-flex like TableCell — header labels start-align on
169
+ // the same column line as body cell text.
170
+ flexDirection: "row",
130
171
  alignItems: "center",
131
172
  justifyContent: "flex-start",
132
173
  paddingHorizontal: "$2",
133
- paddingVertical: "$1",
134
- backgroundColor: "$color3",
174
+ paddingVertical: "$3",
175
+ backgroundColor: "$color2",
135
176
 
136
177
  variants: {
137
178
  displayMode: {
138
- table: { display: "table-cell" as any, style: { verticalAlign: "middle" } },
179
+ table: {
180
+ display: "table-cell" as any,
181
+ style: { verticalAlign: "middle", fontWeight: "normal", textAlign: "start" },
182
+ },
139
183
  flex: { display: "flex" as any },
140
184
  },
141
185
  cellWidth: {
@@ -152,6 +196,8 @@ const HeaderCell = styled(ThemeableStack, {
152
196
  minWidth: tokens.size[name],
153
197
  }),
154
198
  },
199
+ // Row-flex axis map (LC-44): y → alignItems (vertical), x →
200
+ // justifyContent (horizontal).
155
201
  alignHeaderCells: (val: AlignHeaderCells) => {
156
202
  return {
157
203
  alignItems: val.y === "center" ? "center" : `flex-${val.y}`,
@@ -173,7 +219,8 @@ const HeaderCell = styled(ThemeableStack, {
173
219
 
174
220
  const TableBody = styled(ThemeableStack, {
175
221
  name: "TableBody",
176
- ...({ tag: "tbody" } as { tag: string }),
222
+ ...({ render: "tbody" } as { render?: string }),
223
+ role: "rowgroup",
177
224
  context: TableContext,
178
225
  display: "table-row-group" as any,
179
226
  variants: {
@@ -184,7 +231,13 @@ const TableBody = styled(ThemeableStack, {
184
231
  flexDirection: "column" as any,
185
232
  width: "100%",
186
233
  overflowY: "auto" as any,
187
- flex: 1,
234
+ // basis auto (not flex:1 basis-0): in auto-height parents a basis-0
235
+ // chain resolves to 0 and the body collapses to a scrollbar sliver;
236
+ // basis auto sizes to rows there while still filling sized parents
237
+ // (grow) and scrolling (shrink + minHeight 0).
238
+ flexGrow: 1,
239
+ flexShrink: 1,
240
+ flexBasis: "auto" as any,
188
241
  minHeight: 0,
189
242
  },
190
243
  },
@@ -193,7 +246,8 @@ const TableBody = styled(ThemeableStack, {
193
246
 
194
247
  const TableHead = styled(ThemeableStack, {
195
248
  name: "TableHead",
196
- ...({ tag: "thead" } as { tag: string }),
249
+ ...({ render: "thead" } as { render?: string }),
250
+ role: "rowgroup",
197
251
  context: TableContext,
198
252
  display: "table-header-group" as any,
199
253
  borderBottomWidth: 1,
@@ -213,7 +267,8 @@ const TableHead = styled(ThemeableStack, {
213
267
 
214
268
  const TableFoot = styled(ThemeableStack, {
215
269
  name: "TableFoot",
216
- ...({ tag: "tfoot" } as { tag: string }),
270
+ ...({ render: "tfoot" } as { render?: string }),
271
+ role: "rowgroup",
217
272
  context: TableContext,
218
273
  display: "table-footer-group" as any,
219
274
  variants: {
@@ -231,16 +286,20 @@ const TableFoot = styled(ThemeableStack, {
231
286
 
232
287
  const TableCaption = styled(ThemeableStack, {
233
288
  name: "TableCaption",
234
- ...({ tag: "caption" } as { tag: string }),
289
+ ...({ render: "caption" } as { render?: string }),
235
290
  context: TableContext,
236
291
  display: "table-caption" as any,
292
+ // Real <caption> centers its text by UA default — keep the primitive's
293
+ // pre-migration start alignment (logical, RTL-correct).
294
+ style: { textAlign: "start" },
237
295
  paddingVertical: "$2",
238
296
  paddingHorizontal: "$2",
239
297
  });
240
298
 
241
299
  const TableComp = styled(ThemeableStack, {
242
300
  name: "Table",
243
- ...({ tag: "table" } as { tag: string }),
301
+ ...({ render: "table" } as { render?: string }),
302
+ role: "table",
244
303
  context: TableContext,
245
304
  display: "table" as any,
246
305
  borderWidth: 1,
@@ -254,7 +313,10 @@ const TableComp = styled(ThemeableStack, {
254
313
  flex: {
255
314
  display: "flex" as any,
256
315
  flexDirection: "column" as any,
257
- flex: 1,
316
+ // See Body: basis auto so auto-height parents get content height.
317
+ flexGrow: 1,
318
+ flexShrink: 1,
319
+ flexBasis: "auto" as any,
258
320
  minHeight: 0,
259
321
  },
260
322
  },
@@ -292,59 +354,336 @@ function resolveIntent(props: ThemedRowProps): IntentName | undefined {
292
354
  return undefined;
293
355
  }
294
356
 
357
+ /**
358
+ * SP-EDGE row density: the `space` knob scales cell padding (row density)
359
+ * without adding an outer row inset (LC-18 / theme-propagation-spec SP-EDGE).
360
+ * `medium` matches the pre-knob defaults so default rendering is unchanged.
361
+ */
362
+ export function getTableCellPadding(space: string): {
363
+ paddingHorizontal: string;
364
+ paddingVertical: string;
365
+ } {
366
+ const map: Record<string, { paddingHorizontal: string; paddingVertical: string }> = {
367
+ small: { paddingHorizontal: "$1.5", paddingVertical: "$2" },
368
+ medium: { paddingHorizontal: "$2", paddingVertical: "$3" },
369
+ large: { paddingHorizontal: "$3", paddingVertical: "$4" },
370
+ };
371
+ return map[space] ?? map.medium;
372
+ }
373
+
374
+ /**
375
+ * Responsive layout mode for the Table primitive.
376
+ * - `table`: always render the semantic table (default — Layer-1 consumers
377
+ * like markdown tables and DataTable's grid own their responsive handling)
378
+ * - `cards`: render each body row as a stacked card of label/value pairs
379
+ * - `auto`: table on `$sm` and up, cards below (W11 compact class)
380
+ */
381
+ export type TableLayout = "table" | "cards" | "auto";
382
+
383
+ interface CardRowModel {
384
+ key: string;
385
+ intent?: IntentName;
386
+ cells: Array<{ key: string; content: React.ReactNode }>;
387
+ }
388
+
389
+ /** Flatten fragments/arrays into a single element list. */
390
+ function flattenChildren(children: React.ReactNode): React.ReactNode[] {
391
+ return Children.toArray(children).flatMap((child) => {
392
+ if (isValidElement(child) && child.type === Fragment) {
393
+ return flattenChildren((child.props as { children?: React.ReactNode }).children);
394
+ }
395
+ return [child];
396
+ });
397
+ }
398
+
399
+ /**
400
+ * Statically extract header labels, body rows, and the caption from the
401
+ * canonical `Table > Head/Body/Caption > Row > Cell` composition. Rows or
402
+ * cells wrapped in custom components are not discovered — those cards render
403
+ * value-only rows (documented limitation of the card layout).
404
+ */
405
+ function extractCardModel(children: React.ReactNode): {
406
+ headerLabels: React.ReactNode[];
407
+ rows: CardRowModel[];
408
+ caption: React.ReactNode;
409
+ } {
410
+ const headerLabels: React.ReactNode[] = [];
411
+ const rows: CardRowModel[] = [];
412
+ let caption: React.ReactNode = null;
413
+
414
+ for (const section of flattenChildren(children)) {
415
+ if (!isValidElement(section)) continue;
416
+ const sectionProps = section.props as { children?: React.ReactNode };
417
+ if (section.type === ThemedTableHead) {
418
+ for (const rowEl of flattenChildren(sectionProps.children)) {
419
+ if (!isValidElement(rowEl) || rowEl.type !== ThemedRow) continue;
420
+ flattenChildren((rowEl.props as { children?: React.ReactNode }).children).forEach(
421
+ (cellEl, index) => {
422
+ if (!isValidElement(cellEl) || cellEl.type !== ThemedHeaderCell) return;
423
+ if (headerLabels[index] === undefined) {
424
+ headerLabels[index] = (cellEl.props as { children?: React.ReactNode }).children;
425
+ }
426
+ },
427
+ );
428
+ }
429
+ } else if (section.type === TableBody) {
430
+ for (const rowEl of flattenChildren(sectionProps.children)) {
431
+ if (!isValidElement(rowEl) || rowEl.type !== ThemedRow) continue;
432
+ const rowProps = rowEl.props as ThemedRowProps;
433
+ const cells = flattenChildren(rowProps.children)
434
+ .filter(
435
+ (cellEl): cellEl is React.ReactElement =>
436
+ isValidElement(cellEl) && cellEl.type === ThemedCell,
437
+ )
438
+ .map((cellEl, index) => ({
439
+ key: cellEl.key ?? String(index),
440
+ content: (cellEl.props as { children?: React.ReactNode }).children,
441
+ }));
442
+ rows.push({
443
+ key: rowEl.key ?? String(rows.length),
444
+ intent: resolveIntent(rowProps),
445
+ cells,
446
+ });
447
+ }
448
+ } else if (section.type === TableCaption) {
449
+ caption = sectionProps.children;
450
+ }
451
+ // Foot content has no card slot — it is omitted in the card layout.
452
+ }
453
+
454
+ return { headerLabels, rows, caption };
455
+ }
456
+
457
+ /** Card list rendering for the Table primitive (layout="cards" / narrow auto). */
458
+ function TableCardsList({
459
+ children,
460
+ ...props
461
+ }: {
462
+ children?: React.ReactNode;
463
+ [key: string]: any;
464
+ }) {
465
+ const { knobProps } = useResolvedKnobs();
466
+ const { headerLabels, rows, caption } = extractCardModel(children);
467
+ // Table-context props have no meaning on the card list — drop them.
468
+ const {
469
+ cellWidth: _cellWidth,
470
+ cellHeight: _cellHeight,
471
+ alignCells: _alignCells,
472
+ alignHeaderCells: _alignHeaderCells,
473
+ displayMode: _displayMode,
474
+ style: _style,
475
+ ...viewProps
476
+ } = props;
477
+
478
+ return (
479
+ <ThemeableStack flexDirection="column" {...knobProps.gap} testID="table-cards" {...viewProps}>
480
+ {caption != null &&
481
+ (typeof caption === "string" || typeof caption === "number" ? (
482
+ <SizableText fontSize={knobProps.sizeToken as FontSizeTokens} color="$color10">
483
+ {caption}
484
+ </SizableText>
485
+ ) : (
486
+ caption
487
+ ))}
488
+ {rows.map((row) => {
489
+ const card = (
490
+ <ThemeableStack
491
+ key={row.key}
492
+ testID="table-card"
493
+ {...knobProps.surface}
494
+ {...knobProps.cardSurface}
495
+ elevation={knobProps.elevation}
496
+ borderColor="$color6"
497
+ {...(row.intent ? { backgroundColor: "$color3" as const } : {})}
498
+ >
499
+ {row.cells.map((cell, index) => {
500
+ const label = headerLabels[index];
501
+ const value =
502
+ typeof cell.content === "string" || typeof cell.content === "number" ? (
503
+ <SizableText fontSize={knobProps.sizeToken as FontSizeTokens}>
504
+ {cell.content}
505
+ </SizableText>
506
+ ) : (
507
+ cell.content
508
+ );
509
+ return (
510
+ <ThemeableStack
511
+ key={cell.key}
512
+ flexDirection="row"
513
+ alignItems="center"
514
+ gap="$2"
515
+ paddingVertical="$1.5"
516
+ >
517
+ {label != null && (
518
+ <SizableText
519
+ fontSize={knobProps.sizeToken as FontSizeTokens}
520
+ fontWeight="500"
521
+ color="$color10"
522
+ width="40%"
523
+ flexShrink={0}
524
+ >
525
+ {label}
526
+ </SizableText>
527
+ )}
528
+ <ThemeableStack flex={1} minWidth={0}>
529
+ <TableCellContext.Provider value={{ inTableCell: true, isHeader: false }}>
530
+ {value}
531
+ </TableCellContext.Provider>
532
+ </ThemeableStack>
533
+ </ThemeableStack>
534
+ );
535
+ })}
536
+ </ThemeableStack>
537
+ );
538
+ return row.intent ? (
539
+ <Theme key={row.key} name={row.intent}>
540
+ {card}
541
+ </Theme>
542
+ ) : (
543
+ card
544
+ );
545
+ })}
546
+ </ThemeableStack>
547
+ );
548
+ }
549
+
550
+ export interface ThemedTableProps extends React.ComponentProps<typeof TableComp> {
551
+ /** Responsive layout — see {@link TableLayout}. Default `table`. */
552
+ layout?: TableLayout;
553
+ }
554
+
295
555
  // Themed wrappers that inject resolved knob recipes
296
- function ThemedTableComp(props: React.ComponentProps<typeof TableComp>) {
556
+ function ThemedTableComp({ layout = "table", ...props }: ThemedTableProps) {
297
557
  const { knobProps } = useResolvedKnobs();
298
- const baseBorderWidth = knobProps.borderRadius.borderWidth;
558
+ const { sm } = useMedia();
559
+ const baseBorderWidth = knobProps.surface.borderWidth;
299
560
  // When outlined, ensure at least 1px border. When filled, use base or 0.
300
561
  const resolvedBorderWidth = knobProps.outlined ? Math.max(baseBorderWidth, 1) : baseBorderWidth;
301
562
 
563
+ const showCards = layout === "cards" || (layout === "auto" && sm === false);
564
+ if (showCards) {
565
+ return <TableCardsList {...props} />;
566
+ }
567
+
302
568
  return (
303
569
  <TableComp
304
- {...knobProps.borderRadius}
570
+ // Table frames are R-OUTER + SF-CARD-tier surface (theme-propagation-spec
571
+ // `@multiplatform.one/table`): outer corners follow the capped outer
572
+ // radius map; fill flips the frame between the surface bg and transparent.
573
+ {...knobProps.surface}
574
+ {...knobProps.borderRadiusOuter}
575
+ {...(knobProps.outlined ? { backgroundColor: "transparent" as const } : null)}
305
576
  borderWidth={resolvedBorderWidth}
306
- borderColor="$color6"
307
- backgroundColor="$background"
308
577
  elevation={knobProps.elevation}
578
+ // Size knob sets default row height through the table context; consumer
579
+ // cellHeight (table- or cell-level) still overrides via {...props}.
580
+ cellHeight={knobProps.sizeToken as SizeTokens}
309
581
  {...props}
310
- style={[{ tableLayout: "fixed" as const }, props.style]}
582
+ // borderSpacing 0 neutralizes the real <table> UA default (2px gaps
583
+ // between cells) so the migrated element keeps the div-table look.
584
+ style={[{ tableLayout: "fixed" as const, borderSpacing: 0 }, props.style]}
311
585
  />
312
586
  );
313
587
  }
314
588
 
589
+ function ThemedTableHead(props: React.ComponentProps<typeof TableHead>) {
590
+ // Head/body rule is a divider, not a control border — hairline on
591
+ // high-DPI (Axiom 15). Explicit consumer props still override.
592
+ return <TableHead {...hairline.bottom} {...props} />;
593
+ }
594
+
315
595
  function ThemedRow({ accent, error, warning, success, ...props }: ThemedRowProps) {
316
596
  const intent = resolveIntent({ accent, error, warning, success } as ThemedRowProps);
317
597
 
318
- const row = <Row {...(intent ? { backgroundColor: "$color3" } : {})} {...props} />;
598
+ // Intent rows must not use the plain <Theme> wrapper: on web it renders a
599
+ // display:contents <span> between <tbody> and <tr>, which is invalid HTML
600
+ // once the primitives emit real table elements (SSR parsers foster-parent
601
+ // the span out of the table and hydration breaks). Instead the theme's
602
+ // CSS class goes on the <tr> itself (scoping the theme CSS variables for
603
+ // the row and its cells) while <Theme forceClassName={false}> still
604
+ // provides the theme context for JS-resolved values and native styling
605
+ // without emitting any wrapper element.
606
+ const row = (
607
+ <Row
608
+ {...(intent
609
+ ? {
610
+ backgroundColor: "$color3" as const,
611
+ // `color: $color` mirrors the inline color the Theme span used
612
+ // to set, so unstyled text in cells inherits the intent color.
613
+ color: "$color" as const,
614
+ ...(isWeb ? { className: `t_${intent}` } : {}),
615
+ }
616
+ : {})}
617
+ {...props}
618
+ />
619
+ );
319
620
 
320
621
  if (intent) {
321
- return <Theme name={intent}>{row}</Theme>;
622
+ return (
623
+ <Theme name={intent} forceClassName={false}>
624
+ {row}
625
+ </Theme>
626
+ );
322
627
  }
323
628
  return row;
324
629
  }
325
630
 
326
- function ThemedCell({ children, ...props }: React.ComponentProps<typeof Cell>) {
631
+ function ThemedCell({ children, style, ...props }: React.ComponentProps<typeof Cell>) {
632
+ const { knobProps } = useResolvedKnobs();
633
+ // Bare string/number children would render unstyled (and crash on native,
634
+ // where text must live inside a Text component) — wrap them so the size
635
+ // knob reaches cell text (T-VALUE); element children own their text styling.
636
+ const content =
637
+ typeof children === "string" || typeof children === "number" ? (
638
+ <SizableText fontSize={knobProps.sizeToken as FontSizeTokens}>{children}</SizableText>
639
+ ) : (
640
+ children
641
+ );
327
642
  return (
328
- <Cell {...props}>
643
+ <Cell
644
+ {...getTableCellPadding(knobProps.space)}
645
+ {...props}
646
+ // Compose (not replace) the inline style so a consumer style (e.g.
647
+ // sticky pinned cells) keeps the verticalAlign baseline.
648
+ style={[{ verticalAlign: "middle" }, style as any]}
649
+ >
329
650
  <TableCellContext.Provider value={{ inTableCell: true, isHeader: false }}>
330
- {children}
651
+ {content}
331
652
  </TableCellContext.Provider>
332
653
  </Cell>
333
654
  );
334
655
  }
335
656
 
336
- function ThemedHeaderCell({ children, ...props }: React.ComponentProps<typeof HeaderCell>) {
657
+ function ThemedHeaderCell({ children, style, ...props }: React.ComponentProps<typeof HeaderCell>) {
658
+ const { knobProps } = useResolvedKnobs();
659
+ // Bare string/number children would render unstyled (and crash on native,
660
+ // where text must live inside a Text component) — wrap them; element
661
+ // children own their text styling.
662
+ const content =
663
+ typeof children === "string" || typeof children === "number" ? (
664
+ <SizableText fontSize={knobProps.sizeToken as FontSizeTokens} fontWeight="600">
665
+ {children}
666
+ </SizableText>
667
+ ) : (
668
+ children
669
+ );
337
670
  return (
338
- <HeaderCell {...props}>
671
+ <HeaderCell
672
+ {...getTableCellPadding(knobProps.space)}
673
+ {...props}
674
+ // Compose (not replace) the inline style so consumer styles (sticky
675
+ // pinned headers) keep the real-<th> UA neutralizers (bold/centered).
676
+ style={[{ verticalAlign: "middle", fontWeight: "normal", textAlign: "start" }, style as any]}
677
+ >
339
678
  <TableCellContext.Provider value={{ inTableCell: true, isHeader: true }}>
340
- {children}
679
+ {content}
341
680
  </TableCellContext.Provider>
342
681
  </HeaderCell>
343
682
  );
344
683
  }
345
684
 
346
685
  export const Table = withStaticProperties(ThemedTableComp, {
347
- Head: TableHead,
686
+ Head: ThemedTableHead,
348
687
  Body: TableBody,
349
688
  Row: ThemedRow,
350
689
  Cell: ThemedCell,
@@ -354,7 +693,7 @@ export const Table = withStaticProperties(ThemedTableComp, {
354
693
  });
355
694
 
356
695
  // Export prop types for all styled components
357
- export type TableProps = React.ComponentProps<typeof TableComp>;
696
+ export type TableProps = ThemedTableProps;
358
697
  export type TableRowProps = ThemedRowProps;
359
698
  export type TableCellProps = React.ComponentProps<typeof Cell>;
360
699
  export type TableHeaderCellProps = React.ComponentProps<typeof HeaderCell>;