@xsolla/xui-table 0.207.0 → 0.208.1

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/README.md CHANGED
@@ -34,7 +34,7 @@ Designed against the [Xsolla UI Kit Table](https://www.figma.com/design/m0qqLlCv
34
34
 
35
35
  | Sub-component | Role | Notes |
36
36
  |---|---|---|
37
- | `Table` | `role="table"` container | Padding, radius, gap, theme |
37
+ | `Table` | `role="table"` container | Padding, radius, gap, theme, and the layout switches below |
38
38
  | `Table.Caption` | Caption / description | Goes anywhere inside `<Table>`; muted text by default |
39
39
  | `Table.Header` | `role="rowgroup"` (sticky) | Wraps a single `<Table.Row>` containing `<Table.Head>` cells |
40
40
  | `Table.Body` | `role="rowgroup"` | Wraps body `<Table.Row>` nodes; optional `minRows` prop reserves a stable height for paginated tables (see "Stable height across pages") |
@@ -49,6 +49,20 @@ token. Figma node `22969:49282` only ships one canonical density (`56 px`
49
49
  rows, `24 px` horizontal padding, `14 px` body font); if denser variants
50
50
  are added later, this will become a `size` prop.
51
51
 
52
+ ### Layout switches on `<Table>`
53
+
54
+ Four props control the responsive layout and the horizontal scroll
55
+ viewport. They're the only non-`children` surface on `<Table>` itself.
56
+
57
+ | Prop | Default | What it does |
58
+ |---|---|---|
59
+ | `device="desktop" \| "mobile"` | `"desktop"` | Layout variant. Pick it from the viewport, not from a user preference. |
60
+ | `scroll` | `true` on mobile or with `floatFirstColumn`, else `false` | Wraps the header + body rowgroups in one horizontal scroll viewport. |
61
+ | `scrollBar` | `false` | Renders a visible scrollbar for that viewport. |
62
+ | `floatFirstColumn` | `false` | Pins the first column to the left edge while the rest scrolls. Desktop only. |
63
+
64
+ See "Scrolling" and "Responsive layout" below.
65
+
52
66
  ## What's NOT in the toolkit (and why)
53
67
 
54
68
  | Pattern | Where it lives | Why |
@@ -285,6 +299,10 @@ the same reason at the bottom.
285
299
  filter row above is visually separated from the column-header row
286
300
  without the consumer needing to add their own divider.
287
301
 
302
+ These blocks stay **outside** the horizontal scroll viewport when
303
+ `scroll` is on — only `<Table.Header>` and `<Table.Body>` scroll. See
304
+ "Scrolling" below.
305
+
288
306
  ### B. Outside `<Table>`, above the card
289
307
 
290
308
  Useful when the bulk-selection bar should appear *above* the rounded card
@@ -387,8 +405,9 @@ height jump.
387
405
  The cell is hidden via `opacity: 0` + `pointer-events: none` (not
388
406
  `visibility: hidden`), so keyboard Tab still focuses descendants. Focusing
389
407
  a descendant flips the row's `focus-within` state and reveals the cell.
390
- On touch-only devices the cell is always visible — matches Figma's
391
- "Right cell actions are always visible on touch" spec.
408
+ On touch-only devices — and whenever the table is rendered with
409
+ `device="mobile"` — the cell is always visible, matching Figma's "Right
410
+ cell actions are always visible on touch" spec.
392
411
 
393
412
  ## Truncation + tooltip on long values
394
413
 
@@ -418,10 +437,49 @@ when real data arrives. See the `LoadingState` story.
418
437
 
419
438
  Keeps screen reader output meaningful.
420
439
 
421
- ## Scrolling
440
+ ## Responsive layout (`device`)
441
+
442
+ `device` is a layout variant, not a runtime toggle for the user. Select it
443
+ from the viewport — render `device="mobile"` below the mobile breakpoint
444
+ and `device="desktop"` above it:
445
+
446
+ ```tsx
447
+ <Table device={isNarrowViewport ? "mobile" : "desktop"}>...</Table>
448
+ ```
422
449
 
423
- The table is unopinionated about scroll. Wrap it (or the part you want to
424
- scroll) in a sized container.
450
+ Because the toolkit is headless, the data, filters, sort order, and
451
+ selection all live in consumer state — so they survive the switch for
452
+ free. A user who selects three rows on desktop and rotates to a narrow
453
+ viewport keeps that selection.
454
+
455
+ The Mobile variant keeps the **same columnar layout** — it does not
456
+ collapse rows into stacked cards. What changes:
457
+
458
+ | | `desktop` | `mobile` |
459
+ |---|---|---|
460
+ | Horizontal scroll | Off unless you opt in | **On by default** |
461
+ | `revealOnHover` cells | Hidden until hover / focus | **Always visible** (no hover on touch) |
462
+ | `floatFirstColumn` | Honored | Ignored |
463
+
464
+ What adapts *around* the table is consumer-side: the title block stacks,
465
+ and the filter panel collapses to a search field plus a filter button that
466
+ opens the remaining filters in a `<Modal type="bottom sheet">`. Keep a
467
+ `<Badge>` on that button so active filters are visible from the collapsed
468
+ state.
469
+
470
+ **Mobile column order matters.** The leftmost columns are the ones read
471
+ without scrolling, so put the row's identifying field first, then the
472
+ values users compare most often. Push rarely-read columns to the right
473
+ rather than removing them. This — not `floatFirstColumn` — is how
474
+ readability is handled on mobile.
475
+
476
+ **Touch targets.** Hover doesn't exist on touch, so row-level actions
477
+ can't be revealed on hover. Keep the overflow (`⋯`) button permanently
478
+ visible in each row and size it to meet the 44 × 44 px touch target.
479
+
480
+ See the `MobileDevice` story.
481
+
482
+ ## Scrolling
425
483
 
426
484
  ### Vertical scroll with sticky header
427
485
 
@@ -429,13 +487,47 @@ scroll) in a sized container.
429
487
  `<Table.Body>` in a fixed-height `overflow-y: auto` container and the
