@attrus-ds/core 0.2.0 → 0.3.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/LICENSE +27 -27
- package/dist/assets/flags/Brazil-rounded.svg +15 -15
- package/dist/assets/flags/Brazil-square.svg +16 -16
- package/dist/assets/flags/Chile-rounded.svg +10 -10
- package/dist/assets/flags/Chile-square.svg +10 -10
- package/dist/assets/flags/Colombia-rounded.svg +9 -9
- package/dist/assets/flags/Colombia-square.svg +9 -9
- package/dist/assets/flags/EUA-rounded.svg +10 -10
- package/dist/assets/flags/EUA-square.svg +10 -10
- package/dist/assets/flags/EUR-rounded.svg +8 -8
- package/dist/assets/flags/EUR-square.svg +8 -8
- package/dist/assets/flags/Mexico-rounded.svg +18 -18
- package/dist/assets/flags/Mexico-square.svg +18 -18
- package/dist/assets/icon-attrus-dark.svg +2 -2
- package/dist/assets/icon-attrus-light.svg +2 -2
- package/dist/assets/logo-attrus-dark.svg +7 -7
- package/dist/assets/logo-attrus-light.svg +7 -7
- package/dist/css/components/buttons.css +301 -301
- package/dist/css/components/callout.css +195 -195
- package/dist/css/components/cards.css +272 -272
- package/dist/css/components/combobox.css +319 -319
- package/dist/css/components/empty-states.css +61 -61
- package/dist/css/components/inputs.css +975 -975
- package/dist/css/components/number-input.css +138 -138
- package/dist/css/components/overlays.css +374 -374
- package/dist/css/components/primitives.css +314 -314
- package/dist/css/components/sidebar.css +179 -179
- package/dist/css/components/smart-search.css +91 -91
- package/dist/css/components/status-pills.css +207 -207
- package/dist/css/components/stepper.css +309 -309
- package/dist/css/components/table.css +602 -602
- package/dist/css/components/tabs.css +319 -319
- package/dist/css/components/toast.css +243 -243
- package/dist/css/components/tooltip.css +231 -231
- package/dist/css/components/topbar.css +148 -148
- package/dist/css/tokens.css +666 -666
- package/dist/react/AppSidebar/AppSidebar.d.ts +50 -50
- package/dist/react/AppSidebar/AppSidebar.tsx +197 -197
- package/dist/react/AppTopbar/AppTopbar.d.ts +58 -58
- package/dist/react/AppTopbar/AppTopbar.tsx +203 -203
- package/dist/react/Atoms/Atoms.d.ts +149 -149
- package/dist/react/Atoms/Atoms.tsx +407 -407
- package/dist/react/Button/Button.d.ts +25 -25
- package/dist/react/Button/Button.tsx +83 -83
- package/dist/react/Callout/Callout.d.ts +29 -29
- package/dist/react/Callout/Callout.tsx +98 -98
- package/dist/react/Card/Card.d.ts +38 -38
- package/dist/react/Card/Card.tsx +93 -93
- package/dist/react/Combobox/Combobox.d.ts +52 -52
- package/dist/react/Combobox/Combobox.tsx +254 -254
- package/dist/react/DataTable/DataTable.d.ts +81 -81
- package/dist/react/DataTable/DataTable.tsx +308 -308
- package/dist/react/Drawer/Drawer.d.ts +35 -35
- package/dist/react/Drawer/Drawer.tsx +226 -226
- package/dist/react/Input/Input.d.ts +155 -155
- package/dist/react/Input/Input.tsx +478 -478
- package/dist/react/Modal/Modal.d.ts +27 -27
- package/dist/react/Modal/Modal.tsx +173 -173
- package/dist/react/NumberInput/NumberInput.d.ts +25 -25
- package/dist/react/NumberInput/NumberInput.tsx +117 -117
- package/dist/react/Pill/Pill.d.ts +31 -31
- package/dist/react/Pill/Pill.tsx +104 -104
- package/dist/react/SearchPill/SearchPill.d.ts +14 -14
- package/dist/react/SearchPill/SearchPill.tsx +40 -40
- package/dist/react/SmartSearch/SmartSearch.d.ts +65 -65
- package/dist/react/SmartSearch/SmartSearch.tsx +271 -271
- package/dist/react/Stepper/Stepper.d.ts +25 -25
- package/dist/react/Stepper/Stepper.tsx +94 -94
- package/dist/react/Tabs/Tabs.d.ts +32 -32
- package/dist/react/Tabs/Tabs.tsx +147 -147
- package/dist/react/Toast/Toast.d.ts +30 -30
- package/dist/react/Toast/Toast.tsx +94 -94
- package/dist/react/Tooltip/Tooltip.d.ts +23 -23
- package/dist/react/Tooltip/Tooltip.tsx +80 -80
- package/dist/react/ZeroState/ZeroState.d.ts +17 -17
- package/dist/react/ZeroState/ZeroState.tsx +45 -45
- package/dist/react/index.ts +35 -35
- package/dist/tokens/attrus-tokens.flat.json +1234 -1234
- package/dist/tokens/attrus-tokens.json +1089 -1089
- package/package.json +64 -64
|
@@ -1,308 +1,308 @@
|
|
|
1
|
-
import * as React from 'react';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* ATTRUS DataTable — typed wrapper over the canonical `.dt` system
|
|
5
|
-
* (preview/components/table.css). CSS stays the visual source of truth.
|
|
6
|
-
*
|
|
7
|
-
* Renders the full anatomy: .dt-wrap > [.dt-toolbar] + .dt-responsive >
|
|
8
|
-
* table.dt + [.dt-pagination]. Below 560px (container query) rows
|
|
9
|
-
* collapse to label:value cards — string headers feed each td's
|
|
10
|
-
* data-label automatically.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
export type SortDir = 'asc' | 'desc';
|
|
14
|
-
export type DataTableSize = 'sm' | 'md' | 'lg';
|
|
15
|
-
export type RowState = 'muted' | 'error' | undefined;
|
|
16
|
-
|
|
17
|
-
export interface DataTableColumn<T> {
|
|
18
|
-
/** Unique column id — also what onSort receives. */
|
|
19
|
-
key: string;
|
|
20
|
-
/** Column header. Strings also become the responsive data-label. */
|
|
21
|
-
header: React.ReactNode;
|
|
22
|
-
/** Right-aligned tabular numerals (.dt-num). */
|
|
23
|
-
numeric?: boolean;
|
|
24
|
-
/** Monospace identifier (.dt-id). */
|
|
25
|
-
id?: boolean;
|
|
26
|
-
/** Sortable header (.dt-th-sort with chevron). */
|
|
27
|
-
sortable?: boolean;
|
|
28
|
-
/** Cell renderer — defaults to String(row[key]). */
|
|
29
|
-
render?: (row: T) => React.ReactNode;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface DataTableProps<T> {
|
|
33
|
-
columns: Array<DataTableColumn<T>>;
|
|
34
|
-
rows: T[];
|
|
35
|
-
/** Stable row identity. */
|
|
36
|
-
rowKey: (row: T) => string;
|
|
37
|
-
/** Makes rows hoverable + clickable (tr.hov). */
|
|
38
|
-
onRowClick?: (row: T) => void;
|
|
39
|
-
/** Row tint: 'muted' (cancelled) or 'error' (danger). */
|
|
40
|
-
rowState?: (row: T) => RowState;
|
|
41
|
-
/** Keys of persistently selected rows (.is-selected). */
|
|
42
|
-
selectedKeys?: string[];
|
|
43
|
-
/** Renders the `.col-select` checkbox column; called with the row key. Pair with selectedKeys. */
|
|
44
|
-
onSelectToggle?: (key: string) => void;
|
|
45
|
-
/** Header checkbox — toggle all (checked when every row key is in selectedKeys). */
|
|
46
|
-
onSelectAll?: () => void;
|
|
47
|
-
/** Loading state — renders `.dt-skeleton` shimmer rows instead of the body. */
|
|
48
|
-
loading?: boolean;
|
|
49
|
-
/** How many skeleton rows. Default 4. */
|
|
50
|
-
loadingRows?: number;
|
|
51
|
-
/** sm | md (default) | lg — .dt--sm / .dt--lg. */
|
|
52
|
-
size?: DataTableSize;
|
|
53
|
-
zebra?: boolean;
|
|
54
|
-
sticky?: boolean;
|
|
55
|
-
bordered?: boolean;
|
|
56
|
-
/** Dark-surface treatment (.dt.on-inverse). */
|
|
57
|
-
onInverse?: boolean;
|
|
58
|
-
/** Toolbar slot (.dt-toolbar) above the table. */
|
|
59
|
-
toolbar?: React.ReactNode;
|
|
60
|
-
/** Pagination slot (.dt-pagination) below the table. */
|
|
61
|
-
pagination?: React.ReactNode;
|
|
62
|
-
/** Surrounding card (.dt-wrap). Default true; disable inside a Card. */
|
|
63
|
-
wrap?: boolean;
|
|
64
|
-
/** Responsive card-collapse wrapper (.dt-responsive). Default true. */
|
|
65
|
-
responsive?: boolean;
|
|
66
|
-
/** Zero state when rows is empty. */
|
|
67
|
-
empty?: { icon?: React.ReactNode; title: React.ReactNode; sub?: React.ReactNode };
|
|
68
|
-
/** Controlled sorting (visual). */
|
|
69
|
-
sortKey?: string;
|
|
70
|
-
sortDir?: SortDir;
|
|
71
|
-
onSort?: (key: string) => void;
|
|
72
|
-
className?: string;
|
|
73
|
-
style?: React.CSSProperties;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const Chev: React.FC = () => (
|
|
77
|
-
<span className="chev" aria-hidden="true">
|
|
78
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
|
79
|
-
<path d="m6 9 6 6 6-6" />
|
|
80
|
-
</svg>
|
|
81
|
-
</span>
|
|
82
|
-
);
|
|
83
|
-
|
|
84
|
-
const SelectBox: React.FC<{ checked: boolean; onChange: () => void; label: string }> = ({ checked, onChange, label }) => (
|
|
85
|
-
<label className="check" onClick={(e) => e.stopPropagation()}>
|
|
86
|
-
<input type="checkbox" checked={checked} onChange={onChange} aria-label={label} />
|
|
87
|
-
<span className="check-box">
|
|
88
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
89
|
-
<path d="M20 6 9 17l-5-5" />
|
|
90
|
-
</svg>
|
|
91
|
-
</span>
|
|
92
|
-
</label>
|
|
93
|
-
);
|
|
94
|
-
|
|
95
|
-
export function DataTable<T>({
|
|
96
|
-
columns,
|
|
97
|
-
rows,
|
|
98
|
-
rowKey,
|
|
99
|
-
onRowClick,
|
|
100
|
-
rowState,
|
|
101
|
-
selectedKeys,
|
|
102
|
-
onSelectToggle,
|
|
103
|
-
onSelectAll,
|
|
104
|
-
loading = false,
|
|
105
|
-
loadingRows = 4,
|
|
106
|
-
size = 'md',
|
|
107
|
-
zebra = false,
|
|
108
|
-
sticky = false,
|
|
109
|
-
bordered = false,
|
|
110
|
-
onInverse = false,
|
|
111
|
-
toolbar,
|
|
112
|
-
pagination,
|
|
113
|
-
wrap = true,
|
|
114
|
-
responsive = true,
|
|
115
|
-
empty,
|
|
116
|
-
sortKey,
|
|
117
|
-
sortDir,
|
|
118
|
-
onSort,
|
|
119
|
-
className,
|
|
120
|
-
style,
|
|
121
|
-
}: DataTableProps<T>): React.ReactElement {
|
|
122
|
-
const tableCls = [
|
|
123
|
-
'dt',
|
|
124
|
-
size === 'sm' ? 'dt--sm' : '',
|
|
125
|
-
size === 'lg' ? 'dt--lg' : '',
|
|
126
|
-
zebra ? 'dt--zebra' : '',
|
|
127
|
-
sticky ? 'dt--sticky' : '',
|
|
128
|
-
bordered ? 'dt--bordered' : '',
|
|
129
|
-
onInverse ? 'on-inverse' : '',
|
|
130
|
-
]
|
|
131
|
-
.filter(Boolean)
|
|
132
|
-
.join(' ');
|
|
133
|
-
|
|
134
|
-
const selectable = Boolean(onSelectToggle);
|
|
135
|
-
const colCount = columns.length + (selectable ? 1 : 0);
|
|
136
|
-
const allSelected = selectable && rows.length > 0 && rows.every((r) => (selectedKeys || []).indexOf(rowKey(r)) !== -1);
|
|
137
|
-
|
|
138
|
-
const SKW = ['w-lg', 'w-md', 'w-sm'];
|
|
139
|
-
const table = (
|
|
140
|
-
<table className={tableCls} aria-busy={loading || undefined}>
|
|
141
|
-
<thead>
|
|
142
|
-
<tr>
|
|
143
|
-
{selectable ? (
|
|
144
|
-
<th className="col-select">
|
|
145
|
-
{onSelectAll ? <SelectBox checked={allSelected} onChange={onSelectAll} label="Select all rows" /> : null}
|
|
146
|
-
</th>
|
|
147
|
-
) : null}
|
|
148
|
-
{columns.map((c) => {
|
|
149
|
-
const sortCls = c.sortable
|
|
150
|
-
? ['dt-th-sort', sortKey === c.key && sortDir === 'asc' ? 'is-asc' : '', sortKey === c.key && sortDir === 'desc' ? 'is-desc' : '']
|
|
151
|
-
.filter(Boolean)
|
|
152
|
-
.join(' ')
|
|
153
|
-
: undefined;
|
|
154
|
-
return (
|
|
155
|
-
<th
|
|
156
|
-
key={c.key}
|
|
157
|
-
className={[c.numeric ? 'dt-num' : '', sortCls || ''].filter(Boolean).join(' ') || undefined}
|
|
158
|
-
onClick={c.sortable && onSort ? () => onSort(c.key) : undefined}
|
|
159
|
-
aria-sort={sortKey === c.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
|
|
160
|
-
>
|
|
161
|
-
{c.header}
|
|
162
|
-
{c.sortable ? <Chev /> : null}
|
|
163
|
-
</th>
|
|
164
|
-
);
|
|
165
|
-
})}
|
|
166
|
-
</tr>
|
|
167
|
-
</thead>
|
|
168
|
-
<tbody>
|
|
169
|
-
{loading ? (
|
|
170
|
-
Array.from({ length: loadingRows }, (_, i) => (
|
|
171
|
-
<tr key={`sk-${i}`} className="dt-skeleton" aria-hidden="true">
|
|
172
|
-
{selectable ? <td className="col-select"><span className="w-sm" style={{ width: 16 }} /></td> : null}
|
|
173
|
-
{columns.map((c, ci) => (
|
|
174
|
-
<td key={c.key}><span className={SKW[(i + ci) % SKW.length]} /></td>
|
|
175
|
-
))}
|
|
176
|
-
</tr>
|
|
177
|
-
))
|
|
178
|
-
) : rows.length === 0 && empty ? (
|
|
179
|
-
<tr className="dt-empty">
|
|
180
|
-
<td colSpan={colCount}>
|
|
181
|
-
{empty.icon ? <span className="ic">{empty.icon}</span> : null}
|
|
182
|
-
<span className="ttl">{empty.title}</span>
|
|
183
|
-
{empty.sub ? <span className="sub">{empty.sub}</span> : null}
|
|
184
|
-
</td>
|
|
185
|
-
</tr>
|
|
186
|
-
) : (
|
|
187
|
-
rows.map((row) => {
|
|
188
|
-
const k = rowKey(row);
|
|
189
|
-
const state = rowState ? rowState(row) : undefined;
|
|
190
|
-
const cls = [
|
|
191
|
-
onRowClick ? 'hov' : '',
|
|
192
|
-
state === 'muted' ? 'is-muted' : '',
|
|
193
|
-
state === 'error' ? 'is-error' : '',
|
|
194
|
-
selectedKeys && selectedKeys.indexOf(k) !== -1 ? 'is-selected' : '',
|
|
195
|
-
]
|
|
196
|
-
.filter(Boolean)
|
|
197
|
-
.join(' ');
|
|
198
|
-
return (
|
|
199
|
-
<tr
|
|
200
|
-
key={k}
|
|
201
|
-
className={cls || undefined}
|
|
202
|
-
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
203
|
-
style={onRowClick ? { cursor: 'pointer' } : undefined}
|
|
204
|
-
>
|
|
205
|
-
{selectable ? (
|
|
206
|
-
<td className="col-select" data-label="">
|
|
207
|
-
<SelectBox
|
|
208
|
-
checked={(selectedKeys || []).indexOf(k) !== -1}
|
|
209
|
-
onChange={() => onSelectToggle!(k)}
|
|
210
|
-
label="Select row"
|
|
211
|
-
/>
|
|
212
|
-
</td>
|
|
213
|
-
) : null}
|
|
214
|
-
{columns.map((c) => (
|
|
215
|
-
<td
|
|
216
|
-
key={c.key}
|
|
217
|
-
className={[c.numeric ? 'dt-num' : '', c.id ? 'dt-id' : ''].filter(Boolean).join(' ') || undefined}
|
|
218
|
-
data-label={typeof c.header === 'string' ? c.header : undefined}
|
|
219
|
-
>
|
|
220
|
-
{c.render ? c.render(row) : String((row as Record<string, unknown>)[c.key] ?? '')}
|
|
221
|
-
</td>
|
|
222
|
-
))}
|
|
223
|
-
</tr>
|
|
224
|
-
);
|
|
225
|
-
})
|
|
226
|
-
)}
|
|
227
|
-
</tbody>
|
|
228
|
-
</table>
|
|
229
|
-
);
|
|
230
|
-
|
|
231
|
-
const inner = responsive ? <div className="dt-responsive">{table}</div> : table;
|
|
232
|
-
|
|
233
|
-
if (!wrap) return inner;
|
|
234
|
-
|
|
235
|
-
return (
|
|
236
|
-
<div className={['dt-wrap', className || ''].filter(Boolean).join(' ')} style={style}>
|
|
237
|
-
{toolbar ? <div className="dt-toolbar">{toolbar}</div> : null}
|
|
238
|
-
{inner}
|
|
239
|
-
{pagination ? <div className="dt-pagination">{pagination}</div> : null}
|
|
240
|
-
</div>
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
export default DataTable;
|
|
245
|
-
|
|
246
|
-
/* ----------------------------------------------------------------
|
|
247
|
-
Typed cell-pattern helpers — compose inside a column's render().
|
|
248
|
-
---------------------------------------------------------------- */
|
|
249
|
-
|
|
250
|
-
/** Stacked primary + secondary lines — `.dt-cell-stack` (.pri / .sec). */
|
|
251
|
-
export const DTCellStack: React.FC<{ pri: React.ReactNode; sec?: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>> = ({ pri, sec, className, ...rest }) => (
|
|
252
|
-
<div className={['dt-cell-stack', className || ''].filter(Boolean).join(' ')} {...rest}>
|
|
253
|
-
<span className="pri">{pri}</span>
|
|
254
|
-
{sec != null ? <span className="sec">{sec}</span> : null}
|
|
255
|
-
</div>
|
|
256
|
-
);
|
|
257
|
-
|
|
258
|
-
/** Leading avatar/icon + stacked label — `.dt-cell-lead`. */
|
|
259
|
-
export const DTCellLead: React.FC<{ lead: React.ReactNode; pri: React.ReactNode; sec?: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>> = ({ lead, pri, sec, className, ...rest }) => (
|
|
260
|
-
<div className={['dt-cell-lead', className || ''].filter(Boolean).join(' ')} {...rest}>
|
|
261
|
-
{lead}
|
|
262
|
-
<div className="dt-cell-stack">
|
|
263
|
-
<span className="pri">{pri}</span>
|
|
264
|
-
{sec != null ? <span className="sec">{sec}</span> : null}
|
|
265
|
-
</div>
|
|
266
|
-
</div>
|
|
267
|
-
);
|
|
268
|
-
|
|
269
|
-
/** Trailing actions cluster — `.dt-actions` (ghost icon buttons). */
|
|
270
|
-
export const DTActions: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...rest }) => (
|
|
271
|
-
<div className={['dt-actions', className || ''].filter(Boolean).join(' ')} {...rest} />
|
|
272
|
-
);
|
|
273
|
-
|
|
274
|
-
export interface DTPaginationProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
275
|
-
/** Left-side mono range text (e.g. "1–10 of 42"). */
|
|
276
|
-
range?: React.ReactNode;
|
|
277
|
-
/** Current page (1-based). */
|
|
278
|
-
page: number;
|
|
279
|
-
/** Total pages. */
|
|
280
|
-
pages: number;
|
|
281
|
-
onPage?: (page: number) => void;
|
|
282
|
-
}
|
|
283
|
-
/** Canonical pagination row — `.range` + `.pager` with `.page-btn`s (prev · numbered · next).
|
|
284
|
-
Pass as the DataTable `pagination` slot. */
|
|
285
|
-
export const DTPagination: React.FC<DTPaginationProps> = ({ range, page, pages, onPage, className, ...rest }) => (
|
|
286
|
-
<div className={className} style={{ display: 'contents' }} {...rest}>
|
|
287
|
-
{range != null ? <span className="range">{range}</span> : null}
|
|
288
|
-
<nav className="pager" aria-label="Pagination">
|
|
289
|
-
<button type="button" className="page-btn" disabled={page <= 1} onClick={() => onPage && onPage(page - 1)} aria-label="Previous page">
|
|
290
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6" /></svg>
|
|
291
|
-
</button>
|
|
292
|
-
{Array.from({ length: pages }, (_, i) => i + 1).map((p) => (
|
|
293
|
-
<button
|
|
294
|
-
key={p}
|
|
295
|
-
type="button"
|
|
296
|
-
className={p === page ? 'page-btn is-current' : 'page-btn'}
|
|
297
|
-
aria-current={p === page ? 'page' : undefined}
|
|
298
|
-
onClick={() => onPage && onPage(p)}
|
|
299
|
-
>
|
|
300
|
-
{p}
|
|
301
|
-
</button>
|
|
302
|
-
))}
|
|
303
|
-
<button type="button" className="page-btn" disabled={page >= pages} onClick={() => onPage && onPage(page + 1)} aria-label="Next page">
|
|
304
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
|
|
305
|
-
</button>
|
|
306
|
-
</nav>
|
|
307
|
-
</div>
|
|
308
|
-
);
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ATTRUS DataTable — typed wrapper over the canonical `.dt` system
|
|
5
|
+
* (preview/components/table.css). CSS stays the visual source of truth.
|
|
6
|
+
*
|
|
7
|
+
* Renders the full anatomy: .dt-wrap > [.dt-toolbar] + .dt-responsive >
|
|
8
|
+
* table.dt + [.dt-pagination]. Below 560px (container query) rows
|
|
9
|
+
* collapse to label:value cards — string headers feed each td's
|
|
10
|
+
* data-label automatically.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type SortDir = 'asc' | 'desc';
|
|
14
|
+
export type DataTableSize = 'sm' | 'md' | 'lg';
|
|
15
|
+
export type RowState = 'muted' | 'error' | undefined;
|
|
16
|
+
|
|
17
|
+
export interface DataTableColumn<T> {
|
|
18
|
+
/** Unique column id — also what onSort receives. */
|
|
19
|
+
key: string;
|
|
20
|
+
/** Column header. Strings also become the responsive data-label. */
|
|
21
|
+
header: React.ReactNode;
|
|
22
|
+
/** Right-aligned tabular numerals (.dt-num). */
|
|
23
|
+
numeric?: boolean;
|
|
24
|
+
/** Monospace identifier (.dt-id). */
|
|
25
|
+
id?: boolean;
|
|
26
|
+
/** Sortable header (.dt-th-sort with chevron). */
|
|
27
|
+
sortable?: boolean;
|
|
28
|
+
/** Cell renderer — defaults to String(row[key]). */
|
|
29
|
+
render?: (row: T) => React.ReactNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DataTableProps<T> {
|
|
33
|
+
columns: Array<DataTableColumn<T>>;
|
|
34
|
+
rows: T[];
|
|
35
|
+
/** Stable row identity. */
|
|
36
|
+
rowKey: (row: T) => string;
|
|
37
|
+
/** Makes rows hoverable + clickable (tr.hov). */
|
|
38
|
+
onRowClick?: (row: T) => void;
|
|
39
|
+
/** Row tint: 'muted' (cancelled) or 'error' (danger). */
|
|
40
|
+
rowState?: (row: T) => RowState;
|
|
41
|
+
/** Keys of persistently selected rows (.is-selected). */
|
|
42
|
+
selectedKeys?: string[];
|
|
43
|
+
/** Renders the `.col-select` checkbox column; called with the row key. Pair with selectedKeys. */
|
|
44
|
+
onSelectToggle?: (key: string) => void;
|
|
45
|
+
/** Header checkbox — toggle all (checked when every row key is in selectedKeys). */
|
|
46
|
+
onSelectAll?: () => void;
|
|
47
|
+
/** Loading state — renders `.dt-skeleton` shimmer rows instead of the body. */
|
|
48
|
+
loading?: boolean;
|
|
49
|
+
/** How many skeleton rows. Default 4. */
|
|
50
|
+
loadingRows?: number;
|
|
51
|
+
/** sm | md (default) | lg — .dt--sm / .dt--lg. */
|
|
52
|
+
size?: DataTableSize;
|
|
53
|
+
zebra?: boolean;
|
|
54
|
+
sticky?: boolean;
|
|
55
|
+
bordered?: boolean;
|
|
56
|
+
/** Dark-surface treatment (.dt.on-inverse). */
|
|
57
|
+
onInverse?: boolean;
|
|
58
|
+
/** Toolbar slot (.dt-toolbar) above the table. */
|
|
59
|
+
toolbar?: React.ReactNode;
|
|
60
|
+
/** Pagination slot (.dt-pagination) below the table. */
|
|
61
|
+
pagination?: React.ReactNode;
|
|
62
|
+
/** Surrounding card (.dt-wrap). Default true; disable inside a Card. */
|
|
63
|
+
wrap?: boolean;
|
|
64
|
+
/** Responsive card-collapse wrapper (.dt-responsive). Default true. */
|
|
65
|
+
responsive?: boolean;
|
|
66
|
+
/** Zero state when rows is empty. */
|
|
67
|
+
empty?: { icon?: React.ReactNode; title: React.ReactNode; sub?: React.ReactNode };
|
|
68
|
+
/** Controlled sorting (visual). */
|
|
69
|
+
sortKey?: string;
|
|
70
|
+
sortDir?: SortDir;
|
|
71
|
+
onSort?: (key: string) => void;
|
|
72
|
+
className?: string;
|
|
73
|
+
style?: React.CSSProperties;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const Chev: React.FC = () => (
|
|
77
|
+
<span className="chev" aria-hidden="true">
|
|
78
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
|
79
|
+
<path d="m6 9 6 6 6-6" />
|
|
80
|
+
</svg>
|
|
81
|
+
</span>
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const SelectBox: React.FC<{ checked: boolean; onChange: () => void; label: string }> = ({ checked, onChange, label }) => (
|
|
85
|
+
<label className="check" onClick={(e) => e.stopPropagation()}>
|
|
86
|
+
<input type="checkbox" checked={checked} onChange={onChange} aria-label={label} />
|
|
87
|
+
<span className="check-box">
|
|
88
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
89
|
+
<path d="M20 6 9 17l-5-5" />
|
|
90
|
+
</svg>
|
|
91
|
+
</span>
|
|
92
|
+
</label>
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
export function DataTable<T>({
|
|
96
|
+
columns,
|
|
97
|
+
rows,
|
|
98
|
+
rowKey,
|
|
99
|
+
onRowClick,
|
|
100
|
+
rowState,
|
|
101
|
+
selectedKeys,
|
|
102
|
+
onSelectToggle,
|
|
103
|
+
onSelectAll,
|
|
104
|
+
loading = false,
|
|
105
|
+
loadingRows = 4,
|
|
106
|
+
size = 'md',
|
|
107
|
+
zebra = false,
|
|
108
|
+
sticky = false,
|
|
109
|
+
bordered = false,
|
|
110
|
+
onInverse = false,
|
|
111
|
+
toolbar,
|
|
112
|
+
pagination,
|
|
113
|
+
wrap = true,
|
|
114
|
+
responsive = true,
|
|
115
|
+
empty,
|
|
116
|
+
sortKey,
|
|
117
|
+
sortDir,
|
|
118
|
+
onSort,
|
|
119
|
+
className,
|
|
120
|
+
style,
|
|
121
|
+
}: DataTableProps<T>): React.ReactElement {
|
|
122
|
+
const tableCls = [
|
|
123
|
+
'dt',
|
|
124
|
+
size === 'sm' ? 'dt--sm' : '',
|
|
125
|
+
size === 'lg' ? 'dt--lg' : '',
|
|
126
|
+
zebra ? 'dt--zebra' : '',
|
|
127
|
+
sticky ? 'dt--sticky' : '',
|
|
128
|
+
bordered ? 'dt--bordered' : '',
|
|
129
|
+
onInverse ? 'on-inverse' : '',
|
|
130
|
+
]
|
|
131
|
+
.filter(Boolean)
|
|
132
|
+
.join(' ');
|
|
133
|
+
|
|
134
|
+
const selectable = Boolean(onSelectToggle);
|
|
135
|
+
const colCount = columns.length + (selectable ? 1 : 0);
|
|
136
|
+
const allSelected = selectable && rows.length > 0 && rows.every((r) => (selectedKeys || []).indexOf(rowKey(r)) !== -1);
|
|
137
|
+
|
|
138
|
+
const SKW = ['w-lg', 'w-md', 'w-sm'];
|
|
139
|
+
const table = (
|
|
140
|
+
<table className={tableCls} aria-busy={loading || undefined}>
|
|
141
|
+
<thead>
|
|
142
|
+
<tr>
|
|
143
|
+
{selectable ? (
|
|
144
|
+
<th className="col-select">
|
|
145
|
+
{onSelectAll ? <SelectBox checked={allSelected} onChange={onSelectAll} label="Select all rows" /> : null}
|
|
146
|
+
</th>
|
|
147
|
+
) : null}
|
|
148
|
+
{columns.map((c) => {
|
|
149
|
+
const sortCls = c.sortable
|
|
150
|
+
? ['dt-th-sort', sortKey === c.key && sortDir === 'asc' ? 'is-asc' : '', sortKey === c.key && sortDir === 'desc' ? 'is-desc' : '']
|
|
151
|
+
.filter(Boolean)
|
|
152
|
+
.join(' ')
|
|
153
|
+
: undefined;
|
|
154
|
+
return (
|
|
155
|
+
<th
|
|
156
|
+
key={c.key}
|
|
157
|
+
className={[c.numeric ? 'dt-num' : '', sortCls || ''].filter(Boolean).join(' ') || undefined}
|
|
158
|
+
onClick={c.sortable && onSort ? () => onSort(c.key) : undefined}
|
|
159
|
+
aria-sort={sortKey === c.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
|
|
160
|
+
>
|
|
161
|
+
{c.header}
|
|
162
|
+
{c.sortable ? <Chev /> : null}
|
|
163
|
+
</th>
|
|
164
|
+
);
|
|
165
|
+
})}
|
|
166
|
+
</tr>
|
|
167
|
+
</thead>
|
|
168
|
+
<tbody>
|
|
169
|
+
{loading ? (
|
|
170
|
+
Array.from({ length: loadingRows }, (_, i) => (
|
|
171
|
+
<tr key={`sk-${i}`} className="dt-skeleton" aria-hidden="true">
|
|
172
|
+
{selectable ? <td className="col-select"><span className="w-sm" style={{ width: 16 }} /></td> : null}
|
|
173
|
+
{columns.map((c, ci) => (
|
|
174
|
+
<td key={c.key}><span className={SKW[(i + ci) % SKW.length]} /></td>
|
|
175
|
+
))}
|
|
176
|
+
</tr>
|
|
177
|
+
))
|
|
178
|
+
) : rows.length === 0 && empty ? (
|
|
179
|
+
<tr className="dt-empty">
|
|
180
|
+
<td colSpan={colCount}>
|
|
181
|
+
{empty.icon ? <span className="ic">{empty.icon}</span> : null}
|
|
182
|
+
<span className="ttl">{empty.title}</span>
|
|
183
|
+
{empty.sub ? <span className="sub">{empty.sub}</span> : null}
|
|
184
|
+
</td>
|
|
185
|
+
</tr>
|
|
186
|
+
) : (
|
|
187
|
+
rows.map((row) => {
|
|
188
|
+
const k = rowKey(row);
|
|
189
|
+
const state = rowState ? rowState(row) : undefined;
|
|
190
|
+
const cls = [
|
|
191
|
+
onRowClick ? 'hov' : '',
|
|
192
|
+
state === 'muted' ? 'is-muted' : '',
|
|
193
|
+
state === 'error' ? 'is-error' : '',
|
|
194
|
+
selectedKeys && selectedKeys.indexOf(k) !== -1 ? 'is-selected' : '',
|
|
195
|
+
]
|
|
196
|
+
.filter(Boolean)
|
|
197
|
+
.join(' ');
|
|
198
|
+
return (
|
|
199
|
+
<tr
|
|
200
|
+
key={k}
|
|
201
|
+
className={cls || undefined}
|
|
202
|
+
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
203
|
+
style={onRowClick ? { cursor: 'pointer' } : undefined}
|
|
204
|
+
>
|
|
205
|
+
{selectable ? (
|
|
206
|
+
<td className="col-select" data-label="">
|
|
207
|
+
<SelectBox
|
|
208
|
+
checked={(selectedKeys || []).indexOf(k) !== -1}
|
|
209
|
+
onChange={() => onSelectToggle!(k)}
|
|
210
|
+
label="Select row"
|
|
211
|
+
/>
|
|
212
|
+
</td>
|
|
213
|
+
) : null}
|
|
214
|
+
{columns.map((c) => (
|
|
215
|
+
<td
|
|
216
|
+
key={c.key}
|
|
217
|
+
className={[c.numeric ? 'dt-num' : '', c.id ? 'dt-id' : ''].filter(Boolean).join(' ') || undefined}
|
|
218
|
+
data-label={typeof c.header === 'string' ? c.header : undefined}
|
|
219
|
+
>
|
|
220
|
+
{c.render ? c.render(row) : String((row as Record<string, unknown>)[c.key] ?? '')}
|
|
221
|
+
</td>
|
|
222
|
+
))}
|
|
223
|
+
</tr>
|
|
224
|
+
);
|
|
225
|
+
})
|
|
226
|
+
)}
|
|
227
|
+
</tbody>
|
|
228
|
+
</table>
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const inner = responsive ? <div className="dt-responsive">{table}</div> : table;
|
|
232
|
+
|
|
233
|
+
if (!wrap) return inner;
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<div className={['dt-wrap', className || ''].filter(Boolean).join(' ')} style={style}>
|
|
237
|
+
{toolbar ? <div className="dt-toolbar">{toolbar}</div> : null}
|
|
238
|
+
{inner}
|
|
239
|
+
{pagination ? <div className="dt-pagination">{pagination}</div> : null}
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export default DataTable;
|
|
245
|
+
|
|
246
|
+
/* ----------------------------------------------------------------
|
|
247
|
+
Typed cell-pattern helpers — compose inside a column's render().
|
|
248
|
+
---------------------------------------------------------------- */
|
|
249
|
+
|
|
250
|
+
/** Stacked primary + secondary lines — `.dt-cell-stack` (.pri / .sec). */
|
|
251
|
+
export const DTCellStack: React.FC<{ pri: React.ReactNode; sec?: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>> = ({ pri, sec, className, ...rest }) => (
|
|
252
|
+
<div className={['dt-cell-stack', className || ''].filter(Boolean).join(' ')} {...rest}>
|
|
253
|
+
<span className="pri">{pri}</span>
|
|
254
|
+
{sec != null ? <span className="sec">{sec}</span> : null}
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
/** Leading avatar/icon + stacked label — `.dt-cell-lead`. */
|
|
259
|
+
export const DTCellLead: React.FC<{ lead: React.ReactNode; pri: React.ReactNode; sec?: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>> = ({ lead, pri, sec, className, ...rest }) => (
|
|
260
|
+
<div className={['dt-cell-lead', className || ''].filter(Boolean).join(' ')} {...rest}>
|
|
261
|
+
{lead}
|
|
262
|
+
<div className="dt-cell-stack">
|
|
263
|
+
<span className="pri">{pri}</span>
|
|
264
|
+
{sec != null ? <span className="sec">{sec}</span> : null}
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
/** Trailing actions cluster — `.dt-actions` (ghost icon buttons). */
|
|
270
|
+
export const DTActions: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...rest }) => (
|
|
271
|
+
<div className={['dt-actions', className || ''].filter(Boolean).join(' ')} {...rest} />
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
export interface DTPaginationProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
275
|
+
/** Left-side mono range text (e.g. "1–10 of 42"). */
|
|
276
|
+
range?: React.ReactNode;
|
|
277
|
+
/** Current page (1-based). */
|
|
278
|
+
page: number;
|
|
279
|
+
/** Total pages. */
|
|
280
|
+
pages: number;
|
|
281
|
+
onPage?: (page: number) => void;
|
|
282
|
+
}
|
|
283
|
+
/** Canonical pagination row — `.range` + `.pager` with `.page-btn`s (prev · numbered · next).
|
|
284
|
+
Pass as the DataTable `pagination` slot. */
|
|
285
|
+
export const DTPagination: React.FC<DTPaginationProps> = ({ range, page, pages, onPage, className, ...rest }) => (
|
|
286
|
+
<div className={className} style={{ display: 'contents' }} {...rest}>
|
|
287
|
+
{range != null ? <span className="range">{range}</span> : null}
|
|
288
|
+
<nav className="pager" aria-label="Pagination">
|
|
289
|
+
<button type="button" className="page-btn" disabled={page <= 1} onClick={() => onPage && onPage(page - 1)} aria-label="Previous page">
|
|
290
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6" /></svg>
|
|
291
|
+
</button>
|
|
292
|
+
{Array.from({ length: pages }, (_, i) => i + 1).map((p) => (
|
|
293
|
+
<button
|
|
294
|
+
key={p}
|
|
295
|
+
type="button"
|
|
296
|
+
className={p === page ? 'page-btn is-current' : 'page-btn'}
|
|
297
|
+
aria-current={p === page ? 'page' : undefined}
|
|
298
|
+
onClick={() => onPage && onPage(p)}
|
|
299
|
+
>
|
|
300
|
+
{p}
|
|
301
|
+
</button>
|
|
302
|
+
))}
|
|
303
|
+
<button type="button" className="page-btn" disabled={page >= pages} onClick={() => onPage && onPage(page + 1)} aria-label="Next page">
|
|
304
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
|
|
305
|
+
</button>
|
|
306
|
+
</nav>
|
|
307
|
+
</div>
|
|
308
|
+
);
|