@xsolla/xui-table 0.151.0-pr273.1778117489
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 +493 -0
- package/native/index.d.mts +122 -0
- package/native/index.d.ts +122 -0
- package/native/index.js +738 -0
- package/native/index.js.map +1 -0
- package/native/index.mjs +716 -0
- package/native/index.mjs.map +1 -0
- package/package.json +58 -0
- package/web/index.d.mts +122 -0
- package/web/index.d.ts +122 -0
- package/web/index.js +785 -0
- package/web/index.js.map +1 -0
- package/web/index.mjs +756 -0
- package/web/index.mjs.map +1 -0
package/README.md
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
# @xsolla/xui-table
|
|
2
|
+
|
|
3
|
+
A composable, **strictly headless** data table that exposes only the
|
|
4
|
+
structural primitives needed to render a table.
|
|
5
|
+
|
|
6
|
+
The package ships **only the structural primitives**: container, caption,
|
|
7
|
+
header, body, footer, rows, and cells (with sort + reveal-on-hover support).
|
|
8
|
+
Everything else — title blocks, filter panels, item counters, bulk-selection
|
|
9
|
+
bars, pagination, sorting, filtering, selection, empty/loading/error
|
|
10
|
+
placeholders — is composed by the consumer. The toolkit doesn't care
|
|
11
|
+
whether you use plain `useState`, TanStack Table, React Aria, Redux, or
|
|
12
|
+
server components.
|
|
13
|
+
|
|
14
|
+
Designed against the [Xsolla UI Kit Table](https://www.figma.com/design/m0qqLlCvR29fvqj0EzlLAU/Xsolla-UI-kit?node-id=22969-49282).
|
|
15
|
+
|
|
16
|
+
## Surface
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
<Table>
|
|
20
|
+
<Table.Caption /> {/* optional — <caption>-equivalent */}
|
|
21
|
+
<Table.Header> {/* <thead>-equivalent (sticky) */}
|
|
22
|
+
<Table.Row>
|
|
23
|
+
<Table.Head /> {/* <th>-equivalent (sortable) */}
|
|
24
|
+
</Table.Row>
|
|
25
|
+
</Table.Header>
|
|
26
|
+
<Table.Body> {/* <tbody>-equivalent */}
|
|
27
|
+
<Table.Row>
|
|
28
|
+
<Table.Cell />
|
|
29
|
+
</Table.Row>
|
|
30
|
+
</Table.Body>
|
|
31
|
+
<Table.Footer /> {/* slot for pagination etc. */}
|
|
32
|
+
</Table>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
| Sub-component | Role | Notes |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `Table` | `role="table"` container | Padding, radius, gap, theme |
|
|
38
|
+
| `Table.Caption` | Caption / description | Goes anywhere inside `<Table>`; muted text by default |
|
|
39
|
+
| `Table.Header` | `role="rowgroup"` (sticky) | Wraps a single `<Table.Row>` containing `<Table.Head>` cells |
|
|
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") |
|
|
41
|
+
| `Table.Footer` | Pagination slot | Layout primitive only — drop in `<Pagination>`, `<ProgressStep>`, or your own |
|
|
42
|
+
| `Table.Row` | `role="row"` | Used inside both Header and Body; auto-detects context |
|
|
43
|
+
| `Table.Head` | `role="columnheader"` with `aria-sort` | Sortable via `sort` + `onSortToggle` |
|
|
44
|
+
| `Table.Cell` | `role="cell"` | Optional `revealOnHover` for action cells |
|
|
45
|
+
|
|
46
|
+
Density is single-source from Figma — there's no `size` prop. All
|
|
47
|
+
spacing, row heights, and font sizes come from the `theme.sizing.table`
|
|
48
|
+
token. Figma node `22969:49282` only ships one canonical density (`56 px`
|
|
49
|
+
rows, `24 px` horizontal padding, `14 px` body font); if denser variants
|
|
50
|
+
are added later, this will become a `size` prop.
|
|
51
|
+
|
|
52
|
+
## What's NOT in the toolkit (and why)
|
|
53
|
+
|
|
54
|
+
| Pattern | Where it lives | Why |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| Title block (`<h1>` + description + actions) | Consumer | Every team's design system has its own typography scale and action layout |
|
|
57
|
+
| Filter panel (search + selects + view toggle) | Consumer | Filters change per page; coupling them to `<Table>` adds a config surface for no benefit |
|
|
58
|
+
| Item counter (`"N items"`) | Consumer | One `<Text aria-live="polite">` line — no need for a sub-component |
|
|
59
|
+
| Bulk-selection bar (`"N selected"` + actions) | Consumer | Mounted above `<Table>` when `selected.size > 0`; see the `Selectable` story |
|
|
60
|
+
| Pagination | `@xsolla/xui-pagination` (numeric) or `@xsolla/xui-stepper` (`<ProgressStep>` for dots) — drop into `<Table.Footer>` | Already separate components |
|
|
61
|
+
| Sorting / filtering / paging logic | Consumer (plain `useState`, TanStack Table, …) | Headless contract |
|
|
62
|
+
| Empty / loading / error placeholders | Consumer renders inside `<Table.Body>` | Every team wants different illustrations / copy / CTAs |
|
|
63
|
+
|
|
64
|
+
The philosophy is a tiny primitive surface and infinite composition on
|
|
65
|
+
the consumer side.
|
|
66
|
+
|
|
67
|
+
## Headless contract
|
|
68
|
+
|
|
69
|
+
The component is fully **controlled**. None of the following is stored
|
|
70
|
+
internally — the consumer passes it every render:
|
|
71
|
+
|
|
72
|
+
| Prop | On | What you control |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `sort="ascending" \| "descending" \| "none"` | `Table.Head` | Active sort direction for this column. Renders the symmetric dual-chevron `Sort` icon — full opacity when sorted (`"ascending"` / `"descending"`), 40% when sortable but inactive (`"none"`). The icon itself doesn't visually distinguish ascending from descending (Figma's spec); direction is announced to assistive tech via `aria-sort` (set automatically). |
|
|
75
|
+
| `onSortToggle` | `Table.Head` | Click handler for the sort indicator |
|
|
76
|
+
| `selected` | `Table.Row` | Whether the row is in the selected state |
|
|
77
|
+
| `onPress` | `Table.Row` | Row click handler |
|
|
78
|
+
| `hideDivider` | `Table.Row` | Suppress the bottom divider (typically on the last row) |
|
|
79
|
+
| `revealOnHover` | `Table.Cell` | Hide the cell until the parent row is hovered/focused |
|
|
80
|
+
|
|
81
|
+
## Quick start
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
import { useState, useMemo } from "react";
|
|
85
|
+
import { Table } from "@xsolla/xui-table";
|
|
86
|
+
import { Pagination } from "@xsolla/xui-pagination";
|
|
87
|
+
import { Tag } from "@xsolla/xui-tag";
|
|
88
|
+
|
|
89
|
+
function PromotionsTable({ rows }) {
|
|
90
|
+
const [sortKey, setSortKey] = useState("title");
|
|
91
|
+
const [sortDir, setSortDir] = useState("ascending");
|
|
92
|
+
const [page, setPage] = useState(1);
|
|
93
|
+
|
|
94
|
+
const sorted = useMemo(() => {
|
|
95
|
+
if (sortDir === "none") return rows;
|
|
96
|
+
return [...rows].sort((a, b) =>
|
|
97
|
+
sortDir === "ascending"
|
|
98
|
+
? a[sortKey].localeCompare(b[sortKey])
|
|
99
|
+
: b[sortKey].localeCompare(a[sortKey])
|
|
100
|
+
);
|
|
101
|
+
}, [rows, sortKey, sortDir]);
|
|
102
|
+
|
|
103
|
+
const cycleSort = (key) => {
|
|
104
|
+
if (sortKey !== key) {
|
|
105
|
+
setSortKey(key);
|
|
106
|
+
setSortDir("ascending");
|
|
107
|
+
} else if (sortDir === "ascending") setSortDir("descending");
|
|
108
|
+
else if (sortDir === "descending") setSortDir("none");
|
|
109
|
+
else setSortDir("ascending");
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<Table>
|
|
114
|
+
<Table.Header>
|
|
115
|
+
<Table.Row>
|
|
116
|
+
<Table.Head
|
|
117
|
+
sort={sortKey === "title" ? sortDir : "none"}
|
|
118
|
+
onSortToggle={() => cycleSort("title")}
|
|
119
|
+
>
|
|
120
|
+
Title
|
|
121
|
+
</Table.Head>
|
|
122
|
+
<Table.Head width={140}>Status</Table.Head>
|
|
123
|
+
<Table.Head width={140}>Created</Table.Head>
|
|
124
|
+
</Table.Row>
|
|
125
|
+
</Table.Header>
|
|
126
|
+
<Table.Body>
|
|
127
|
+
{sorted.map((row, i) => (
|
|
128
|
+
<Table.Row key={row.id} hideDivider={i === sorted.length - 1}>
|
|
129
|
+
<Table.Cell>{row.title}</Table.Cell>
|
|
130
|
+
<Table.Cell width={140}>
|
|
131
|
+
<Tag tone={row.tone} size="sm">
|
|
132
|
+
{row.status}
|
|
133
|
+
</Tag>
|
|
134
|
+
</Table.Cell>
|
|
135
|
+
<Table.Cell width={140}>{row.created}</Table.Cell>
|
|
136
|
+
</Table.Row>
|
|
137
|
+
))}
|
|
138
|
+
</Table.Body>
|
|
139
|
+
<Table.Footer>
|
|
140
|
+
<Pagination
|
|
141
|
+
currentPage={page}
|
|
142
|
+
totalPages={5}
|
|
143
|
+
onPageChange={setPage}
|
|
144
|
+
/>
|
|
145
|
+
</Table.Footer>
|
|
146
|
+
</Table>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Plugging in TanStack Table (or any other engine)
|
|
152
|
+
|
|
153
|
+
`@xsolla/xui-table` does **not** depend on TanStack — it's a consumer
|
|
154
|
+
choice. The toolkit renders rows; TanStack tells you which rows to render.
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
import {
|
|
158
|
+
useReactTable,
|
|
159
|
+
getCoreRowModel,
|
|
160
|
+
getSortedRowModel,
|
|
161
|
+
flexRender,
|
|
162
|
+
} from "@tanstack/react-table";
|
|
163
|
+
|
|
164
|
+
function TanStackPromotionsTable({ data, columns }) {
|
|
165
|
+
const table = useReactTable({
|
|
166
|
+
data,
|
|
167
|
+
columns,
|
|
168
|
+
getCoreRowModel: getCoreRowModel(),
|
|
169
|
+
getSortedRowModel: getSortedRowModel(),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<Table>
|
|
174
|
+
<Table.Header>
|
|
175
|
+
{table.getHeaderGroups().map((hg) => (
|
|
176
|
+
<Table.Row key={hg.id}>
|
|
177
|
+
{hg.headers.map((h) => (
|
|
178
|
+
<Table.Head
|
|
179
|
+
key={h.id}
|
|
180
|
+
sort={h.column.getIsSorted() || "none"}
|
|
181
|
+
onSortToggle={h.column.getToggleSortingHandler()}
|
|
182
|
+
>
|
|
183
|
+
{flexRender(h.column.columnDef.header, h.getContext())}
|
|
184
|
+
</Table.Head>
|
|
185
|
+
))}
|
|
186
|
+
</Table.Row>
|
|
187
|
+
))}
|
|
188
|
+
</Table.Header>
|
|
189
|
+
<Table.Body>
|
|
190
|
+
{table.getRowModel().rows.map((row, i, all) => (
|
|
191
|
+
<Table.Row key={row.id} hideDivider={i === all.length - 1}>
|
|
192
|
+
{row.getVisibleCells().map((cell) => (
|
|
193
|
+
<Table.Cell key={cell.id}>
|
|
194
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
195
|
+
</Table.Cell>
|
|
196
|
+
))}
|
|
197
|
+
</Table.Row>
|
|
198
|
+
))}
|
|
199
|
+
</Table.Body>
|
|
200
|
+
</Table>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Composing surrounding UI on the consumer side
|
|
206
|
+
|
|
207
|
+
Most production tables wrap extra UI around the data: a title block, a
|
|
208
|
+
filter panel, an item counter, a bulk-selection bar, and pagination. The
|
|
209
|
+
toolkit ships none of those — they're plain JSX. There are **two valid
|
|
210
|
+
positions** for this surrounding UI:
|
|
211
|
+
|
|
212
|
+
### A. Inside `<Table>`, above `<Table.Header>` (matches Figma)
|
|
213
|
+
|
|
214
|
+
The Figma "Filter panel" group sits inside the same rounded card as the
|
|
215
|
+
header and rows. Achieve this by dropping the surrounding UI as direct
|
|
216
|
+
children of `<Table>`.
|
|
217
|
+
|
|
218
|
+
`<Table>` ships **flush-stack** defaults (`containerPaddingVertical: 0`,
|
|
219
|
+
`containerGap: 0`) — its rowgroups (Header / Body / Footer) sit
|
|
220
|
+
edge-to-edge inside the card and the dividers do the visual separation.
|
|
221
|
+
Figma's master shows 24 px top/bottom padding and a 16 px gap between
|
|
222
|
+
groups, but that spacing only exists to host the filter-panel / counter
|
|
223
|
+
blocks; reusing it for rowgroups produces visible gaps between Header
|
|
224
|
+
and Body that look broken.
|
|
225
|
+
|
|
226
|
+
So each surrounding block composes its own spacing: wrap them in a
|
|
227
|
+
column-flex `<Box>` with `gap={16}`, give the wrapper `paddingTop={24}`
|
|
228
|
+
so it doesn't sit flush against the rounded card top edge, and have
|
|
229
|
+
each block use `paddingHorizontal={24}` to align with header / cell
|
|
230
|
+
padding. The Footer adds `paddingTop={16}` + `paddingBottom={24}` for
|
|
231
|
+
the same reason at the bottom.
|
|
232
|
+
|
|
233
|
+
```tsx
|
|
234
|
+
<Table>
|
|
235
|
+
{/* Wrapper owns gap + top padding */}
|
|
236
|
+
<Box flexDirection="column" gap={16} paddingTop={24}>
|
|
237
|
+
{/* Title block */}
|
|
238
|
+
<Box flexDirection="row" justifyContent="space-between" paddingHorizontal={24}>
|
|
239
|
+
<Box flexDirection="column" gap={4}>
|
|
240
|
+
<Text fontSize={24} fontWeight="600">Promotions</Text>
|
|
241
|
+
<Text fontSize={14} style={{ opacity: 0.6 }}>
|
|
242
|
+
Manage active and draft promotions
|
|
243
|
+
</Text>
|
|
244
|
+
</Box>
|
|
245
|
+
<Box flexDirection="row" gap={8}>
|
|
246
|
+
<Button variant="secondary">Export</Button>
|
|
247
|
+
<Button>New promotion</Button>
|
|
248
|
+
</Box>
|
|
249
|
+
</Box>
|
|
250
|
+
|
|
251
|
+
{/* Filter panel */}
|
|
252
|
+
<Box flexDirection="row" paddingHorizontal={24}>
|
|
253
|
+
<Input placeholder="Search" iconLeft={<Search size={18} />} />
|
|
254
|
+
</Box>
|
|
255
|
+
|
|
256
|
+
{/* Item counter */}
|
|
257
|
+
<Box flexDirection="row" justifyContent="space-between" paddingHorizontal={24}>
|
|
258
|
+
<Text aria-live="polite" style={{ opacity: 0.6 }}>{rows.length} items</Text>
|
|
259
|
+
<FlexButton variant="tertiary" size="sm" iconRight={<Reset size={14} />}>
|
|
260
|
+
Reset filters
|
|
261
|
+
</FlexButton>
|
|
262
|
+
</Box>
|
|
263
|
+
</Box>
|
|
264
|
+
|
|
265
|
+
<Table.Header>
|
|
266
|
+
<Table.Row>
|
|
267
|
+
<Table.Head>Title</Table.Head>
|
|
268
|
+
...
|
|
269
|
+
</Table.Row>
|
|
270
|
+
</Table.Header>
|
|
271
|
+
<Table.Body>
|
|
272
|
+
{rows.map((row) => (
|
|
273
|
+
// No `hideDivider` on the last row — the divider visually
|
|
274
|
+
// separates the body from the footer below.
|
|
275
|
+
<Table.Row key={row.id}>...</Table.Row>
|
|
276
|
+
))}
|
|
277
|
+
</Table.Body>
|
|
278
|
+
<Table.Footer paddingTop={16} paddingBottom={24}>
|
|
279
|
+
<Pagination ... />
|
|
280
|
+
</Table.Footer>
|
|
281
|
+
</Table>
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`<Table.Header>` paints a horizontal divider _below_ itself, so the
|
|
285
|
+
filter row above is visually separated from the column-header row
|
|
286
|
+
without the consumer needing to add their own divider.
|
|
287
|
+
|
|
288
|
+
### B. Outside `<Table>`, above the card
|
|
289
|
+
|
|
290
|
+
Useful when the bulk-selection bar should appear *above* the rounded card
|
|
291
|
+
(common for "X selected" toolbars that visually replace a page-level
|
|
292
|
+
header):
|
|
293
|
+
|
|
294
|
+
```tsx
|
|
295
|
+
<Box flexDirection="column" gap={12}>
|
|
296
|
+
{selected.size > 0 && (
|
|
297
|
+
<Box role="region" aria-live="polite" /* …action layout… */>
|
|
298
|
+
<Text>{selected.size} selected</Text>
|
|
299
|
+
<Button>Delete</Button>
|
|
300
|
+
</Box>
|
|
301
|
+
)}
|
|
302
|
+
<Table>
|
|
303
|
+
<Table.Header>...</Table.Header>
|
|
304
|
+
<Table.Body>...</Table.Body>
|
|
305
|
+
</Table>
|
|
306
|
+
</Box>
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
See the `Selectable` story.
|
|
310
|
+
|
|
311
|
+
## Pagination — pick the right component
|
|
312
|
+
|
|
313
|
+
Drop either of these into `<Table.Footer>`:
|
|
314
|
+
|
|
315
|
+
| Component | Use when |
|
|
316
|
+
|---|---|
|
|
317
|
+
| `<Pagination>` | Numeric pages — `Prev` / `1 2 3 …` / `Next` |
|
|
318
|
+
| `<ProgressStep>` (from `@xsolla/xui-stepper`) | Dot-style "step N of M" pagination |
|
|
319
|
+
|
|
320
|
+
`<Pagination>` is 1-indexed (`currentPage`, `totalPages`); `<ProgressStep>`
|
|
321
|
+
is 0-indexed (`activeStep`, `count`). When swapping between them, adjust
|
|
322
|
+
by `±1`.
|
|
323
|
+
|
|
324
|
+
## Stable height across pages (`Table.Body minRows`)
|
|
325
|
+
|
|
326
|
+
Paginated tables have three states that naturally render at different
|
|
327
|
+
heights and cause the card to jump:
|
|
328
|
+
|
|
329
|
+
1. **Full pages** — `pageSize` rows, divider stack between them.
|
|
330
|
+
2. **Partial last page** — fewer than `pageSize` rows.
|
|
331
|
+
3. **No-results state** — a single empty-state block.
|
|
332
|
+
|
|
333
|
+
Pass `minRows={pageSize}` to `<Table.Body>` and the body locks itself to
|
|
334
|
+
the full page height for all three:
|
|
335
|
+
|
|
336
|
+
```tsx
|
|
337
|
+
<Table>
|
|
338
|
+
<Table.Header>{/* … */}</Table.Header>
|
|
339
|
+
<Table.Body minRows={pageSize}>
|
|
340
|
+
{rows.length === 0 ? (
|
|
341
|
+
<Box style={{ flex: 1, /* …centered no-results message… */ }}>
|
|
342
|
+
No results
|
|
343
|
+
</Box>
|
|
344
|
+
) : (
|
|
345
|
+
rows.map((row, i) => (
|
|
346
|
+
<Table.Row key={row.id} hideDivider={i === rows.length - 1}>
|
|
347
|
+
{/* …cells… */}
|
|
348
|
+
</Table.Row>
|
|
349
|
+
))
|
|
350
|
+
)}
|
|
351
|
+
</Table.Body>
|
|
352
|
+
<Table.Footer>{/* …pagination… */}</Table.Footer>
|
|
353
|
+
</Table>
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Behavior:
|
|
357
|
+
|
|
358
|
+
- The body reserves a `min-height` of
|
|
359
|
+
`minRows × rowHeight + (minRows - 1) × dividerHeight` from
|
|
360
|
+
`theme.sizing.table`.
|
|
361
|
+
- When all children are `<Table.Row>` and there are fewer of them than
|
|
362
|
+
`minRows`, invisible placeholder slots are appended to fill the
|
|
363
|
+
difference. The last real row keeps its divider visible (mirroring how
|
|
364
|
+
a fully filled page looks); the last placeholder slot drops the
|
|
365
|
+
`+1px` divider so the body bottom matches a full page exactly.
|
|
366
|
+
- When the body holds a non-row child (e.g. your custom no-results
|
|
367
|
+
panel), no placeholders are appended — only the `min-height` is
|
|
368
|
+
enforced, so you can stretch the panel via `flex: 1` or `height:
|
|
369
|
+
100%`.
|
|
370
|
+
|
|
371
|
+
Always render `<Table.Footer>` in the same conditional state, otherwise
|
|
372
|
+
the footer disappearing on the no-results state will reintroduce a
|
|
373
|
+
height jump.
|
|
374
|
+
|
|
375
|
+
## Hide row actions until hover (`revealOnHover`)
|
|
376
|
+
|
|
377
|
+
```tsx
|
|
378
|
+
<Table.Row>
|
|
379
|
+
<Table.Cell>{row.title}</Table.Cell>
|
|
380
|
+
{/* …other cells… */}
|
|
381
|
+
<Table.Cell width={48} position="right" revealOnHover>
|
|
382
|
+
<IconButton icon={<MoreVr />} aria-label={`Actions for ${row.title}`} />
|
|
383
|
+
</Table.Cell>
|
|
384
|
+
</Table.Row>
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
The cell is hidden via `opacity: 0` + `pointer-events: none` (not
|
|
388
|
+
`visibility: hidden`), so keyboard Tab still focuses descendants. Focusing
|
|
389
|
+
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.
|
|
392
|
+
|
|
393
|
+
## Truncation + tooltip on long values
|
|
394
|
+
|
|
395
|
+
`<Table.Cell>` and `<Table.Head>` wrap string children in a single-line
|
|
396
|
+
`<Text>` with `text-overflow: ellipsis`. For long values, wrap them in a
|
|
397
|
+
`<Tooltip>` so the full text is reachable:
|
|
398
|
+
|
|
399
|
+
```tsx
|
|
400
|
+
<Table.Cell>
|
|
401
|
+
<Tooltip content={row.title}>
|
|
402
|
+
<span>{row.title}</span>
|
|
403
|
+
</Tooltip>
|
|
404
|
+
</Table.Cell>
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
## Loading: skeleton rows, not spinners
|
|
408
|
+
|
|
409
|
+
Render the same number of rows you expect to receive, with skeleton blocks
|
|
410
|
+
inside each `<Table.Cell>`. Column widths stay fixed → no layout shift
|
|
411
|
+
when real data arrives. See the `LoadingState` story.
|
|
412
|
+
|
|
413
|
+
## Empty cells: `aria-label` for `—`
|
|
414
|
+
|
|
415
|
+
```tsx
|
|
416
|
+
<Table.Cell aria-label="No value">—</Table.Cell>
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Keeps screen reader output meaningful.
|
|
420
|
+
|
|
421
|
+
## Scrolling
|
|
422
|
+
|
|
423
|
+
The table is unopinionated about scroll. Wrap it (or the part you want to
|
|
424
|
+
scroll) in a sized container.
|
|
425
|
+
|
|
426
|
+
### Vertical scroll with sticky header
|
|
427
|
+
|
|
428
|
+
`<Table.Header>` already uses `position: sticky; top: 0`. Wrap
|
|
429
|
+
`<Table.Body>` in a fixed-height `overflow-y: auto` container and the
|
|
430
|
+
header stays pinned while rows scroll. See the `Scrollable` story.
|
|
431
|
+
|
|
432
|
+
### Horizontal scroll
|
|
433
|
+
|
|
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.
|
|
439
|
+
|
|
440
|
+
```tsx
|
|
441
|
+
<Box style={{ width: "100%", overflowX: "auto" }}>
|
|
442
|
+
<Box style={{ minWidth: "max-content" }}>
|
|
443
|
+
<Table>...</Table>
|
|
444
|
+
</Box>
|
|
445
|
+
</Box>
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
Don't hard-code a pixel `min-width`. The actual row width is
|
|
449
|
+
`sum(cell widths) + (n - 1) × cellGap + 2 × rowPaddingHorizontal`, which
|
|
450
|
+
almost always undershoots if you eyeball it — and any miss leaves the
|
|
451
|
+
cells spilling outside the card's white background when you scroll.
|
|
452
|
+
`max-content` lets the browser compute the right value so the card and
|
|
453
|
+
the cells stay the same width. See the `HorizontalScroll` story.
|
|
454
|
+
|
|
455
|
+
### Frozen first column (optional)
|
|
456
|
+
|
|
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.
|
|
461
|
+
|
|
462
|
+
## Virtualization
|
|
463
|
+
|
|
464
|
+
Not built in. Either:
|
|
465
|
+
|
|
466
|
+
- Drop a virtualization wrapper around `<Table.Body>`'s children
|
|
467
|
+
(`@tanstack/react-virtual`, `react-window`), or
|
|
468
|
+
- Rely on server-side pagination via `<Table.Footer>` + `<Pagination>`.
|
|
469
|
+
|
|
470
|
+
Row height is fixed (`theme.sizing.table.rowHeight`, 56 px), so
|
|
471
|
+
fixed-height virtualizers work out of the box.
|
|
472
|
+
|
|
473
|
+
## Platform support
|
|
474
|
+
|
|
475
|
+
**Web only.** The package builds a `dist/native/` bundle to match the
|
|
476
|
+
workspace convention, but `Table` relies on behavior that doesn't exist
|
|
477
|
+
on React Native (`position: sticky`, DOM mouse and focus events, CSS
|
|
478
|
+
transitions). React Native is not supported.
|
|
479
|
+
|
|
480
|
+
For native screens, pair `FlatList` with your own row + cell components.
|
|
481
|
+
|
|
482
|
+
## At a glance
|
|
483
|
+
|
|
484
|
+
| | `@xsolla/xui-table` |
|
|
485
|
+
|---|---|
|
|
486
|
+
| Surface | `Table`, `Table.Caption`, `Table.Header`, `Table.Body`, `Table.Footer`, `Table.Row`, `Table.Head`, `Table.Cell` |
|
|
487
|
+
| State | None — bring your own (plain `useState`, TanStack Table, …) |
|
|
488
|
+
| Sort indicator | Built into `Table.Head` (symmetric dual-chevron + `aria-sort` for direction) |
|
|
489
|
+
| Hover-reveal cells | `revealOnHover` prop on `Table.Cell` |
|
|
490
|
+
| Sticky header | Built into `Table.Header` |
|
|
491
|
+
| Theming | Toolkit theme tokens (`theme.sizing.table`) |
|
|
492
|
+
| Cross-platform | Web only |
|
|
493
|
+
| Bundles a row data engine | No |
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import React__default, { ReactNode } from 'react';
|
|
3
|
+
import { BoxProps } from '@xsolla/xui-primitives-core';
|
|
4
|
+
import { ThemeOverrideProps } from '@xsolla/xui-core';
|
|
5
|
+
|
|
6
|
+
type TableCellAlign = "left" | "right" | "center";
|
|
7
|
+
type TableCellPosition = "default" | "left" | "right";
|
|
8
|
+
/**
|
|
9
|
+
* Structural primitives only:
|
|
10
|
+
*
|
|
11
|
+
* <Table>
|
|
12
|
+
* <Table.Caption /> ← optional <caption>-equivalent for a11y
|
|
13
|
+
* <Table.Header> ← <thead>-equivalent (sticky)
|
|
14
|
+
* <Table.Row>
|
|
15
|
+
* <Table.Head /> ← <th>-equivalent (sortable)
|
|
16
|
+
* </Table.Row>
|
|
17
|
+
* </Table.Header>
|
|
18
|
+
* <Table.Body> ← <tbody>-equivalent
|
|
19
|
+
* <Table.Row>
|
|
20
|
+
* <Table.Cell />
|
|
21
|
+
* </Table.Row>
|
|
22
|
+
* </Table.Body>
|
|
23
|
+
* <Table.Footer /> ← slot for pagination etc.
|
|
24
|
+
* </Table>
|
|
25
|
+
*
|
|
26
|
+
* Filter panels, item counters, title blocks, bulk-selection bars, and
|
|
27
|
+
* pagination components are NOT part of the surface — compose them on the
|
|
28
|
+
* consumer side around <Table>.
|
|
29
|
+
*
|
|
30
|
+
* Density is single-source from Figma (`theme.sizing.table`); no `size`
|
|
31
|
+
* prop. If Figma ever ships md/sm variants, this will become a function
|
|
32
|
+
* with a size argument again.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* `<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.
|
|
39
|
+
*/
|
|
40
|
+
interface TableProps extends ThemeOverrideProps {
|
|
41
|
+
children?: ReactNode;
|
|
42
|
+
}
|
|
43
|
+
interface TableCaptionProps extends BoxProps, ThemeOverrideProps {
|
|
44
|
+
children?: ReactNode;
|
|
45
|
+
}
|
|
46
|
+
interface TableHeaderProps extends BoxProps, ThemeOverrideProps {
|
|
47
|
+
children?: ReactNode;
|
|
48
|
+
}
|
|
49
|
+
interface TableBodyProps extends BoxProps, ThemeOverrideProps {
|
|
50
|
+
children?: ReactNode;
|
|
51
|
+
/**
|
|
52
|
+
* Reserve space for at least this many row slots. Useful for paginated
|
|
53
|
+
* tables where partial last pages or empty filter results would
|
|
54
|
+
* otherwise make the card height jump between states.
|
|
55
|
+
*
|
|
56
|
+
* When the body contains only `<Table.Row>` children and there are
|
|
57
|
+
* fewer of them than `minRows`, invisible placeholder slots are
|
|
58
|
+
* appended to fill the difference, and the last real row keeps its
|
|
59
|
+
* divider visible (mirroring a fully filled page).
|
|
60
|
+
*
|
|
61
|
+
* If the body contains non-row children (e.g. a custom empty-state
|
|
62
|
+
* block), no placeholders are appended — the body just reserves the
|
|
63
|
+
* full height via `min-height` so the consumer can stretch their
|
|
64
|
+
* empty-state to fill it.
|
|
65
|
+
*/
|
|
66
|
+
minRows?: number;
|
|
67
|
+
}
|
|
68
|
+
interface TableFooterProps extends BoxProps {
|
|
69
|
+
children?: ReactNode;
|
|
70
|
+
}
|
|
71
|
+
interface TableRowProps extends BoxProps, ThemeOverrideProps {
|
|
72
|
+
children?: ReactNode;
|
|
73
|
+
/** Highlight the row on hover. Defaults to true (body rows only). */
|
|
74
|
+
hoverable?: boolean;
|
|
75
|
+
/** Marks the row as selected. */
|
|
76
|
+
selected?: boolean;
|
|
77
|
+
/** Hide the bottom divider for this row. */
|
|
78
|
+
hideDivider?: boolean;
|
|
79
|
+
/** Click handler. When provided, the row becomes interactive. */
|
|
80
|
+
onPress?: () => void;
|
|
81
|
+
/**
|
|
82
|
+
* Optional focus events. The Row uses these internally to track
|
|
83
|
+
* focus-within so that descendant `revealOnHover` cells can show
|
|
84
|
+
* themselves when keyboard-focused; consumer handlers (if provided) are
|
|
85
|
+
* still invoked.
|
|
86
|
+
*/
|
|
87
|
+
onFocus?: (e: React.FocusEvent<HTMLElement>) => void;
|
|
88
|
+
onBlur?: (e: React.FocusEvent<HTMLElement>) => void;
|
|
89
|
+
}
|
|
90
|
+
interface TableCellProps extends Omit<BoxProps, "position">, ThemeOverrideProps {
|
|
91
|
+
children?: ReactNode;
|
|
92
|
+
/** Cell text alignment. Defaults to "left". */
|
|
93
|
+
align?: TableCellAlign;
|
|
94
|
+
/** Position within the row — controls padding (left = leading, right = trailing). */
|
|
95
|
+
position?: TableCellPosition;
|
|
96
|
+
/** Allow the cell to flex to fill space. */
|
|
97
|
+
grow?: number;
|
|
98
|
+
/**
|
|
99
|
+
* Hide the cell until the parent row is hovered or focused (via `:focus-within`).
|
|
100
|
+
* Typically used for `position="right"` action cells per Figma spec.
|
|
101
|
+
* Falls back to always-visible on touch devices.
|
|
102
|
+
*/
|
|
103
|
+
revealOnHover?: boolean;
|
|
104
|
+
}
|
|
105
|
+
interface TableHeadProps extends TableCellProps {
|
|
106
|
+
/** Sort direction. */
|
|
107
|
+
sort?: "ascending" | "descending" | "none";
|
|
108
|
+
/** Click to toggle sort. */
|
|
109
|
+
onSortToggle?: () => void;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
declare const Table: React__default.ForwardRefExoticComponent<TableProps & React__default.RefAttributes<any>> & {
|
|
113
|
+
Caption: React__default.ForwardRefExoticComponent<TableCaptionProps & React__default.RefAttributes<any>>;
|
|
114
|
+
Header: React__default.ForwardRefExoticComponent<TableHeaderProps & React__default.RefAttributes<any>>;
|
|
115
|
+
Body: React__default.ForwardRefExoticComponent<TableBodyProps & React__default.RefAttributes<any>>;
|
|
116
|
+
Footer: React__default.ForwardRefExoticComponent<TableFooterProps & React__default.RefAttributes<any>>;
|
|
117
|
+
Row: React__default.ForwardRefExoticComponent<TableRowProps & React__default.RefAttributes<any>>;
|
|
118
|
+
Head: React__default.ForwardRefExoticComponent<TableHeadProps & React__default.RefAttributes<any>>;
|
|
119
|
+
Cell: React__default.ForwardRefExoticComponent<TableCellProps & React__default.RefAttributes<any>>;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export { Table, type TableBodyProps, type TableCaptionProps, type TableCellAlign, type TableCellPosition, type TableCellProps, type TableFooterProps, type TableHeadProps, type TableHeaderProps, type TableProps, type TableRowProps };
|