430
488
  header stays pinned while rows scroll. See the `Scrollable` story.
431
489
 
432
- ### Horizontal scroll
490
+ ### Horizontal scroll (`scroll`, `scrollBar`)
491
+
492
+ When the columns exceed the available width, the table body scrolls
493
+ horizontally. Set `scroll` and `<Table>` wraps the contiguous run of
494
+ `<Table.Header>` / `<Table.Body>` children in a single scroll viewport, so
495
+ the header row and the body scroll **in lockstep** and column labels stay
496
+ aligned with their values.
497
+
498
+ Everything else stays fixed to the container width: `<Table.Caption>`,
499
+ `<Table.Footer>`, and any consumer-composed filter panel or item counter
500
+ dropped in as a direct child (position A above).
433
501
 
434
- `<Table>` is intentionally opaque — it doesn't accept `style` or
435
- Box-level layout props, so the scroll viewport lives **outside**.
436
- Two-Box wrapper: the outer Box scrolls horizontally and the inner uses
437
- `min-width: max-content` so the table card grows to the natural width of
438
- its widest row.
502
+ ```tsx
503
+ <Table scroll scrollBar>
504
+ <Table.Caption>Stays fixed while the rows scroll</Table.Caption>
505
+ <Table.Header>...</Table.Header>
506
+ <Table.Body>...</Table.Body>
507
+ <Table.Footer>...</Table.Footer> {/* also stays fixed */}
508
+ </Table>
509
+ ```
510
+
511
+ `scroll` defaults to `true` on `device="mobile"` and whenever
512
+ `floatFirstColumn` is on, and `false` otherwise — so desktop tables that
513
+ already supply their own outer scroll container (below) are unaffected.
514
+
515
+ `scrollBar` controls whether a **visible** scrollbar is rendered for that
516
+ viewport, and is `false` by default (matching the Figma `ScrollBar` prop).
517
+ Because it's off by default, an overflowing table gives no persistent
518
+ signal that content continues to the right — decide deliberately rather
519
+ than leaving the default in place. On desktop there is no swipe
520
+ affordance, so without a visible scrollbar the overflowing columns can go
521
+ unnoticed entirely. The scrollbar reflects the table body only.
522
+
523
+ See the `BuiltInHorizontalScroll` story.
524
+
525
+ #### Composing your own scroll viewport instead
526
+
527
+ `scroll` is opt-in on desktop precisely so the pre-existing two-Box
528
+ wrapper keeps working. The outer Box scrolls horizontally and the inner
529
+ uses `min-width: max-content` so the table card grows to the natural width
530
+ of its widest row:
439
531
 
