@marianmeres/stuic 3.161.0 → 3.162.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -98,6 +98,54 @@
98
98
  /** Callback when a row is clicked */
99
99
  onRowClick?: (row: T, index: number) => void;
100
100
 
101
+ /**
102
+ * Where this row's primary action navigates. When it returns a href for a row, the
103
+ * content of that row's "lead" cell is wrapped in an anchor -- which is what makes a
104
+ * clickable row keyboard-, middle- and modifier-click-reachable.
105
+ *
106
+ * Return `undefined` for a row with no destination (a placeholder, a row still
107
+ * minting its id) -- that row's lead cell renders exactly as it does today.
108
+ *
109
+ * Independent of `onRowClick`: supply both and the anchor handles the keyboard and
110
+ * modified clicks while `onRowClick` keeps handling a plain click anywhere else on
111
+ * the row -- the row click handler ignores events originating inside an anchor, so
112
+ * the two never double-fire. (Consequence: a plain click on the lead cell navigates
113
+ * via the href and does NOT call `onRowClick` -- point both at the same destination.)
114
+ */
115
+ rowHref?: (row: T, index: number) => string | undefined;
116
+ /**
117
+ * Which column is the "lead" cell for `rowHref`. Defaults to the first column in
118
+ * `columns`. On mobile, if that column is `hideOnMobile`, the first visible card
119
+ * field is used instead (so the card keeps a keyboard-reachable link).
120
+ */
121
+ rowHrefColumn?: string;
122
+ /** Additional CSS classes for the anchor generated by `rowHref`. */
123
+ classRowLink?: string;
124
+
125
+ /**
126
+ * Make the desktop `<tr>` itself focusable and Enter-activatable.
127
+ *
128
+ * For a row whose action is NOT a navigation -- prefer `rowHref` when it is, since a
129
+ * real link is better in every respect (tab order, `Enter`, middle/cmd-click, "copy
130
+ * link address", and an announcement of where it goes).
131
+ *
132
+ * Deliberately does NOT set `role="button"`: a `<tr>` carries an implicit `row` role
133
+ * that the table's own structure depends on, and overriding it detaches the row from
134
+ * the table for assistive technology. The row stays a row; it merely becomes focusable.
135
+ *
136
+ * Also deliberately `Enter`-only, unlike the mobile card (which IS a `role="button"`,
137
+ * where `Space` is expected). Taking `Space` -- page scroll -- away from a table is a
138
+ * worse trade than the one the card makes.
139
+ *
140
+ * No-op unless `onRowClick` or `selectOnRowClick` is set.
141
+ */
142
+ rowActivatable?: boolean;
143
+ /**
144
+ * Accessible name for an activatable row, e.g. `(row) => "Open " + row.name`.
145
+ * Only applied when the row is actually activatable.
146
+ */
147
+ rowLabel?: (row: T, index: number) => string | undefined;
148
+
101
149
  /** Show loading state (spinner overlay + reduced opacity) */
102
150
  loading?: boolean;
103
151
 
@@ -203,6 +251,11 @@
203
251
  selectedAll = $bindable(false),
204
252
  excluded = $bindable(new Set()),
205
253
  onRowClick,
254
+ rowHref,
255
+ rowHrefColumn,
256
+ classRowLink,
257
+ rowActivatable = false,
258
+ rowLabel,
206
259
  loading = false,
207
260
  cell,
208
261
  row,
@@ -342,22 +395,46 @@
342
395
  return allOnPageSelected;
343
396
  });
344
397
 
