@attrus-ds/core 1.0.13 → 1.0.14

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.
@@ -13,6 +13,9 @@ export interface DataTableColumn<T> {
13
13
  numeric?: boolean;
14
14
  /** Monospace identifier (.dt-id). */
15
15
  id?: boolean;
16
+ /** Strike this cell's figure through — an annulled amount inside a row that is
17
+ still live. A fully cancelled row uses `rowState: 'muted'` instead. */
18
+ isVoid?: (row: T) => boolean;
16
19
  /** Sortable header (.dt-th-sort with chevron). */
17
20
  sortable?: boolean;
18
21
  /** Cell renderer — defaults to String(row[key]). */
@@ -66,8 +69,73 @@ export interface DataTableProps<T> {
66
69
  export declare function DataTable<T>(props: DataTableProps<T>): React.ReactElement;
67
70
  export declare const DTCellStack: React.FC<{ pri: React.ReactNode; sec?: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>>;
68
71
  export declare const DTCellLead: React.FC<{ lead: React.ReactNode; pri: React.ReactNode; sec?: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>>;
72
+ export interface DTBadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
73
+ children?: React.ReactNode;
74
+ tone?: 'success' | 'warning' | 'danger' | 'info';
75
+ /** After the label instead of before it. */
76
+ trailing?: boolean;
77
+ size?: 'sm' | 'md';
78
+ }
79
+ export declare const DTBadge: React.FC<DTBadgeProps>;
80
+
81
+ export interface DTDeltaProps extends React.HTMLAttributes<HTMLSpanElement> {
82
+ children?: React.ReactNode;
83
+ direction: 'up' | 'down' | 'flat';
84
+ /** Screen-reader phrasing, e.g. "up 12.4% versus last month". */
85
+ label?: string;
86
+ }
87
+ export declare const DTDelta: React.FC<DTDeltaProps>;
88
+
69
89
  export declare const DTActions: React.FC<React.HTMLAttributes<HTMLDivElement>>;
70
90
 
91
+ /** Lead cell of a subrow — connector glyph, optional mini icon tile, label.
92
+ `parentName` renders a visually-hidden "Item of <parent>:" prefix, since
93
+ native table semantics carry no hierarchy. */
94
+ export interface DTGroupDividerProps extends React.HTMLAttributes<HTMLTableRowElement> {
95
+ label: React.ReactNode;
96
+ colSpan: number;
97
+ meta?: React.ReactNode;
98
+ }
99
+ export declare function DTGroupDivider(props: DTGroupDividerProps): React.ReactElement;
100
+
101
+ export interface DTDetailProps extends React.HTMLAttributes<HTMLTableRowElement> {
102
+ colSpan: number;
103
+ open?: boolean;
104
+ title?: React.ReactNode;
105
+ children?: React.ReactNode;
106
+ }
107
+ export declare function DTDetail(props: DTDetailProps): React.ReactElement;
108
+
109
+ export declare const DTToolbar: React.FC<{
110
+ lead?: React.ReactNode;
111
+ filters?: React.ReactNode;
112
+ actions?: React.ReactNode;
113
+ /** Selection state — same bar, preserved height. */
114
+ selecting?: boolean;
115
+ size?: 'md' | 'sm';
116
+ } & React.HTMLAttributes<HTMLDivElement>>;
117
+
118
+ export declare const DTDetailCard: React.FC<React.ButtonHTMLAttributes<HTMLButtonElement>>;
119
+
120
+ export declare const DTSubrowLead: React.FC<{
121
+ label: React.ReactNode;
122
+ icon?: React.ReactNode;
123
+ parentName?: string;
124
+ } & React.HTMLAttributes<HTMLDivElement>>;
125
+
126
+ /** Disclosure control for a collapsible subrow group — goes in the parent's
127
+ lead cell. `controls` must match the subrow's id. */
128
+ export declare const DTSubrowToggle: React.FC<{
129
+ expanded: boolean;
130
+ onToggle: () => void;
131
+ controls: string;
132
+ count?: number;
133
+ label?: string;
134
+ } & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'aria-expanded'>>;
135
+
136
+ /** Animated wrapper for a collapsible subrow's cell content (grid 0fr → 1fr). */
137
+ export declare const DTSubrowCell: React.FC<React.HTMLAttributes<HTMLDivElement>>;
138
+
71
139
  export interface DTPaginationProps extends React.HTMLAttributes<HTMLDivElement> {
72
140
  /** Left-side mono range text (e.g. "1–10 of 42"). */
73
141
  range?: React.ReactNode;
@@ -75,7 +143,30 @@ export interface DTPaginationProps extends React.HTMLAttributes<HTMLDivElement>
75
143
  page: number;
76
144
  pages: number;
77
145
  onPage?: (page: number) => void;
146
+ /** Rows-per-page choices, rendered as a segmented pill (short fixed sets only
147
+ — a <select> hides the options behind a tap). Rendering this implies the
148
+ caller re-fetches and resets to page 1. */
149
+ pageSizes?: number[];
150
+ pageSize?: number;
151
+ onPageSize?: (size: number) => void;
78
152
  }
153
+ export interface DTParentRowProps extends React.HTMLAttributes<HTMLTableRowElement> {
154
+ children?: React.ReactNode;
155
+ /** subrows share the parent's columns; a detail panel does not. */
156
+ kind?: 'subrows' | 'detail';
157
+ open?: boolean;
158
+ onToggle?: () => void;
159
+ controls?: string;
160
+ }
161
+ export declare const DTId: React.FC<React.HTMLAttributes<HTMLSpanElement>>;
162
+ export declare const DTCheckCell: React.FC<React.TdHTMLAttributes<HTMLTableCellElement>>;
163
+
164
+ export declare const DTSubrowMeta: React.FC<{ tag?: React.ReactNode; status?: React.ReactNode; count?: React.ReactNode }>;
165
+ export declare const DTDetailGrid: React.FC<React.HTMLAttributes<HTMLDivElement>>;
166
+ export declare const DTScrollY: React.FC<React.HTMLAttributes<HTMLDivElement>>;
167
+
168
+ export declare const DTParentRow: React.FC<DTParentRowProps>;
169
+
79
170
  export declare const DTPagination: React.FC<DTPaginationProps>;
80
171
 
81
172
  export default DataTable;
@@ -10,6 +10,13 @@ import * as React from 'react';
10
10
  * data-label automatically.
11
11
  */
12
12
 
13
+ /* Is this slot content already the canonical bar? Compared by displayName rather
14
+ than identity: DTPagination is declared after DataTable, and a name also
15
+ survives a caller re-exporting the component. */
16
+ function isOwn(node: React.ReactNode, name: string): boolean {
17
+ return React.isValidElement(node) && (node.type as { displayName?: string })?.displayName === name;
18
+ }
19
+
13
20
  export type SortDir = 'asc' | 'desc';
14
21
  export type DataTableSize = 'sm' | 'md' | 'lg';
15
22
  export type RowState = 'muted' | 'error' | undefined;
@@ -23,6 +30,11 @@ export interface DataTableColumn<T> {
23
30
  numeric?: boolean;
24
31
  /** Monospace identifier (.dt-id). */
25
32
  id?: boolean;
33
+ /** Strike this cell's figure through: the amount is annulled (cancelled,
34
+ reversed, voided) while the ROW itself is still live. A fully cancelled row
35
+ uses `rowState: 'muted'`, which strikes its numeric cells for free. Only
36
+ meaningful on a numeric column — striking a name reads as damage. */
37
+ isVoid?: (row: T) => boolean;
26
38
  /** Sortable header (.dt-th-sort with chevron). */
27
39
  sortable?: boolean;
28
40
  /** Cell renderer — defaults to String(row[key]). */
@@ -48,7 +60,7 @@ export interface DataTableProps<T> {
48
60
  loading?: boolean;
49
61
  /** How many skeleton rows. Default 4. */
50
62
  loadingRows?: number;
51
- /** sm | md (default) | lg — .dt--sm / .dt--lg. */
63
+ /** sm | md (default) | lg — .dt-sm / .dt-lg. */
52
64
  size?: DataTableSize;
53
65
  zebra?: boolean;
54
66
  sticky?: boolean;
@@ -71,13 +83,30 @@ export interface DataTableProps<T> {
71
83
  onSort?: (key: string) => void;
72
84
  className?: string;
73
85
  style?: React.CSSProperties;
86
+ /** Row scale. */
87
+ size?: 'sm' | 'md' | 'lg';
88
+ /** Vertical rules between columns — for dense numeric grids only. */
89
+ bordered?: boolean;
90
+ /** Alternating row tint. Prefer plain rows; zebra competes with row states. */
91
+ zebra?: boolean;
92
+ /** Sticky header. Requires a bounded height, so wrap in DTScrollY. */
93
+ sticky?: boolean;
74
94
  }
75
95
 
96
+ /* Two arrows stacked in a clipped slot; `.chev-track` slides between them so the
97
+ direction change keeps an arrow pointing the whole way. A single chevron
98
+ rotated 180° also reads, but it spins through horizontal — a frame where the
99
+ glyph points at neither value. */
76
100
  const Chev: React.FC = () => (
77
101
  <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>
102
+ <span className="chev-track">
103
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
104
+ <path d="m18 15-6-6-6 6" />
105
+ </svg>
106
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
107
+ <path d="m6 9 6 6 6-6" />
108
+ </svg>
109
+ </span>
81
110
  </span>
82
111
  );
83
112
 
@@ -121,12 +150,10 @@ export function DataTable<T>({
121
150
  }: DataTableProps<T>): React.ReactElement {
122
151
  const tableCls = [
123
152
  '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' : '',
153
+ size ? 'dt-' + size : '',
154
+ bordered ? 'dt-bordered' : '',
155
+ zebra ? 'dt-zebra' : '',
156
+ sticky ? 'dt-sticky' : '',
130
157
  ]
131
158
  .filter(Boolean)
132
159
  .join(' ');
@@ -146,6 +173,11 @@ export function DataTable<T>({
146
173
  </th>
147
174
  ) : null}
148
175
  {columns.map((c) => {
176
+ /* The control is a <button>, not the <th> with onClick: a click
177
+ handler on a header cell is unreachable by keyboard and announces
178
+ nothing, and sorting is the one table action a keyboard user needs
179
+ most. The th keeps aria-sort (the ARIA home for sort state) and
180
+ `.dt-num`; the button carries `.dt-th-sort` and the chevron. */
149
181
  const sortCls = c.sortable
150
182
  ? ['dt-th-sort', sortKey === c.key && sortDir === 'asc' ? 'is-asc' : '', sortKey === c.key && sortDir === 'desc' ? 'is-desc' : '']
151
183
  .filter(Boolean)
@@ -154,12 +186,17 @@ export function DataTable<T>({
154
186
  return (
155
187
  <th
156
188
  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}
189
+ className={c.numeric ? 'dt-num' : undefined}
190
+ aria-sort={c.sortable ? (sortKey === c.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none') : undefined}
160
191
  >
161
- {c.header}
162
- {c.sortable ? <Chev /> : null}
192
+ {c.sortable ? (
193
+ <button type="button" className={sortCls} onClick={onSort ? () => onSort(c.key) : undefined}>
194
+ {c.header}
195
+ <Chev />
196
+ </button>
197
+ ) : (
198
+ c.header
199
+ )}
163
200
  </th>
164
201
  );
165
202
  })}
@@ -214,7 +251,7 @@ export function DataTable<T>({
214
251
  {columns.map((c) => (
215
252
  <td
216
253
  key={c.key}
217
- className={[c.numeric ? 'dt-num' : '', c.id ? 'dt-id' : ''].filter(Boolean).join(' ') || undefined}
254
+ className={[c.numeric ? 'dt-num' : '', c.id ? 'dt-id' : '', c.isVoid && c.isVoid(row) ? 'is-void' : ''].filter(Boolean).join(' ') || undefined}
218
255
  data-label={typeof c.header === 'string' ? c.header : undefined}
219
256
  >
220
257
  {c.render ? c.render(row) : String((row as Record<string, unknown>)[c.key] ?? '')}
@@ -234,9 +271,16 @@ export function DataTable<T>({
234
271
 
235
272
  return (
236
273
  <div className={['dt-wrap', className || ''].filter(Boolean).join(' ')} style={style}>
237
- {toolbar ? <div className="dt-toolbar">{toolbar}</div> : null}
274
+ {/* Same rule as the pagination slot: DTToolbar already IS a .dt-toolbar,
275
+ so wrapping it would nest two bars and the inner one would shrink-wrap. */}
276
+ {toolbar ? (isOwn(toolbar, 'DTToolbar') ? toolbar : <div className="dt-toolbar">{toolbar}</div>) : null}
238
277
  {inner}
239
- {pagination ? <div className="dt-pagination">{pagination}</div> : null}
278
+ {/* The slot does NOT wrap: DTPagination already IS a .dt-pagination, and
279
+ wrapping it produced a 958px footer holding a 438px footer — the inner
280
+ one shrink-wrapped and sat left, so the pager looked bunched even though
281
+ its own margin-left:auto was working. Raw children still get the frame,
282
+ which is what the slot is for. */}
283
+ {pagination ? (isOwn(pagination, 'DTPagination') ? pagination : <div className="dt-pagination">{pagination}</div>) : null}
240
284
  </div>
241
285
  );
242
286
  }
@@ -266,8 +310,187 @@ export const DTCellLead: React.FC<{ lead: React.ReactNode; pri: React.ReactNode;
266
310
  </div>
267
311
  );
268
312
 
269
- /** Trailing actions cluster — `.dt-actions` (ghost icon buttons). */
270
- export const DTActions: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...rest }) => (
313
+ /** Group separator — a full-width overline that labels the rows beneath it.
314
+ It is a LABEL, not data: it carries no row state, no hover, and spans every
315
+ column, so `colSpan` must match the table's column count. */
316
+ export interface DTGroupDividerProps extends React.HTMLAttributes<HTMLTableRowElement> {
317
+ label: React.ReactNode;
318
+ colSpan: number;
319
+ /** Optional right-aligned aggregate ("4 payouts · −$18,750.00"). */
320
+ meta?: React.ReactNode;
321
+ }
322
+ export function DTGroupDivider(props: DTGroupDividerProps): React.ReactElement {
323
+ const { label, colSpan, meta, className, ...rest } = props;
324
+ return (
325
+ <tr className={['dt-row-divider', className || ''].filter(Boolean).join(' ')} {...rest}>
326
+ <td colSpan={colSpan}>
327
+ {label}
328
+ {meta != null ? <span className="dt-row-divider-meta">{meta}</span> : null}
329
+ </td>
330
+ </tr>
331
+ );
332
+ }
333
+
334
+ /** Detail panel — a child row that keeps its OWN columns instead of borrowing
335
+ the parent's. Use when the child data shares no schema with the row above
336
+ (an audit trail under a payment); use a subrow when it does. The parent must
337
+ carry `has-detail` so its bottom border is dropped and the pair reads as
338
+ one block. */
339
+ export interface DTDetailProps extends React.HTMLAttributes<HTMLTableRowElement> {
340
+ colSpan: number;
341
+ open?: boolean;
342
+ title?: React.ReactNode;
343
+ children?: React.ReactNode;
344
+ }
345
+ export function DTDetail(props: DTDetailProps): React.ReactElement {
346
+ const { colSpan, open, title, children, className, ...rest } = props;
347
+ return (
348
+ <tr className={['dt-detail', open ? 'is-open' : '', className || ''].filter(Boolean).join(' ')} {...rest}>
349
+ <td colSpan={colSpan}>
350
+ <div className="dt-detail-body">
351
+ {title != null ? <div className="dt-detail-title">{title}</div> : null}
352
+ {children}
353
+ </div>
354
+ </td>
355
+ </tr>
356
+ );
357
+ }
358
+
359
+ /** Table chrome — the bar above the data. Three zones with fixed roles: lead
360
+ carries identity, filters the middle, actions cluster right via
361
+ margin-left:auto (never a flex:1 spacer, which collapses the lead).
362
+ `selecting` is the same bar in another state, not a second bar — the height
363
+ is preserved so the table does not jump when the first row is ticked. */
364
+ export interface DTToolbarProps extends React.HTMLAttributes<HTMLDivElement> {
365
+ lead?: React.ReactNode;
366
+ filters?: React.ReactNode;
367
+ actions?: React.ReactNode;
368
+ selecting?: boolean;
369
+ size?: 'md' | 'sm';
370
+ }
371
+ export function DTToolbar(props: DTToolbarProps): React.ReactElement {
372
+ const { lead, filters, actions, selecting, size, className, ...rest } = props;
373
+ const cls = ['dt-toolbar', size === 'sm' ? 'dt-toolbar-sm' : '', selecting ? 'is-selecting' : '', className || ''].filter(Boolean).join(' ');
374
+ return (
375
+ <div className={cls} {...rest}>
376
+ {lead != null ? <div className="dt-toolbar-lead">{lead}</div> : null}
377
+ {filters != null ? <div className="dt-toolbar-filters">{filters}</div> : null}
378
+ {actions != null ? <div className="dt-toolbar-actions">{actions}</div> : null}
379
+ </div>
380
+ );
381
+ }
382
+
383
+ /** Clickable key-value card inside a detail panel. */
384
+ export function DTDetailCard(props: React.ButtonHTMLAttributes<HTMLButtonElement>): React.ReactElement {
385
+ const { children, className, ...rest } = props;
386
+ return <button type="button" className={['dt-detail-card', className || ''].filter(Boolean).join(' ')} {...rest}>{children}</button>;
387
+ }
388
+
389
+ /** Lead cell of a subrow — connector glyph, optional mini icon tile, label.
390
+ The glyph is a CSS ::before so it is neither copied with the text nor
391
+ announced as a character. `parentName` renders a visually-hidden prefix:
392
+ native table semantics carry no hierarchy, so the relationship is stated in
393
+ CONTENT rather than by claiming role="treegrid" without its keyboard model. */
394
+ export const DTSubrowLead: React.FC<{
395
+ label: React.ReactNode;
396
+ icon?: React.ReactNode;
397
+ parentName?: string;
398
+ } & React.HTMLAttributes<HTMLDivElement>> = ({ label, icon, parentName, className, ...rest }) => (
399
+ <div className={['dt-subrow-lead', className || ''].filter(Boolean).join(' ')} {...rest}>
400
+ {icon != null ? <span className="ic-mini">{icon}</span> : null}
401
+ <span className="txt">
402
+ {parentName ? <span className="sr-only">{'Item of ' + parentName + ': '}</span> : null}
403
+ {label}
404
+ </span>
405
+ </div>
406
+ );
407
+
408
+ /** Disclosure control for a collapsible subrow group — put it in the parent's
409
+ lead cell. Wire `controls` to the subrow's id so `aria-controls` resolves. */
410
+ export const DTSubrowToggle: React.FC<{
411
+ expanded: boolean;
412
+ onToggle: () => void;
413
+ controls: string;
414
+ /** Announced as "Show 2 items" / "Hide 2 items". */
415
+ count?: number;
416
+ label?: string;
417
+ } & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'aria-expanded'>> = ({
418
+ expanded, onToggle, controls, count, label, className, ...rest
419
+ }) => (
420
+ <button
421
+ type="button"
422
+ className={['dt-subrow-toggle', className || ''].filter(Boolean).join(' ')}
423
+ aria-expanded={expanded}
424
+ aria-controls={controls}
425
+ aria-label={(expanded ? 'Hide' : 'Show') + ' ' + (count != null ? count + ' ' : '') + (label || 'linked items')}
426
+ onClick={(e) => { e.stopPropagation(); onToggle(); }}
427
+ {...rest}
428
+ >
429
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
430
+ <polyline points="9 18 15 12 9 6" />
431
+ </svg>
432
+ </button>
433
+ );
434
+
435
+ /** Animated wrapper for a collapsible subrow's cell content. A <tr> cannot be
436
+ animated and display:grid on a <td> breaks column alignment, so the height
437
+ transition lives here: a grid going 0fr -> 1fr, which needs no magic pixel
438
+ value and therefore survives a two-line subrow on mobile. */
439
+ export const DTSubrowCell: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, className, ...rest }) => (
440
+ <div className="dt-subrow-inner" {...rest}>
441
+ <div className={['dt-subrow-cell', className || ''].filter(Boolean).join(' ')}>{children}</div>
442
+ </div>
443
+ );
444
+
445
+ /** Round tone badge inside a cell — `.ic-round`.
446
+ Leading answers "what kind of row is this" and is scanned DOWN the column, so
447
+ keep one icon per kind. Trailing annotates the label itself ("verified", "has
448
+ a note") and hugs the text rather than floating to the cell edge, where it
449
+ would read as another column. Reach for a Pill when the state needs a word;
450
+ a badge only when the icon is unambiguous on its own. */
451
+ export interface DTBadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
452
+ children?: React.ReactNode;
453
+ tone?: 'success' | 'warning' | 'danger' | 'info';
454
+ /** After the label instead of before it. */
455
+ trailing?: boolean;
456
+ size?: 'sm' | 'md';
457
+ }
458
+ export const DTBadge: React.FC<DTBadgeProps> = ({ children, tone, trailing, size = 'md', className, ...rest }) => (
459
+ <span
460
+ className={['ic-round', tone ? 'tone-' + tone : '', trailing ? 'is-trailing' : '', size === 'sm' ? 'is-sm' : '', className || ''].filter(Boolean).join(' ')}
461
+ aria-hidden="true"
462
+ {...rest}
463
+ >
464
+ {children}
465
+ </span>
466
+ );
467
+
468
+ /** Trend delta inside a cell — `.stat-delta`.
469
+ Use when the COMPARISON is the point; when the amount is the point, use
470
+ `.dt-num` and put the delta in its own column. Direction is carried by the
471
+ arrow and the sign, never by colour alone — and `flat` is worth rendering,
472
+ because an empty cell reads as missing data rather than "unchanged". Down is
473
+ not automatically bad: label the polarity when it is ambiguous. */
474
+ export interface DTDeltaProps extends React.HTMLAttributes<HTMLSpanElement> {
475
+ children?: React.ReactNode;
476
+ direction: 'up' | 'down' | 'flat';
477
+ /** Screen-reader phrasing, e.g. "up 12.4% versus last month". */
478
+ label?: string;
479
+ }
480
+ export const DTDelta: React.FC<DTDeltaProps> = ({ children, direction, label, className, ...rest }) => {
481
+ const path = direction === 'up' ? 'm3 17 6-6 4 4 8-8' : direction === 'down' ? 'm3 7 6 6 4-4 8 8' : 'M5 12h14';
482
+ return (
483
+ <span className={['stat-delta', 'stat-delta-' + direction, className || ''].filter(Boolean).join(' ')} {...rest}>
484
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
485
+ <path d={path} />
486
+ </svg>
487
+ {label ? <span className="sr-only">{label}</span> : null}
488
+ {children}
489
+ </span>
490
+ );
491
+ };
492
+
493
+ /** Trailing actions cluster — `.dt-actions` (ghost icon buttons). */export const DTActions: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...rest }) => (
271
494
  <div className={['dt-actions', className || ''].filter(Boolean).join(' ')} {...rest} />
272
495
  );
273
496
 
@@ -279,30 +502,185 @@ export interface DTPaginationProps extends React.HTMLAttributes<HTMLDivElement>
279
502
  /** Total pages. */
280
503
  pages: number;
281
504
  onPage?: (page: number) => void;
505
+ /** Rows-per-page choices. Rendering this control implies the caller re-fetches
506
+ and resets to page 1 — the component owns neither. */
507
+ pageSizes?: number[];
508
+ pageSize?: number;
509
+ onPageSize?: (size: number) => void;
510
+ }
511
+ /* Which page numbers to print. Always the first and last step plus the current
512
+ one and its neighbours; an ellipsis stands in for each gap. Under 8 pages
513
+ everything fits, so no gap is worth the visual noise. */
514
+ function pageWindow(page: number, pages: number): Array<number | '…'> {
515
+ if (pages <= 7) return Array.from({ length: pages }, (_, n) => n + 1);
516
+ const out: Array<number | '…'> = [1];
517
+ const from = Math.max(2, page - 1);
518
+ const to = Math.min(pages - 1, page + 1);
519
+ if (from > 2) out.push('…');
520
+ for (let p = from; p <= to; p++) out.push(p);
521
+ if (to < pages - 1) out.push('…');
522
+ out.push(pages);
523
+ return out;
282
524
  }
525
+
526
+ /* Rows-per-page. The sliding white pill is a single .tab-indicator positioned by
527
+ custom properties, so SOMETHING has to measure the selected tab and write them:
528
+ the markup alone renders the indicator parked at zero width, which looks like a
529
+ dead control even though the clicks work. Re-measured on value change and on
530
+ resize, since the tab widths depend on the digits ("25" vs "100"). */
531
+ const PageSizeTabs: React.FC<{
532
+ sizes: number[];
533
+ value?: number;
534
+ onChange?: (n: number) => void;
535
+ }> = ({ sizes, value, onChange }) => {
536
+ const groupRef = React.useRef<HTMLSpanElement>(null);
537
+
538
+ React.useEffect(() => {
539
+ const place = () => {
540
+ const group = groupRef.current;
541
+ if (!group) return;
542
+ const active = group.querySelector<HTMLElement>('[aria-selected="true"]');
543
+ if (!active) return;
544
+ group.style.setProperty('--ind-x', active.offsetLeft + 'px');
545
+ group.style.setProperty('--ind-w', active.offsetWidth + 'px');
546
+ };
547
+ place();
548
+ /* Fonts land after first paint and change every tab's width, so a single
549
+ measurement at mount puts the pill in the wrong place. */
550
+ if (document.fonts && document.fonts.ready) document.fonts.ready.then(place).catch(() => {});
551
+ window.addEventListener('resize', place);
552
+ return () => window.removeEventListener('resize', place);
553
+ }, [value, sizes.join(',')]);
554
+
555
+ return (
556
+ <span className="dt-pagination-size">
557
+ Rows
558
+ <span ref={groupRef} className="tabs tabs-pill tabs-sm is-animated" role="tablist" aria-label="Rows per page">
559
+ <span className="tab-indicator" aria-hidden="true" />
560
+ {sizes.map((n) => (
561
+ <button
562
+ key={n}
563
+ type="button"
564
+ role="tab"
565
+ className="tab"
566
+ aria-selected={n === value}
567
+ onClick={() => onChange && onChange(n)}
568
+ >
569
+ {n}
570
+ </button>
571
+ ))}
572
+ </span>
573
+ </span>
574
+ );
575
+ };
576
+
283
577
  /** Canonical pagination row — `.range` + `.pager` with `.page-btn`s (prev · numbered · next).
284
578
  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">
579
+ export const DTPagination: React.FC<DTPaginationProps> = ({ range, page, pages, onPage, pageSizes, pageSize, onPageSize, className, ...rest }) => (
580
+ <div className={['dt-pagination', className || ''].filter(Boolean).join(' ')} {...rest}>
581
+ {range != null ? <span className="dt-pagination-range">{range}</span> : null}
582
+ {pageSizes && pageSizes.length ? (
583
+ <PageSizeTabs sizes={pageSizes} value={pageSize} onChange={onPageSize} />
584
+ ) : null}
585
+ <nav className="dt-pagination-nav" aria-label="Pagination">
586
+ {/* `.page-btn` is the canonical control the stylesheet defines: a bordered
587
+ box with a square minimum and mono figures, current step in accent. The
588
+ previous markup reached for `.btn.btn-ghost` instead, so the pager
589
+ rendered as borderless pills and the boxed style the DS ships went
590
+ unused — a class with rules and no callers. */}
289
591
  <button type="button" className="page-btn" disabled={page <= 1} onClick={() => onPage && onPage(page - 1)} aria-label="Previous page">
290
592
  <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
593
  </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
- ))}
594
+ {/* Windowed: first, last, and the neighbours of the current step, with an
595
+ ellipsis for the gap. Printing every page turns a 40-page table's footer
596
+ into a wall of numbers nobody scans. */}
597
+ {pageWindow(page, pages).map((p, i) =>
598
+ p === '…' ? (
599
+ <span key={'gap' + i} className="page-gap" aria-hidden="true">…</span>
600
+ ) : (
601
+ <button
602
+ key={p}
603
+ type="button"
604
+ className={p === page ? 'page-btn is-current' : 'page-btn'}
605
+ aria-current={p === page ? 'page' : undefined}
606
+ aria-label={'Page ' + p}
607
+ onClick={() => onPage && onPage(p as number)}
608
+ >
609
+ {p}
610
+ </button>
611
+ )
612
+ )}
303
613
  <button type="button" className="page-btn" disabled={page >= pages} onClick={() => onPage && onPage(page + 1)} aria-label="Next page">
304
614
  <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
615
  </button>
306
616
  </nav>
307
617
  </div>
308
618
  );
619
+
620
+ /** Cell utilities. `noTab` turns OFF tabular figures — for IDs and codes,
621
+ where fixed-width digits look mechanical; `check` is the selection gutter,
622
+ a fixed 40px so the first data column starts at the same x either way. */
623
+ export const DTId: React.FC<React.HTMLAttributes<HTMLSpanElement>> = ({ children, className, ...rest }) => (
624
+ <span className={['dt-id', 'dt-no-tab', className || ''].filter(Boolean).join(' ')} {...rest}>{children}</span>
625
+ );
626
+ export const DTCheckCell: React.FC<React.TdHTMLAttributes<HTMLTableCellElement>> = ({ children, className, ...rest }) => (
627
+ <td className={['dt-check', className || ''].filter(Boolean).join(' ')} {...rest}>{children}</td>
628
+ );
629
+
630
+ /** Trailing chips on a subrow: what KIND of child it is, and its state.
631
+ Kept separate from the label so the two never merge into one string — an
632
+ agent reading the row can tell the taxonomy from the status. */
633
+ export const DTSubrowMeta: React.FC<{ tag?: React.ReactNode; status?: React.ReactNode; count?: React.ReactNode }> = ({ tag, status, count }) => (
634
+ <React.Fragment>
635
+ {tag != null ? <span className="dt-subrow-tag">{tag}</span> : null}
636
+ {status != null ? <span className="dt-subrow-status">{status}</span> : null}
637
+ {count != null ? <span className="dt-subrow-count">{count}</span> : null}
638
+ </React.Fragment>
639
+ );
640
+
641
+ /** Key–value grid inside a detail panel — for ONE record's attributes. Use a
642
+ nested table instead when the panel holds many records of one type. */
643
+ export const DTDetailGrid: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, className, ...rest }) => (
644
+ <div className={['dt-detail-grid', className || ''].filter(Boolean).join(' ')} {...rest}>{children}</div>
645
+ );
646
+
647
+ /** Vertical scroller for a sticky-header table. Belongs inside .dt-wrap so the
648
+ wrap keeps ownership of the radius and the border. */
649
+ export const DTScrollY: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, className, style, ...rest }) => (
650
+ <div className={['dt-scroll-y', className || ''].filter(Boolean).join(' ')} style={style} {...rest}>{children}</div>
651
+ );
652
+
653
+ /** Parent of a subrow or detail group. It exists because the FUSION is the
654
+ parent's job: `has-subrows` drops the parent's bottom border so the pair
655
+ reads as one block, and without it a caller gets an indented child floating
656
+ under a fully-ruled row — the exact defect the docs warn against. */
657
+ export interface DTParentRowProps extends React.HTMLAttributes<HTMLTableRowElement> {
658
+ children?: React.ReactNode;
659
+ /** The group's kind: subrows share the parent's columns, a detail panel does not. */
660
+ kind?: 'subrows' | 'detail';
661
+ open?: boolean;
662
+ /** Whole-row disclosure — adds role/tabindex and the pointer affordance. */
663
+ onToggle?: () => void;
664
+ controls?: string;
665
+ }
666
+ export const DTParentRow: React.FC<DTParentRowProps> = ({ children, kind = 'subrows', open, onToggle, controls, className, ...rest }) => (
667
+ <tr
668
+ className={[kind === 'detail' ? 'has-detail' : 'has-subrows', onToggle ? 'is-collapsible is-clickable' : '', open ? 'is-open' : '', className || ''].filter(Boolean).join(' ')}
669
+ role={onToggle ? 'button' : undefined}
670
+ tabIndex={onToggle ? 0 : undefined}
671
+ aria-expanded={onToggle ? !!open : undefined}
672
+ aria-controls={controls}
673
+ onClick={onToggle}
674
+ onKeyDown={onToggle ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle(); } } : undefined}
675
+ {...rest}
676
+ >
677
+ {children}
678
+ </tr>
679
+ );
680
+
681
+ /* Slot markers, set after both declarations exist. DataTable reads these to tell
682
+ "already the canonical bar" from "raw children that still need the frame" —
683
+ assigning them earlier put DTPagination in its temporal dead zone and took the
684
+ whole module down with it. */
685
+ DTToolbar.displayName = 'DTToolbar';
686
+ DTPagination.displayName = 'DTPagination';
@@ -1,6 +1,9 @@
1
1
  import * as React from 'react';
2
2
 
3
3
  export interface SearchPillProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
4
+ /** Field height: 'md' (40px, default) or 'sm' (32px). Named fieldSize because
5
+ the native input `size` attribute means character count, not height. */
6
+ fieldSize?: 'sm' | 'md';
4
7
  /** Keyboard hint rendered as a .kbd chip (e.g. "⌘K"). */
5
8
  shortcut?: string;
6
9
  /** Leading icon — defaults to the canonical search glyph. */