440
532
  ```tsx
441
533
  <Box style={{ width: "100%", overflowX: "auto" }}>
@@ -452,12 +544,55 @@ cells spilling outside the card's white background when you scroll.
452
544
  `max-content` lets the browser compute the right value so the card and
453
545
  the cells stay the same width. See the `HorizontalScroll` story.
454
546
 
455
- ### Frozen first column (optional)
547
+ Don't combine the two: if you supply your own viewport, leave `scroll`
548
+ off (or pass `scroll={false}` on mobile) so you don't nest one scroll
549
+ container inside another.
550
+
551
+ ### Floating first column (`floatFirstColumn`, desktop only)
552
+
553
+ When the columns overflow and the body scrolls horizontally, set
554
+ `floatFirstColumn` and the first column holds its position at the left
555
+ edge, keeping a shadow — the only cue that content continues underneath
556
+ it. It implies the scroll viewport, so `scroll` is optional alongside it.
557
+
558
+ ```tsx
559
+ <Table floatFirstColumn scrollBar>
560
+ <Table.Header>
561
+ <Table.Row>
562
+ <Table.Head width={200}>Name</Table.Head>
563
+ <Table.Head width={120}>Status</Table.Head>
564
+ {/* …more columns than fit… */}
565
+ </Table.Row>
566
+ </Table.Header>
567
+ <Table.Body>...</Table.Body>
568
+ </Table>
569
+ ```
570
+
571
+ Use it when the table has more columns than fit the viewport and the first
572
+ column carries the row's identity (a name, an ID, an avatar) the user
573
+ needs as a reference while reading columns to the right. Keep the pinned
574
+ column narrow — only the first column can be pinned, and pinning a wide
575
+ one leaves too little room for the scrolling area.
576
+
577
+ Implementation notes:
578
+
579
+ - It is the **real first cell** held in place inside the scrolling
580
+ container, not a duplicate overlay kept in step with the real column.
581
+ (Figma builds it as a separate absolutely-positioned layer only because
582
+ Figma can't express a sticky column any other way.)
583
+ - The row's left gutter moves onto the pinned cell so it stays flush with
584
+ the container edge instead of leaving a transparent strip when scrolled.
585
+ - The pinned cell paints the row's current background — including the
586
+ hover and `selected` states — so scrolled-under columns can't show
587
+ through it.
588
+ - Only `<Table.Cell>` and `<Table.Head>` are pinned. If the first child of
589
+ a row is a custom wrapper, nothing is pinned for that row.
590
+
591
+ **Desktop only.** On `device="mobile"` the prop is ignored: horizontal
592
+ scrolling is already the reading mode there, so handle readability through
593
+ column order instead (see "Responsive layout").
456
594
 
457
- Wrap the first cell in `position: sticky; left: 0` with a matching
458
- background color and z-index. The toolkit doesn't ship a `freezeFirst`
459
- prop because the visual treatment (border, shadow, background) varies per
460
- team.
595
+ See the `FloatingFirstColumn` story.
461
596
 
462
597
  ## Virtualization
463
598
 
@@ -488,6 +623,9 @@ For native screens, pair `FlatList` with your own row + cell components.
488
623
  | Sort indicator | Built into `Table.Head` (symmetric dual-chevron + `aria-sort` for direction) |
489
624
  | Hover-reveal cells | `revealOnHover` prop on `Table.Cell` |
490
625
  | Sticky header | Built into `Table.Header` |
626
+ | Responsive variant | `device="desktop" \| "mobile"` on `Table` |
627
+ | Horizontal scroll | `scroll` + `scrollBar` on `Table` (or compose your own viewport) |
628
+ | Frozen first column | `floatFirstColumn` on `Table` (desktop only) |
491
629
  | Theming | Toolkit theme tokens (`theme.sizing.table`) |
492
630
  | Cross-platform | Web only |
493
631
  | Bundles a row data engine | No |
@@ -5,6 +5,13 @@ import { ThemeOverrideProps } from '@xsolla/xui-core';
5
5
 
6
6
  type TableCellAlign = "left" | "right" | "center";
7
7
  type TableCellPosition = "default" | "left" | "right";
8
+ /**
9
+ * Layout variant of the table. Not a runtime user preference — pick it
10
+ * from the viewport (render `"mobile"` below the mobile breakpoint,
11
+ * `"desktop"` above it) per the Figma spec. Data, filters, sort order,
12
+ * and selection are owned by the consumer, so they survive the switch.
13
+ */
14
+ type TableDevice = "desktop" | "mobile";
8
15
  /**
9
16
  * Structural primitives only:
10
17
  *
@@ -33,12 +40,63 @@ type TableCellPosition = "default" | "left" | "right";
33
40
  */
34
41
  /**
35
42
  * `<Table>` is intentionally opaque: no `style`, no Box-level props. The
36
- * surface is `children` only (plus theme overrides). To wrap the table in
37
- * a horizontal-scroll viewport, compose two outer Boxes around it — see
38
- * the `HorizontalScroll` story or the README "Horizontal scroll" section.
43
+ * surface is `children` plus the layout switches below (`device`,
44
+ * `scroll`, `scrollBar`, `floatFirstColumn`) and theme overrides.
45
+ *
46
+ * The scroll switches wrap the contiguous run of `<Table.Header>` /
47
+ * `<Table.Body>` children in a horizontal scroll viewport, so the header
48
+ * row and the body scroll in lockstep while `<Table.Caption>`,
49
+ * `<Table.Footer>`, and any consumer-composed filter panel / item counter
50
+ * stay fixed to the container width.
39
51
  */
40
52
  interface TableProps extends ThemeOverrideProps {
41
53
  children?: ReactNode;
54
+ /**
55
+ * Layout variant. Defaults to `"desktop"`.
56
+ *
57
+ * `"mobile"` keeps the same columnar layout — it does not collapse rows
58
+ * into stacked cards. What changes:
59
+ *
60
+ * - horizontal scrolling is on by default (this is how a wide table is
61
+ * read on a narrow screen),
62
+ * - `<Table.Cell revealOnHover>` cells are always visible, because hover
63
+ * does not exist on touch,
64
+ * - `floatFirstColumn` is ignored — order columns by importance instead
65
+ * and put the row's identifying field first.
66
+ */
67
+ device?: TableDevice;
68
+ /**
69
+ * Wrap the header + body rowgroups in a horizontal scroll viewport.
70
+ *
71
+ * Defaults to `true` when `device="mobile"` or `floatFirstColumn` is on,
72
+ * and `false` otherwise — so existing desktop tables that supply their
73
+ * own outer scroll container (see the `HorizontalScroll` story) are
74
+ * unaffected. Set it explicitly to override.
75
+ */
76
+ scroll?: boolean;
77
+ /**
78
+ * Render a visible scrollbar for the horizontal scroll viewport.
79
+ * Defaults to `false`, matching the Figma `ScrollBar` prop.
80
+ *
81
+ * Because it is off by default, an overflowing table gives no persistent
82
+ * signal that content continues to the right. On desktop there is no
83
+ * swipe affordance, so turn it on whenever columns are expected to
84
+ * overflow.
85
+ */
86
+ scrollBar?: boolean;
87
+ /**
88
+ * Pin the first column to the left edge while the rest of the row
89
+ * scrolls horizontally. Defaults to `false`. **Desktop only** — ignored
90
+ * when `device="mobile"`.
91
+ *
92
+ * This is the real first cell held in place inside the scrolling
93
+ * container, not a duplicate overlay. Use it when the table has more
94
+ * columns than fit and the first column carries the row's identity (a
95
+ * name, an ID, an avatar) the user needs as a reference while reading
96
+ * columns to the right. Keep the pinned column narrow; only the first
97
+ * column can be pinned.
98
+ */
99
+ floatFirstColumn?: boolean;
42
100
  testID?: string;
43
101
  }
44
102
  interface TableCaptionProps extends BoxProps, ThemeOverrideProps {
@@ -102,6 +160,20 @@ interface TableRowProps extends BoxProps, ThemeOverrideProps {
102
160
  */
103
161
  onKeyDown?: (e: React.KeyboardEvent<Element>) => void;
104
162
  }
163
+ /**
164
+ * Resolved sticky-column styling handed down from `<Table.Row>` to the
165
+ * first cell in the row when `floatFirstColumn` is on.
166
+ *
167
+ * @internal Not part of the public API.
168
+ */
169
+ interface TableFloatingCellConfig {
170
+ /** Row background at its current state, so scrolled cells can't show through. */
171
+ background: string;
172
+ /** Row's horizontal gutter, moved onto the pinned cell so it stays flush. */
173
+ paddingLeft: number;
174
+ /** Elevation cue that content continues underneath the pinned column. */
175
+ shadow: string;
176
+ }
105
177
  interface TableCellProps extends Omit<BoxProps, "position">, ThemeOverrideProps {
106
178
  children?: ReactNode;
107
179
  /** Cell text alignment. Defaults to "left". */
@@ -113,9 +185,16 @@ interface TableCellProps extends Omit<BoxProps, "position">, ThemeOverrideProps
113
185
  /**
114
186
  * Hide the cell until the parent row is hovered or focused (via `:focus-within`).
115
187
  * Typically used for `position="right"` action cells per Figma spec.
116
- * Falls back to always-visible on touch devices.
188
+ * Falls back to always-visible on touch devices and when the enclosing
189
+ * `<Table>` is rendered with `device="mobile"`.
117
190
  */
118
191
  revealOnHover?: boolean;
192
+ /**
193
+ * @internal Injected by `<Table.Row>` on the first cell when the
194
+ * enclosing `<Table>` has `floatFirstColumn`. Do not set this manually —
195
+ * it is destructured away and never reaches the DOM.
196
+ */
197
+ __floating?: TableFloatingCellConfig;
119
198
  testID?: string;
120
199
  }
121
200
  interface TableHeadProps extends TableCellProps {
@@ -136,4 +215,4 @@ declare const Table: React__default.ForwardRefExoticComponent<TableProps & React
136
215
  Cell: React__default.ForwardRefExoticComponent<TableCellProps & React__default.RefAttributes<any>>;
137
216
  };
138
217
 
139
- export { Table, type TableBodyProps, type TableCaptionProps, type TableCellAlign, type TableCellPosition, type TableCellProps, type TableFooterProps, type TableHeadProps, type TableHeaderProps, type TableProps, type TableRowProps };
218
+ export { Table, type TableBodyProps, type TableCaptionProps, type TableCellAlign, type TableCellPosition, type TableCellProps, type TableDevice, type TableFloatingCellConfig, type TableFooterProps, type TableHeadProps, type TableHeaderProps, type TableProps, type TableRowProps };
package/native/index.d.ts CHANGED
@@ -5,6 +5,13 @@ import { ThemeOverrideProps } from '@xsolla/xui-core';
5
5
 
6
6
  type TableCellAlign = "left" | "right" | "center";
7
7
  type TableCellPosition = "default" | "left" | "right";
8
+ /**
9
+ * Layout variant of the table. Not a runtime user preference — pick it
10
+ * from the viewport (render `"mobile"` below the mobile breakpoint,
11
+ * `"desktop"` above it) per the Figma spec. Data, filters, sort order,
12
+ * and selection are owned by the consumer, so they survive the switch.
13
+ */
14
+ type TableDevice = "desktop" | "mobile";
8
15
  /**
9
16
  * Structural primitives only:
10
17
  *
@@ -33,12 +40,63 @@ type TableCellPosition = "default" | "left" | "right";
33
40
  */
34
41
  /**
35
42
  * `<Table>` is intentionally opaque: no `style`, no Box-level props. The
36
- * surface is `children` only (plus theme overrides). To wrap the table in
37
- * a horizontal-scroll viewport, compose two outer Boxes around it — see
38
- * the `HorizontalScroll` story or the README "Horizontal scroll" section.
43
+ * surface is `children` plus the layout switches below (`device`,
44
+ * `scroll`, `scrollBar`, `floatFirstColumn`) and theme overrides.
45
+ *
46
+ * The scroll switches wrap the contiguous run of `<Table.Header>` /
47
+ * `<Table.Body>` children in a horizontal scroll viewport, so the header
48
+ * row and the body scroll in lockstep while `<Table.Caption>`,
49
+ * `<Table.Footer>`, and any consumer-composed filter panel / item counter
50
+ * stay fixed to the container width.
39
51
  */
40
52
  interface TableProps extends ThemeOverrideProps {
41
53
  children?: ReactNode;
54
+ /**
55
+ * Layout variant. Defaults to `"desktop"`.
56
+ *
57
+ * `"mobile"` keeps the same columnar layout — it does not collapse rows
58
+ * into stacked cards. What changes:
59
+ *
60
+ * - horizontal scrolling is on by default (this is how a wide table is
61
+ * read on a narrow screen),
62
+ * - `<Table.Cell revealOnHover>` cells are always visible, because hover
63
+ * does not exist on touch,
64
+ * - `floatFirstColumn` is ignored — order columns by importance instead
65
+ * and put the row's identifying field first.
66
+ */
67
+ device?: TableDevice;
68
+ /**
69
+ * Wrap the header + body rowgroups in a horizontal scroll viewport.
70
+ *
71
+ * Defaults to `true` when `device="mobile"` or `floatFirstColumn` is on,
72
+ * and `false` otherwise — so existing desktop tables that supply their
73
+ * own outer scroll container (see the `HorizontalScroll` story) are
74
+ * unaffected. Set it explicitly to override.
75
+ */
76
+ scroll?: boolean;
77
+ /**
78
+ * Render a visible scrollbar for the horizontal scroll viewport.
79
+ * Defaults to `false`, matching the Figma `ScrollBar` prop.
80
+ *
81
+ * Because it is off by default, an overflowing table gives no persistent
82
+ * signal that content continues to the right. On desktop there is no
83
+ * swipe affordance, so turn it on whenever columns are expected to
84
+ * overflow.
85
+ */
86
+ scrollBar?: boolean;
87
+ /**
88
+ * Pin the first column to the left edge while the rest of the row
89
+ * scrolls horizontally. Defaults to `false`. **Desktop only** — ignored
90
+ * when `device="mobile"`.
91
+ *
92
+ * This is the real first cell held in place inside the scrolling
93
+ * container, not a duplicate overlay. Use it when the table has more
94
+ * columns than fit and the first column carries the row's identity (a
95
+ * name, an ID, an avatar) the user needs as a reference while reading
96
+ * columns to the right. Keep the pinned column narrow; only the first
97
+ * column can be pinned.
98
+ */
99
+ floatFirstColumn?: boolean;
42
100
  testID?: string;
43
101
  }
44
102
  interface TableCaptionProps extends BoxProps, ThemeOverrideProps {
@@ -102,6 +160,20 @@ interface TableRowProps extends BoxProps, ThemeOverrideProps {
102
160
  */
103
161
  onKeyDown?: (e: React.KeyboardEvent<Element>) => void;
104
162
  }
163
+ /**
164
+ * Resolved sticky-column styling handed down from `<Table.Row>` to the
165
+ * first cell in the row when `floatFirstColumn` is on.
166
+ *
167
+ * @internal Not part of the public API.
168
+ */
169
+ interface TableFloatingCellConfig {
170
+ /** Row background at its current state, so scrolled cells can't show through. */
171
+ background: string;
172
+ /** Row's horizontal gutter, moved onto the pinned cell so it stays flush. */
173
+ paddingLeft: number;
174
+ /** Elevation cue that content continues underneath the pinned column. */
175
+ shadow: string;
176
+ }
105
177
  interface TableCellProps extends Omit<BoxProps, "position">, ThemeOverrideProps {
106
178
  children?: ReactNode;
107
179
  /** Cell text alignment. Defaults to "left". */
@@ -113,9 +185,16 @@ interface TableCellProps extends Omit<BoxProps, "position">, ThemeOverrideProps
113
185
  /**
114
186
  * Hide the cell until the parent row is hovered or focused (via `:focus-within`).
115
187
  * Typically used for `position="right"` action cells per Figma spec.
116
- * Falls back to always-visible on touch devices.
188
+ * Falls back to always-visible on touch devices and when the enclosing
189
+ * `<Table>` is rendered with `device="mobile"`.
117
190
  */
118
191
  revealOnHover?: boolean;
192
+ /**
193
+ * @internal Injected by `<Table.Row>` on the first cell when the
194
+ * enclosing `<Table>` has `floatFirstColumn`. Do not set this manually —
195
+ * it is destructured away and never reaches the DOM.
196
+ */
197
+ __floating?: TableFloatingCellConfig;
119
198
  testID?: string;
120
199
  }
121
200
  interface TableHeadProps extends TableCellProps {
@@ -136,4 +215,4 @@ declare const Table: React__default.ForwardRefExoticComponent<TableProps & React
136
215
  Cell: React__default.ForwardRefExoticComponent<TableCellProps & React__default.RefAttributes<any>>;
137
216
  };
138
217
 
139
- export { Table, type TableBodyProps, type TableCaptionProps, type TableCellAlign, type TableCellPosition, type TableCellProps, type TableFooterProps, type TableHeadProps, type TableHeaderProps, type TableProps, type TableRowProps };
218
+ export { Table, type TableBodyProps, type TableCaptionProps, type TableCellAlign, type TableCellPosition, type TableCellProps, type TableDevice, type TableFloatingCellConfig, type TableFooterProps, type TableHeadProps, type TableHeaderProps, type TableProps, type TableRowProps };
package/native/index.js CHANGED
@@ -344,6 +344,10 @@ var import_xui_core = require("@xsolla/xui-core");
344
344
  var import_xui_icons_base = require("@xsolla/xui-icons-base");
345
345
  var import_jsx_runtime3 = require("react/jsx-runtime");
346
346
  var TableRowGroupContext = (0, import_react.createContext)("body");
347
+ var TableConfigContext = (0, import_react.createContext)({
348
+ device: "desktop",
349
+ floatFirstColumn: false
350
+ });
347
351
  var RowRevealContext = (0, import_react.createContext)({
348
352
  revealed: true
349
353
  });
@@ -369,11 +373,35 @@ var Divider = ({ color }) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
369
373
  style: { flexShrink: 0 }
370
374
  }
371
375
  );
376
+ var floatingCellStyle = (floating) => ({
377
+ position: "sticky",
378
+ left: 0,
379
+ zIndex: 2,
380
+ backgroundColor: floating.background,
381
+ paddingLeft: floating.paddingLeft,
382
+ boxShadow: floating.shadow
383
+ });
372
384
  var TableRoot = (0, import_react.forwardRef)(
373
- ({ children, testID, themeMode, themeProductContext }, ref) => {
385
+ ({
386
+ children,
387
+ device = "desktop",
388
+ scroll,
389
+ scrollBar = false,
390
+ floatFirstColumn = false,
391
+ testID,
392
+ themeMode,
393
+ themeProductContext
394
+ }, ref) => {
374
395
  const { theme } = (0, import_xui_core.useResolvedTheme)({ themeMode, themeProductContext });
375
396
  const sizing = theme.sizing.table;
376
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
397
+ const floatEnabled = floatFirstColumn && device !== "mobile";
398
+ const scrollEnabled = scroll ?? (device === "mobile" || floatEnabled);
399
+ const config = (0, import_react.useMemo)(
400
+ () => ({ device, floatFirstColumn: floatEnabled }),
401
+ [device, floatEnabled]
402
+ );
403
+ const content = scrollEnabled ? wrapRowGroupsInScrollViewport(children, scrollBar) : children;
404
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(TableConfigContext.Provider, { value: config, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
377
405
  Box,
378
406
  {
379
407
  ref,
@@ -390,9 +418,9 @@ var TableRoot = (0, import_react.forwardRef)(
390
418
  color: theme.colors.content.primary,
391
419
  overflow: "clip"
392
420
  },
393
- children
421
+ children: content
394
422
  }
395
- );
423
+ ) });
396
424
  }
397
425
  );
398
426
  TableRoot.displayName = "Table";
@@ -536,13 +564,14 @@ var TableRow = (0, import_react.forwardRef)(
536
564
  const { theme } = (0, import_xui_core.useResolvedTheme)({ themeMode, themeProductContext });
537
565
  const sizing = theme.sizing.table;
538
566
  const rowGroup = (0, import_react.useContext)(TableRowGroupContext);
567
+ const { device, floatFirstColumn } = (0, import_react.useContext)(TableConfigContext);
539
568
  const hoverCapable = useHoverCapable();
540
569
  const isHeaderRow = rowGroup === "header";
541
570
  const hoverable = hoverableProp ?? !isHeaderRow;
542
571
  const hideDivider = hideDividerProp ?? isHeaderRow;
543
572
  const [hovered, setHovered] = (0, import_react.useState)(false);
544
573
  const [focusedWithin, setFocusedWithin] = (0, import_react.useState)(false);
545
- const revealed = hoverCapable ? hovered || focusedWithin : true;
574
+ const revealed = device === "mobile" || !hoverCapable ? true : hovered || focusedWithin;
546
575
  const handleMouseEnter = (0, import_react.useCallback)(
547
576
  (e) => {
548
577
  setHovered(true);
@@ -590,6 +619,26 @@ var TableRow = (0, import_react.forwardRef)(
590
619
  const rowHeight = isHeaderRow ? sizing.headerRowHeight : sizing.rowHeight;
591
620
  const rowPaddingHorizontal = isHeaderRow ? sizing.headerRowPaddingHorizontal : sizing.rowPaddingHorizontal;
592
621
  const ctxValue = (0, import_react.useMemo)(() => ({ revealed }), [revealed]);
622
+ const resolvedBg = hoverable && hovered ? theme.colors.background.secondary : baseBg;
623
+ let renderedChildren = children;
624
+ let didFloat = false;
625
+ if (floatFirstColumn) {
626
+ const childArray = import_react.default.Children.toArray(children);
627
+ const firstCellIndex = childArray.findIndex(
628
+ (c) => import_react.default.isValidElement(c) && (c.type === TableCell || c.type === TableHead)
629
+ );
630
+ if (firstCellIndex !== -1) {
631
+ didFloat = true;
632
+ const floating = {
633
+ background: resolvedBg,
634
+ paddingLeft: rowPaddingHorizontal,
635
+ shadow: theme.shadow.surface
636
+ };
637
+ renderedChildren = childArray.map(
638
+ (child, i) => i === firstCellIndex && import_react.default.isValidElement(child) ? import_react.default.cloneElement(child, { __floating: floating }) : child
639
+ );
640
+ }
641
+ }
593
642
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(RowRevealContext.Provider, { value: ctxValue, children: [
594
643
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
595
644
  Box,
@@ -614,8 +663,14 @@ var TableRow = (0, import_react.forwardRef)(
614
663
  onBlur: handleBlur,
615
664
  ...props,
616
665
  onKeyDown: handleKeyDown,
617
- style,
618
- children
666
+ style: {
667
+ // The pinned cell takes over the row's left gutter so it stays
668
+ // flush with the scroll container edge — without this the row
669
+ // padding scrolls away and leaves a transparent strip.
670
+ ...didFloat && { paddingLeft: 0 },
671
+ ...style
672
+ },
673
+ children: renderedChildren
619
674
  }
620
675
  ),
621
676
  !hideDivider && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Divider, { color: theme.colors.border.secondary })
@@ -651,6 +706,7 @@ var TableCell = (0, import_react.forwardRef)(
651
706
  width,
652
707
  grow,
653
708
  revealOnHover,
709
+ __floating,
654
710
  testID,
655
711
  themeMode,
656
712
  themeProductContext,
@@ -679,6 +735,7 @@ var TableCell = (0, import_react.forwardRef)(
679
735
  minWidth: 0,
680
736
  ...positionStyle,
681
737
  ...revealStyle,
738
+ ...__floating && floatingCellStyle(__floating),
682
739
  ...style
683
740
  },
684
741
  children: typeof children === "string" || typeof children === "number" ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
@@ -721,6 +778,7 @@ var TableHead = (0, import_react.forwardRef)(
721
778
  grow,
722
779
  sort,
723
780
  onSortToggle,
781
+ __floating,
724
782
  testID,
725
783
  themeMode,
726
784
  themeProductContext,
@@ -751,6 +809,7 @@ var TableHead = (0, import_react.forwardRef)(
751
809
  flex: width != null ? "0 0 auto" : grow ?? 1,
752
810
  minWidth: 0,
753
811
  ...positionStyle,
812
+ ...__floating && floatingCellStyle(__floating),
754
813
  ...style
755
814
  },
756
815
  children: [
@@ -805,6 +864,51 @@ var TableFooter = (0, import_react.forwardRef)(
805
864
  }
806
865
  );
807
866
  TableFooter.displayName = "Table.Footer";
867
+ function wrapRowGroupsInScrollViewport(children, scrollBar) {
868
+ const viewportStyle = {
869
+ overflowX: "auto",
870
+ WebkitOverflowScrolling: "touch",
871
+ // `false` hides the scrollbar without disabling the scrolling itself.
872
+ ...scrollBar ? { scrollbarWidth: "thin" } : { scrollbarWidth: "none", msOverflowStyle: "none" }
873
+ };
874
+ const out = [];
875
+ let run = [];
876
+ const flush = () => {
877
+ if (run.length === 0) return;
878
+ const group = run;
879
+ run = [];
880
+ out.push(
881
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
882
+ Box,
883
+ {
884
+ width: "100%",
885
+ style: viewportStyle,
886
+ children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
887
+ Box,
888
+ {
889
+ flexDirection: "column",
890
+ alignItems: "stretch",
891
+ style: { minWidth: "max-content" },
892
+ children: group
893
+ }
894
+ )
895
+ },
896
+ `__table-scroll-${out.length}`
897
+ )
898
+ );
899
+ };
900
+ import_react.default.Children.toArray(children).forEach((child) => {
901
+ const isRowGroup = import_react.default.isValidElement(child) && (child.type === TableHeader || child.type === TableBody);
902
+ if (isRowGroup) {
903
+ run.push(child);
904
+ return;
905
+ }
906
+ flush();
907
+ out.push(child);
908
+ });
909
+ flush();
910
+ return out;
911
+ }
808
912
  var Table = Object.assign(TableRoot, {
809
913
  Caption: TableCaption,
810
914
  Header: TableHeader,