345
- // --- Row click ---
346
- function handleRowClick(row: T, index: number, e: MouseEvent) {
347
- const target = e.target as HTMLElement;
348
- if (
398
+ // --- Row click / activation ---
399
+
400
+ // A row is a big target that legitimately contains its own controls. Anything that
401
+ // originates inside one of those is that control's business, not the row's - this
402
+ // guards the click handler AND both keydown handlers (Enter on a focused lead link
403
+ // must navigate, not fire onRowClick; Space on a focused checkbox must toggle it).
404
+ function isInteractiveTarget(e: Event): boolean {
405
+ const target = e.target as HTMLElement | null;
406
+ if (!target?.closest) return false;
407
+ return !!(
349
408
  target.closest('input[type="checkbox"]') ||
350
409
  target.closest("button") ||
351
410
  target.closest("a")
352
- ) {
353
- return;
354
- }
411
+ );
412
+ }
413
+
414
+ function activateRow(row: T, index: number) {
355
415
  if (selectable && selectOnRowClick) {
356
416
  toggleSelectRow(getRowId(row, index));
357
417
  }
358
418
  onRowClick?.(row, index);
359
419
  }
360
420
 
421
+ function handleRowClick(row: T, index: number, e: MouseEvent) {
422
+ if (isInteractiveTarget(e)) return;
423
+ activateRow(row, index);
424
+ }
425
+
426
+ /**
427
+ * Shared by the desktop `<tr>` (Enter only) and the mobile card (Enter + Space, since
428
+ * the card actually is a `role="button"`).
429
+ */
430
+ function handleRowKeydown(row: T, index: number, e: KeyboardEvent, withSpace: boolean) {
431
+ if (!rowClickable) return;
432
+ if (e.key !== "Enter" && !(withSpace && e.key === " ")) return;
433
+ if (isInteractiveTarget(e)) return;
434
+ e.preventDefault();
435
+ activateRow(row, index);
436
+ }
437
+
361
438
  // --- Cell value helpers ---
362
439
  function getCellValue(row: T, column: DataTableColumn<T>): any {
363
440
  return column.key.split(".").reduce((obj: any, k) => obj?.[k], row);
@@ -373,8 +450,45 @@
373
450
  let rootClass = $derived(unstyled ? classProp : twMerge("stuic-data-table", classProp));
374
451
 
375
452
  let mobileColumns = $derived(columns.filter((col) => !col.hideOnMobile));
453
+
454
+ // --- Row link / activation derivations ---
455
+ let rowClickable = $derived(!!(onRowClick || selectOnRowClick));
456
+ let rowIsActivatable = $derived(rowActivatable && rowClickable);
457
+
458
+ // The lead cell is the one whose content gets wrapped in the `rowHref` anchor.
459
+ let leadColumnKey = $derived(rowHrefColumn ?? columns[0]?.key);
460
+ // A `hideOnMobile` lead column would leave the card with no link at all, so there we
461
+ // fall back to the first visible field rather than silently dropping the anchor.
462
+ let mobileLeadColumnKey = $derived(
463
+ mobileColumns.some((col) => col.key === leadColumnKey)
464
+ ? leadColumnKey
465
+ : mobileColumns[0]?.key
466
+ );
467
+
468
+ let rowLinkClass = $derived(
469
+ unstyled ? classRowLink : twMerge("stuic-data-table-row-link", classRowLink)
470
+ );
376
471
  </script>
377
472
 
473
+ <!--
474
+ The rendered content of a single cell, shared by both layouts so that a `rowHref`
475
+ anchor wraps exactly what the cell would have rendered anyway (a consumer `cell`
476
+ snippet included).
477
+ -->
478
+ {#snippet cellBody(
479
+ col: DataTableColumn<T>,
480
+ rowData: T,
481
+ value: any,
482
+ rowIndex: number,
483
+ variant: "desktop" | "mobile"
484
+ )}
485
+ {#if cell}
486
+ {@render cell({ column: col, row: rowData, value, rowIndex, variant })}
487
+ {:else}
488
+ {getCellDisplay(rowData, col)}
489
+ {/if}
490
+ {/snippet}
491
+
378
492
  <!-- Batch action bar -->
379
493
  {#if selectable && effectiveCount > 0 && batchActions}
380
494
  <div class={!unstyled ? "stuic-data-table-batch" : undefined}>
@@ -461,16 +575,20 @@
461
575
  {@const rowId = getRowId(rowData, rowIndex)}
462
576
  {@const isSelected = selectable && isRowSelected(rowId)}
463
577
  {@const selectDisabled = !!selectDisabledBy?.(rowData, rowIndex)}
578
+ {@const rowLink = rowHref?.(rowData, rowIndex)}
464
579
  {#if row}
465
580
  {@render row({ row: rowData, columns, rowIndex, isSelected })}
466
581
  {:else}
467
582
  <tr
468
583
  data-hoverable={!unstyled ? "true" : undefined}
469
- data-clickable={!unstyled && (onRowClick || selectOnRowClick)
470
- ? "true"
471
- : undefined}
584
+ data-clickable={!unstyled && rowClickable ? "true" : undefined}
472
585
  data-selected={!unstyled && isSelected ? "true" : undefined}
586
+ tabindex={rowIsActivatable ? 0 : undefined}
587
+ aria-label={rowIsActivatable ? rowLabel?.(rowData, rowIndex) : undefined}
473
588
  onclick={(e) => handleRowClick(rowData, rowIndex, e)}
589
+ onkeydown={(e) => {
590
+ if (rowActivatable) handleRowKeydown(rowData, rowIndex, e, false);
591
+ }}
474
592
  >
475
593
  {#if selectable}
476
594
  <td data-checkbox class="stuic-checkbox">
@@ -485,20 +603,17 @@
485
603
  {/if}
486
604
  {#each columns as col (col.key)}
487
605
  {@const value = getCellValue(rowData, col)}
606
+ {@const href = col.key === leadColumnKey ? rowLink : undefined}
488
607
  <td
489
608
  class={col.class}
490
609
  data-align={!unstyled && col.align ? col.align : undefined}
491
610
  >
492
- {#if cell}
493
- {@render cell({
494
- column: col,
495
- row: rowData,
496
- value,
497
- rowIndex,
498
- variant: "desktop",
499
- })}
611
+ {#if href}
612
+ <a class={rowLinkClass} {href}>
613
+ {@render cellBody(col, rowData, value, rowIndex, "desktop")}
614
+ </a>
500
615
  {:else}
501
- {getCellDisplay(rowData, col)}
616
+ {@render cellBody(col, rowData, value, rowIndex, "desktop")}
502
617
  {/if}
503
618
  </td>
504
619
  {/each}
@@ -531,6 +646,7 @@
531
646
  {@const rowId = getRowId(rowData, rowIndex)}
532
647
  {@const isSelected = selectable && isRowSelected(rowId)}
533
648
  {@const selectDisabled = !!selectDisabledBy?.(rowData, rowIndex)}
649
+ {@const rowLink = rowHref?.(rowData, rowIndex)}
534
650
  {#if mobileRow}
535
651
  {@render mobileRow({
536
652
  row: rowData,
@@ -542,25 +658,12 @@
542
658
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
543
659
  <div
544
660
  class={!unstyled ? "stuic-data-table-card" : undefined}
545
- data-clickable={!unstyled && (onRowClick || selectOnRowClick)
546
- ? "true"
547
- : undefined}
661
+ data-clickable={!unstyled && rowClickable ? "true" : undefined}
548
662
  data-selected={!unstyled && isSelected ? "true" : undefined}
549
- role={onRowClick || selectOnRowClick ? "button" : undefined}
550
- tabindex={onRowClick || selectOnRowClick ? 0 : undefined}
663
+ role={rowClickable ? "button" : undefined}
664
+ tabindex={rowClickable ? 0 : undefined}
551
665
  onclick={(e) => handleRowClick(rowData, rowIndex, e)}
552
- onkeydown={(e) => {
553
- if (
554
- (onRowClick || selectOnRowClick) &&
555
- (e.key === "Enter" || e.key === " ")
556
- ) {
557
- e.preventDefault();
558
- if (selectable && selectOnRowClick) {
559
- toggleSelectRow(rowId);
560
- }
561
- onRowClick?.(rowData, rowIndex);
562
- }
563
- }}
666
+ onkeydown={(e) => handleRowKeydown(rowData, rowIndex, e, true)}
564
667
  >
565
668
  {#if selectable}
566
669
  <div
@@ -579,6 +682,7 @@
579
682
  {/if}
580
683
  {#each mobileColumns as col (col.key)}
581
684
  {@const value = getCellValue(rowData, col)}
685
+ {@const href = col.key === mobileLeadColumnKey ? rowLink : undefined}
582
686
  <div class={!unstyled ? "stuic-data-table-card-row" : undefined}>
583
687
  <span class={!unstyled ? "stuic-data-table-card-label" : undefined}>
584
688
  {#if isTHCNotEmpty(col.label)}
@@ -588,16 +692,12 @@
588
692
  {/if}
589
693
  </span>
590
694
  <span class={!unstyled ? "stuic-data-table-card-value" : undefined}>
591
- {#if cell}
592
- {@render cell({
593
- column: col,
594
- row: rowData,
595
- value,
596
- rowIndex,
597
- variant: "mobile",
598
- })}
695
+ {#if href}
696
+ <a class={rowLinkClass} {href}>
697
+ {@render cellBody(col, rowData, value, rowIndex, "mobile")}
698
+ </a>
599
699
  {:else}
600
- {getCellDisplay(rowData, col)}
700
+ {@render cellBody(col, rowData, value, rowIndex, "mobile")}
601
701
  {/if}
602
702
  </span>
603
703
  </div>
@@ -58,6 +58,52 @@ export interface Props<T = Record<string, any>> extends Omit<HTMLAttributes<HTML
58
58
  excluded?: Set<string | number>;
59
59
  /** Callback when a row is clicked */
60
60
  onRowClick?: (row: T, index: number) => void;
61
+ /**
62
+ * Where this row's primary action navigates. When it returns a href for a row, the
63
+ * content of that row's "lead" cell is wrapped in an anchor -- which is what makes a
64
+ * clickable row keyboard-, middle- and modifier-click-reachable.
65
+ *
66
+ * Return `undefined` for a row with no destination (a placeholder, a row still
67
+ * minting its id) -- that row's lead cell renders exactly as it does today.
68
+ *
69
+ * Independent of `onRowClick`: supply both and the anchor handles the keyboard and
70
+ * modified clicks while `onRowClick` keeps handling a plain click anywhere else on
71
+ * the row -- the row click handler ignores events originating inside an anchor, so
72
+ * the two never double-fire. (Consequence: a plain click on the lead cell navigates
73
+ * via the href and does NOT call `onRowClick` -- point both at the same destination.)
74
+ */
75
+ rowHref?: (row: T, index: number) => string | undefined;
76
+ /**
77
+ * Which column is the "lead" cell for `rowHref`. Defaults to the first column in
78
+ * `columns`. On mobile, if that column is `hideOnMobile`, the first visible card
79
+ * field is used instead (so the card keeps a keyboard-reachable link).
80
+ */
81
+ rowHrefColumn?: string;
82
+ /** Additional CSS classes for the anchor generated by `rowHref`. */
83
+ classRowLink?: string;
84
+ /**
85
+ * Make the desktop `<tr>` itself focusable and Enter-activatable.
86
+ *
87
+ * For a row whose action is NOT a navigation -- prefer `rowHref` when it is, since a
88
+ * real link is better in every respect (tab order, `Enter`, middle/cmd-click, "copy
89
+ * link address", and an announcement of where it goes).
90
+ *
91
+ * Deliberately does NOT set `role="button"`: a `<tr>` carries an implicit `row` role
92
+ * that the table's own structure depends on, and overriding it detaches the row from
93
+ * the table for assistive technology. The row stays a row; it merely becomes focusable.
94
+ *
95
+ * Also deliberately `Enter`-only, unlike the mobile card (which IS a `role="button"`,
96
+ * where `Space` is expected). Taking `Space` -- page scroll -- away from a table is a
97
+ * worse trade than the one the card makes.
98
+ *
99
+ * No-op unless `onRowClick` or `selectOnRowClick` is set.
100
+ */
101
+ rowActivatable?: boolean;
102
+ /**
103
+ * Accessible name for an activatable row, e.g. `(row) => "Open " + row.name`.
104
+ * Only applied when the row is actually activatable.
105
+ */
106
+ rowLabel?: (row: T, index: number) => string | undefined;
61
107
  /** Show loading state (spinner overlay + reduced opacity) */
62
108
  loading?: boolean;
63
109
  /** Custom cell renderer snippet (rendered in both desktop table and mobile card layouts; use `variant` to tell them apart) */
@@ -143,6 +143,89 @@ New records inserted while all-pages mode is active are implicitly selected (the
143
143
 
144
144
  **Filter changes:** when filters change in the consumer, reset the bound selection stores (`selected`, `selectedAll`, `excluded`) explicitly — DataTable doesn't track which filter produced the current state.
145
145
 
146
+ ### Clickable Rows That Work Without a Mouse
147
+
148
+ `onRowClick` alone makes a row **mouse-only** on the desktop table layout: a `<tr>` is not
149
+ focusable, so nothing about the row is reachable by keyboard. (The mobile card layout has
150
+ always been fine — it is a `<div role="button">`.) Two opt-in props close that gap, and
151
+ they are not alternatives:
152
+
153
+ | Your row action is… | Use |
154
+ | ------------------------------- | ---------------- |
155
+ | a navigation (it has a URL) | `rowHref` |
156
+ | anything else (drawer, expand…) | `rowActivatable` |
157
+
158
+ #### `rowHref` — the lead cell becomes a real link
159
+
160
+ ```svelte
161
+ <DataTable
162
+ {columns}
163
+ {data}
164
+ getRowId={(row) => row.id}
165
+ rowHref={(row) => `/users/${row.id}`}
166
+ />
167
+ ```
168
+
169
+ The content of the row's **lead cell** (the first column by default, or `rowHrefColumn`)
170
+ is wrapped in an `<a href>` — including whatever your `cell` snippet renders for it, and
171
+ respecting the column's `renderValue`. A link gets, for free and correctly: keyboard focus
172
+ in the right tab order, `Enter`, ⌘/middle-click and "open in new tab", "copy link address",
173
+ and a screen-reader announcement of _where it goes_ rather than an anonymous "button".
174
+
175
+ Return `undefined` for a row with no destination (a placeholder, a row still minting its
176
+ id) — that row's lead cell renders exactly as it would without `rowHref`.
177
+
178
+ `rowHref` composes with `onRowClick`: the row click handler ignores clicks originating
179
+ inside an anchor, so the two never double-fire. The consequence is worth stating — a plain
180
+ click **on the lead cell** navigates via the href and does _not_ call `onRowClick`, so point
181
+ both at the same destination.
182
+
183
+ On mobile the same field is linked. If the lead column is `hideOnMobile`, the card links its
184
+ first visible field instead, rather than silently dropping the anchor.
185
+
186
+ By default the link looks like the text it replaced (`color: inherit`, no underline until
187
+ hover) — a table where every lead cell is blue-and-underlined is a worse table. Three CSS
188
+ variables and `classRowLink` are there if you disagree.
189
+
190
+ #### `rowActivatable` — the `<tr>` itself
191
+
192
+ For a row whose action has no address:
193
+
194
+ ```svelte
195
+ <DataTable
196
+ {columns}
197
+ {data}
198
+ onRowClick={(row) => openDrawer(row)}
199
+ rowActivatable
200
+ rowLabel={(row) => `Open ${row.name}`}
201
+ />
202
+ ```
203
+
204
+ The `<tr>` gets `tabindex="0"`, an optional `aria-label` from `rowLabel`, and a
205
+ `:focus-visible` ring. It is a no-op unless `onRowClick` or `selectOnRowClick` is set.
206
+
207
+ Two deliberate choices, so nobody "fixes" them later:
208
+
209
+ - **No `role="button"` on the `<tr>`.** A `<tr>` carries an implicit `row` role that the
210
+ table's own structure depends on. Overriding it detaches the row from the table for
211
+ assistive technology — row/column counts stop making sense and cell-by-cell navigation
212
+ breaks. The row stays a row; it merely becomes focusable. (The mobile card is a `<div>`,
213
+ so `role="button"` is correct _there_.)
214
+ - **`Enter` only, no `Space`.** `Space` on a focused row scrolls the page, and a row is not
215
+ a button — taking `Space` away from a table is a worse trade than the one the card makes.
216
+
217
+ With `selectable` + `selectOnRowClick`, `Enter` toggles selection _and_ fires `onRowClick`,
218
+ matching the mobile card.
219
+
220
+ #### Both are opt-in
221
+
222
+ With neither prop set, the rendered markup is exactly what it was before they existed. A
223
+ consumer who replaces the whole `<tr>` with the `row` snippet opts out of both and owns the
224
+ keyboard story themselves.
225
+
226
+ Interactive descendants always win: a click, `Enter` or `Space` that originates inside a
227
+ `<button>`, an `<a>` or a checkbox in the row is that control's business, not the row's.
228
+
146
229
  ### Custom Cell Rendering
147
230
 
148
231
  The `cell` snippet is used for both desktop and mobile layouts. Use the `variant` param if rendering differs per layout.
@@ -204,33 +287,38 @@ Replace the entire `<tr>` on desktop. When this snippet is provided, DataTable d
204
287
 
205
288
  ## Props
206
289
 
207
- | Prop | Type | Default | Description |
208
- | --------------------- | ---------------------------------- | ------------- | ------------------------------------------------------------------------ |
209
- | `columns` | `DataTableColumn<T>[]` | required | Column definitions |
210
- | `data` | `T[]` | required | Array of row data |
211
- | `getRowId` | `(row, index) => string \| number` | `(_, i) => i` | Row ID extractor |
212
- | `paging` | `PagingCalcResult` | - | Paging state (from `@marianmeres/paging-store`) |
213
- | `onPageChange` | `(offset: number) => void` | - | Called with the new offset when the user navigates pages |
214
- | `selectable` | `boolean` | `false` | Enable selection checkboxes |
215
- | `selected` | `Set<string \| number>` | `new Set()` | Selected row IDs (bindable) |
216
- | `selectOnRowClick` | `boolean` | `false` | Clicking anywhere on a row toggles its selection |
217
- | `selectDisabledBy` | `(row, index) => boolean` | - | Return `true` to disable selection for a specific row |
218
- | `allowSelectAllPages` | `boolean` | `false` | Show a banner offering "select all results" across paged data |
219
- | `selectedAll` | `boolean` | `false` | All-pages mode flag (bindable). In this mode `excluded` drives selection |
220
- | `excluded` | `Set<string \| number>` | `new Set()` | Deselected row IDs while in all-pages mode (bindable) |
221
- | `onRowClick` | `(row, index) => void` | - | Row click callback |
222
- | `loading` | `boolean` | `false` | Show loading overlay |
223
- | `small` | `boolean` | `false` | Force mobile/card layout regardless of viewport |
224
- | `t` | `TranslateFn` | built-in | Optional translation function |
225
- | `cell` | `Snippet` | - | Custom cell renderer (desktop + mobile) |
226
- | `row` | `Snippet` | - | Custom desktop `<tr>` renderer (overrides default row) |
227
- | `mobileRow` | `Snippet` | - | Custom mobile card renderer |
228
- | `batchActions` | `Snippet` | - | Batch action bar content |
229
- | `selectAllBanner` | `Snippet` | - | Override default "select all across pages" banner |
230
- | `empty` | `Snippet` | - | Custom empty state |
231
- | `unstyled` | `boolean` | `false` | Skip default styling |
232
- | `class` | `string` | - | Additional CSS classes |
233
- | `el` | `HTMLDivElement` | - | Bindable element ref |
290
+ | Prop | Type | Default | Description |
291
+ | --------------------- | ------------------------------------- | ------------- | -------------------------------------------------------------------------- |
292
+ | `columns` | `DataTableColumn<T>[]` | required | Column definitions |
293
+ | `data` | `T[]` | required | Array of row data |
294
+ | `getRowId` | `(row, index) => string \| number` | `(_, i) => i` | Row ID extractor |
295
+ | `paging` | `PagingCalcResult` | - | Paging state (from `@marianmeres/paging-store`) |
296
+ | `onPageChange` | `(offset: number) => void` | - | Called with the new offset when the user navigates pages |
297
+ | `selectable` | `boolean` | `false` | Enable selection checkboxes |
298
+ | `selected` | `Set<string \| number>` | `new Set()` | Selected row IDs (bindable) |
299
+ | `selectOnRowClick` | `boolean` | `false` | Clicking anywhere on a row toggles its selection |
300
+ | `selectDisabledBy` | `(row, index) => boolean` | - | Return `true` to disable selection for a specific row |
301
+ | `allowSelectAllPages` | `boolean` | `false` | Show a banner offering "select all results" across paged data |
302
+ | `selectedAll` | `boolean` | `false` | All-pages mode flag (bindable). In this mode `excluded` drives selection |
303
+ | `excluded` | `Set<string \| number>` | `new Set()` | Deselected row IDs while in all-pages mode (bindable) |
304
+ | `onRowClick` | `(row, index) => void` | - | Row click callback |
305
+ | `rowHref` | `(row, index) => string \| undefined` | - | Wrap the lead cell's content in an `<a href>` (keyboard/⌘-click reachable) |
306
+ | `rowHrefColumn` | `string` | first column | Which column is the lead cell for `rowHref` |
307
+ | `classRowLink` | `string` | - | Extra classes for the `rowHref` anchor |
308
+ | `rowActivatable` | `boolean` | `false` | Make the desktop `<tr>` focusable + `Enter`-activatable (no `role`) |
309
+ | `rowLabel` | `(row, index) => string \| undefined` | - | Accessible name for an activatable row |
310
+ | `loading` | `boolean` | `false` | Show loading overlay |
311
+ | `small` | `boolean` | `false` | Force mobile/card layout regardless of viewport |
312
+ | `t` | `TranslateFn` | built-in | Optional translation function |
313
+ | `cell` | `Snippet` | - | Custom cell renderer (desktop + mobile) |
314
+ | `row` | `Snippet` | - | Custom desktop `<tr>` renderer (overrides default row) |
315
+ | `mobileRow` | `Snippet` | - | Custom mobile card renderer |
316
+ | `batchActions` | `Snippet` | - | Batch action bar content |
317
+ | `selectAllBanner` | `Snippet` | - | Override default "select all across pages" banner |
318
+ | `empty` | `Snippet` | - | Custom empty state |
319
+ | `unstyled` | `boolean` | `false` | Skip default styling |
320
+ | `class` | `string` | - | Additional CSS classes |
321
+ | `el` | `HTMLDivElement` | - | Bindable element ref |
234
322
 
235
323
  ### Snippet signatures
236
324
 
@@ -262,29 +350,34 @@ Replace the entire `<tr>` on desktop. When this snippet is provided, DataTable d
262
350
 
263
351
  ## CSS Variables
264
352
 
265
- | Variable | Default | Description |
266
- | ----------------------------------------- | ------------------------------------- | ---------------------------- |
267
- | `--stuic-data-table-radius` | `var(--radius-md)` | Border radius |
268
- | `--stuic-data-table-border-color` | `var(--stuic-color-border)` | Border color |
269
- | `--stuic-data-table-header-bg` | `var(--stuic-color-muted)` | Header background |
270
- | `--stuic-data-table-header-color` | `var(--stuic-color-muted-foreground)` | Header text |
271
- | `--stuic-data-table-header-font-size` | `0.875rem` | Header font size |
272
- | `--stuic-data-table-header-font-weight` | `var(--font-weight-semibold)` | Header font weight |
273
- | `--stuic-data-table-header-padding-x` | `0.75rem` | Header horizontal padding |
274
- | `--stuic-data-table-header-padding-y` | `0.5rem` | Header vertical padding |
275
- | `--stuic-data-table-row-bg` | `transparent` | Row background |
276
- | `--stuic-data-table-row-bg-hover` | `var(--stuic-color-muted)` | Row hover background |
277
- | `--stuic-data-table-row-bg-selected` | `color-mix(primary 10%)` | Selected row background |
278
- | `--stuic-data-table-row-border-color` | `var(--stuic-color-border)` | Row border color |
279
- | `--stuic-data-table-cell-padding-x` | `0.75rem` | Cell horizontal padding |
280
- | `--stuic-data-table-cell-padding-y` | `0.75rem` | Cell vertical padding |
281
- | `--stuic-data-table-cell-font-size` | `0.875rem` | Cell font size |
282
- | `--stuic-data-table-loading-opacity` | `0.5` | Loading state opacity |
283
- | `--stuic-data-table-card-bg` | `var(--stuic-color-background)` | Mobile card background |
284
- | `--stuic-data-table-card-border-color` | `var(--stuic-color-border)` | Mobile card border |
285
- | `--stuic-data-table-card-radius` | `var(--radius-md)` | Mobile card radius |
286
- | `--stuic-data-table-card-padding` | `0.75rem` | Mobile card padding |
287
- | `--stuic-data-table-card-gap` | `0.5rem` | Gap between mobile cards |
288
- | `--stuic-data-table-select-all-bg` | `color-mix(primary 10%)` | Select-all banner background |
289
- | `--stuic-data-table-select-all-padding-x` | `0.75rem` | Banner horizontal padding |
290
- | `--stuic-data-table-select-all-padding-y` | `0.5rem` | Banner vertical padding |
353
+ | Variable | Default | Description |
354
+ | ---------------------------------------------- | ------------------------------------- | ---------------------------- |
355
+ | `--stuic-data-table-radius` | `var(--radius-md)` | Border radius |
356
+ | `--stuic-data-table-border-color` | `var(--stuic-color-border)` | Border color |
357
+ | `--stuic-data-table-header-bg` | `var(--stuic-color-muted)` | Header background |
358
+ | `--stuic-data-table-header-color` | `var(--stuic-color-muted-foreground)` | Header text |
359
+ | `--stuic-data-table-header-font-size` | `0.875rem` | Header font size |
360
+ | `--stuic-data-table-header-font-weight` | `var(--font-weight-semibold)` | Header font weight |
361
+ | `--stuic-data-table-header-padding-x` | `0.75rem` | Header horizontal padding |
362
+ | `--stuic-data-table-header-padding-y` | `0.5rem` | Header vertical padding |
363
+ | `--stuic-data-table-row-bg` | `transparent` | Row background |
364
+ | `--stuic-data-table-row-bg-hover` | `var(--stuic-color-muted)` | Row hover background |
365
+ | `--stuic-data-table-row-bg-selected` | `color-mix(primary 10%)` | Selected row background |
366
+ | `--stuic-data-table-row-border-color` | `var(--stuic-color-border)` | Row border color |
367
+ | `--stuic-data-table-row-link-color` | `inherit` | `rowHref` anchor color |
368
+ | `--stuic-data-table-row-link-decoration` | `none` | `rowHref` anchor decoration |
369
+ | `--stuic-data-table-row-link-decoration-hover` | `underline` | …on hover |
370
+ | `--stuic-data-table-row-ring-width` | `3px` | Activatable row focus ring |
371
+ | `--stuic-data-table-row-ring-color` | `var(--stuic-color-ring)` | Activatable row ring color |
372
+ | `--stuic-data-table-cell-padding-x` | `0.75rem` | Cell horizontal padding |
373
+ | `--stuic-data-table-cell-padding-y` | `0.75rem` | Cell vertical padding |
374
+ | `--stuic-data-table-cell-font-size` | `0.875rem` | Cell font size |
375
+ | `--stuic-data-table-loading-opacity` | `0.5` | Loading state opacity |
376
+ | `--stuic-data-table-card-bg` | `var(--stuic-color-background)` | Mobile card background |
377
+ | `--stuic-data-table-card-border-color` | `var(--stuic-color-border)` | Mobile card border |
378
+ | `--stuic-data-table-card-radius` | `var(--radius-md)` | Mobile card radius |
379
+ | `--stuic-data-table-card-padding` | `0.75rem` | Mobile card padding |
380
+ | `--stuic-data-table-card-gap` | `0.5rem` | Gap between mobile cards |
381
+ | `--stuic-data-table-select-all-bg` | `color-mix(primary 10%)` | Select-all banner background |
382
+ | `--stuic-data-table-select-all-padding-x` | `0.75rem` | Banner horizontal padding |
383
+ | `--stuic-data-table-select-all-padding-y` | `0.5rem` | Banner vertical padding |
@@ -24,6 +24,15 @@
24
24
  --stuic-data-table-row-bg-selected: color-mix(in srgb, var(--stuic-color-primary) 10%, var(--stuic-color-background));
25
25
  --stuic-data-table-row-border-color: var(--stuic-color-border);
26
26
 
27
+ /* Row link (rowHref) — defaults to "looks like the text it replaced" */
28
+ --stuic-data-table-row-link-color: inherit;
29
+ --stuic-data-table-row-link-decoration: none;
30
+ --stuic-data-table-row-link-decoration-hover: underline;
31
+
32
+ /* Focus ring for an activatable row (rowActivatable) */
33
+ --stuic-data-table-row-ring-width: 3px;
34
+ --stuic-data-table-row-ring-color: var(--stuic-color-ring);
35
+
27
36
  /* Cell */
28
37
  --stuic-data-table-cell-padding-x: 0.75rem;
29
38
  --stuic-data-table-cell-padding-y: 0.75rem;
@@ -152,6 +161,31 @@
152
161
  );
153
162
  }
154
163
 
164
+ /*
165
+ `rowActivatable` puts tabindex on the <tr>, and a focusable element with no
166
+ visible ring is worse than no tabindex at all. The negative offset keeps the
167
+ ring inside the row box — an outset ring on a <tr> is clipped unpredictably
168
+ by border-collapse.
169
+ */
170
+ .stuic-data-table tbody tr:focus-visible {
171
+ outline: var(--stuic-data-table-row-ring-width) solid
172
+ var(--stuic-data-table-row-ring-color);
173
+ outline-offset: calc(var(--stuic-data-table-row-ring-width) * -1);
174
+ }
175
+
176
+ /* ============================================================================
177
+ ROW LINK (rowHref)
178
+ ============================================================================ */
179
+
180
+ .stuic-data-table-row-link {
181
+ color: var(--stuic-data-table-row-link-color);
182
+ text-decoration: var(--stuic-data-table-row-link-decoration);
183
+ }
184
+
185
+ .stuic-data-table-row-link:hover {
186
+ text-decoration: var(--stuic-data-table-row-link-decoration-hover);
187
+ }
188
+
155
189
  /* ============================================================================
156
190
  CELL
157
191
  ============================================================================ */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.161.0",
3
+ "version": "3.162.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",