@marianmeres/stuic 3.161.0 → 3.163.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.
@@ -4,35 +4,8 @@
4
4
  import type { PagingCalcResult } from "@marianmeres/paging-store";
5
5
  import type { THC } from "../Thc/index.js";
6
6
  import type { TranslateFn } from "../../types.js";
7
- import { isPlainObject } from "../../utils/is-plain-object.js";
8
- import { replaceMap } from "../../utils/replace-map.js";
9
-
10
- // i18n ready
11
- function t_default(
12
- k: string,
13
- values: false | null | undefined | Record<string, string | number> = null,
14
- fallback: string | boolean = "",
15
- _i18nSpanWrap: boolean = true
16
- ) {
17
- const m: Record<string, string> = {
18
- previous_page: "Prev",
19
- next_page: "Next",
20
- page_x_of_y: "Page {page} of {pageCount}",
21
- no_data: "No data",
22
- select_all_rows: "Select all rows on this page",
23
- select_row: "Select row",
24
- select_all_on_page_x: "All {count} on this page selected.",
25
- select_all_results: "Select all {totalCount} results",
26
- all_results_selected: "All {totalCount} results selected.",
27
- clear_selection: "Clear selection",
28
- };
29
- let out = m[k] ?? fallback ?? k;
30
- return isPlainObject(values)
31
- ? replaceMap(out, values as any, {
32
- preSearchKeyTransform: (k) => `{${k}}`,
33
- })
34
- : out;
35
- }
7
+ // i18n ready -- see ./i18n.ts (english, built-in) and ./i18n-sk.ts (opt-in slovak)
8
+ import { DATA_TABLE_MESSAGES_EN, t_default } from "./i18n.js";
36
9
 
37
10
  export interface DataTableColumn<T = Record<string, any>> {
38
11
  /** Property key to extract from row data (supports dot-notation: "data.name") */
@@ -95,9 +68,71 @@
95
68
  /** Set of row IDs explicitly deselected while in all-pages mode (bindable) */
96
69
  excluded?: Set<string | number>;
97
70
 
71
+ /**
72
+ * Keep the selection bar -- the strip above the table holding the idle status,
73
+ * `batchActions`, and the select-all-across-pages offer -- in the layout at all
74
+ * times, so checking the first row does not shove the table down. While nothing
75
+ * is selected it reads `t("no_rows_selected")`.
76
+ *
77
+ * Only meaningful when `selectable` is on AND there is something to reserve room
78
+ * for (a `batchActions` snippet or `allowSelectAllPages`); with neither, no bar
79
+ * is rendered at all.
80
+ *
81
+ * Set `false` for the pop-in behaviour: the bar appears only once it has content.
82
+ */
83
+ reserveBatchBar?: boolean;
84
+
98
85
  /** Callback when a row is clicked */
99
86
  onRowClick?: (row: T, index: number) => void;
100
87
 
88
+ /**
89
+ * Where this row's primary action navigates. When it returns a href for a row, the
90
+ * content of that row's "lead" cell is wrapped in an anchor -- which is what makes a
91
+ * clickable row keyboard-, middle- and modifier-click-reachable.
92
+ *
93
+ * Return `undefined` for a row with no destination (a placeholder, a row still
94
+ * minting its id) -- that row's lead cell renders exactly as it does today.
95
+ *
96
+ * Independent of `onRowClick`: supply both and the anchor handles the keyboard and
97
+ * modified clicks while `onRowClick` keeps handling a plain click anywhere else on
98
+ * the row -- the row click handler ignores events originating inside an anchor, so
99
+ * the two never double-fire. (Consequence: a plain click on the lead cell navigates
100
+ * via the href and does NOT call `onRowClick` -- point both at the same destination.)
101
+ */
102
+ rowHref?: (row: T, index: number) => string | undefined;
103
+ /**
104
+ * Which column is the "lead" cell for `rowHref`. Defaults to the first column in
105
+ * `columns`. On mobile, if that column is `hideOnMobile`, the first visible card
106
+ * field is used instead (so the card keeps a keyboard-reachable link).
107
+ */
108
+ rowHrefColumn?: string;
109
+ /** Additional CSS classes for the anchor generated by `rowHref`. */
110
+ classRowLink?: string;
111
+
112
+ /**
113
+ * Make the desktop `<tr>` itself focusable and Enter-activatable.
114
+ *
115
+ * For a row whose action is NOT a navigation -- prefer `rowHref` when it is, since a
116
+ * real link is better in every respect (tab order, `Enter`, middle/cmd-click, "copy
117
+ * link address", and an announcement of where it goes).
118
+ *
119
+ * Deliberately does NOT set `role="button"`: a `<tr>` carries an implicit `row` role
120
+ * that the table's own structure depends on, and overriding it detaches the row from
121
+ * the table for assistive technology. The row stays a row; it merely becomes focusable.
122
+ *
123
+ * Also deliberately `Enter`-only, unlike the mobile card (which IS a `role="button"`,
124
+ * where `Space` is expected). Taking `Space` -- page scroll -- away from a table is a
125
+ * worse trade than the one the card makes.
126
+ *
127
+ * No-op unless `onRowClick` or `selectOnRowClick` is set.
128
+ */
129
+ rowActivatable?: boolean;
130
+ /**
131
+ * Accessible name for an activatable row, e.g. `(row) => "Open " + row.name`.
132
+ * Only applied when the row is actually activatable.
133
+ */
134
+ rowLabel?: (row: T, index: number) => string | undefined;
135
+
101
136
  /** Show loading state (spinner overlay + reduced opacity) */
102
137
  loading?: boolean;
103
138
 
@@ -125,7 +160,8 @@
125
160
  ]
126
161
  >;
127
162
  /**
128
- * Batch actions bar snippet (shown when items are selected).
163
+ * Content of the selection bar while something is selected. Replaces the default
164
+ * "{count} selected" status, so render your own count alongside the buttons.
129
165
  *
130
166
  * Note: in all-pages mode (`selectedAll === true`) `selectedRows` only contains
131
167
  * rows from the current page that aren't excluded. Off-page rows are not
@@ -147,8 +183,8 @@
147
183
  ]
148
184
  >;
149
185
  /**
150
- * Custom "select all results across pages" banner. When omitted, a default
151
- * banner is rendered.
186
+ * Custom "select all results across pages" offer, rendered on the trailing edge
187
+ * of the selection bar. When omitted, a default one is rendered.
152
188
  */
153
189
  selectAllBanner?: Snippet<
154
190
  [
@@ -202,7 +238,13 @@
202
238
  allowSelectAllPages = false,
203
239
  selectedAll = $bindable(false),
204
240
  excluded = $bindable(new Set()),
241
+ reserveBatchBar = true,
205
242
  onRowClick,
243
+ rowHref,
244
+ rowHrefColumn,
245
+ classRowLink,
246
+ rowActivatable = false,
247
+ rowLabel,
206
248
  loading = false,
207
249
  cell,
208
250
  row,
@@ -342,22 +384,70 @@
342
384
  return allOnPageSelected;
343
385
  });
344
386
 
345
- // --- Row click ---
346
- function handleRowClick(row: T, index: number, e: MouseEvent) {
347
- const target = e.target as HTMLElement;
348
- if (
387
+ // The default selection status, shown when there is no `batchActions` snippet to take
388
+ // its place. Both keys post-date the component, so a consumer `t` written against an
389
+ // older version does not know them -- and one that answers an unknown key with ""
390
+ // would leave the reserved bar visibly blank. Hence the explicit english fallbacks
391
+ // (pre-interpolated, since a `t` may hand a fallback straight back untouched), which
392
+ // every other, older key can do without.
393
+ let statusText = $derived(
394
+ effectiveCount > 0
395
+ ? t(
396
+ "x_rows_selected",
397
+ { count: effectiveCount },
398
+ DATA_TABLE_MESSAGES_EN.x_rows_selected.replace("{count}", `${effectiveCount}`)
399
+ )
400
+ : t("no_rows_selected", null, DATA_TABLE_MESSAGES_EN.no_rows_selected)
401
+ );
402
+
403
+ // The bar only exists if something can appear in it; `reserveBatchBar` then decides
404
+ // whether it holds its place while that something is absent.
405
+ let hasBatchBar = $derived(selectable && (!!batchActions || allowSelectAllPages));
406
+ let showBatchBar = $derived(
407
+ hasBatchBar &&
408
+ (reserveBatchBar || showSelectAllBanner || (effectiveCount > 0 && !!batchActions))
409
+ );
410
+
411
+ // --- Row click / activation ---
412
+
413
+ // A row is a big target that legitimately contains its own controls. Anything that
414
+ // originates inside one of those is that control's business, not the row's - this
415
+ // guards the click handler AND both keydown handlers (Enter on a focused lead link
416
+ // must navigate, not fire onRowClick; Space on a focused checkbox must toggle it).
417
+ function isInteractiveTarget(e: Event): boolean {
418
+ const target = e.target as HTMLElement | null;
419
+ if (!target?.closest) return false;
420
+ return !!(
349
421
  target.closest('input[type="checkbox"]') ||
350
422
  target.closest("button") ||
351
423
  target.closest("a")
352
- ) {
353
- return;
354
- }
424
+ );
425
+ }
426
+
427
+ function activateRow(row: T, index: number) {
355
428
  if (selectable && selectOnRowClick) {
356
429
  toggleSelectRow(getRowId(row, index));
357
430
  }
358
431
  onRowClick?.(row, index);
359
432
  }
360
433
 
434
+ function handleRowClick(row: T, index: number, e: MouseEvent) {
435
+ if (isInteractiveTarget(e)) return;
436
+ activateRow(row, index);
437
+ }
438
+
439
+ /**
440
+ * Shared by the desktop `<tr>` (Enter only) and the mobile card (Enter + Space, since
441
+ * the card actually is a `role="button"`).
442
+ */
443
+ function handleRowKeydown(row: T, index: number, e: KeyboardEvent, withSpace: boolean) {
444
+ if (!rowClickable) return;
445
+ if (e.key !== "Enter" && !(withSpace && e.key === " ")) return;
446
+ if (isInteractiveTarget(e)) return;
447
+ e.preventDefault();
448
+ activateRow(row, index);
449
+ }
450
+
361
451
  // --- Cell value helpers ---
362
452
  function getCellValue(row: T, column: DataTableColumn<T>): any {
363
453
  return column.key.split(".").reduce((obj: any, k) => obj?.[k], row);
@@ -373,49 +463,101 @@
373
463
  let rootClass = $derived(unstyled ? classProp : twMerge("stuic-data-table", classProp));
374
464
 
375
465
  let mobileColumns = $derived(columns.filter((col) => !col.hideOnMobile));
376
- </script>
377
466
 
378
- <!-- Batch action bar -->
379
- {#if selectable && effectiveCount > 0 && batchActions}
380
- <div class={!unstyled ? "stuic-data-table-batch" : undefined}>
381
- {@render batchActions({
382
- selected,
383
- selectedRows,
384
- selectedAll,
385
- excluded,
386
- effectiveCount,
387
- totalCount,
388
- clearSelection: clearAllSelection,
389
- })}
390
- </div>
391
- {/if}
467
+ // --- Row link / activation derivations ---
468
+ let rowClickable = $derived(!!(onRowClick || selectOnRowClick));
469
+ let rowIsActivatable = $derived(rowActivatable && rowClickable);
470
+
471
+ // The lead cell is the one whose content gets wrapped in the `rowHref` anchor.
472
+ let leadColumnKey = $derived(rowHrefColumn ?? columns[0]?.key);
473
+ // A `hideOnMobile` lead column would leave the card with no link at all, so there we
474
+ // fall back to the first visible field rather than silently dropping the anchor.
475
+ let mobileLeadColumnKey = $derived(
476
+ mobileColumns.some((col) => col.key === leadColumnKey)
477
+ ? leadColumnKey
478
+ : mobileColumns[0]?.key
479
+ );
480
+
481
+ let rowLinkClass = $derived(
482
+ unstyled ? classRowLink : twMerge("stuic-data-table-row-link", classRowLink)
483
+ );
484
+ </script>
392
485
 
393
- <!-- Select-all-across-pages banner -->
394
- {#if showSelectAllBanner && paging}
395
- {#if selectAllBanner}
396
- {@render selectAllBanner({
397
- selectedAll,
398
- effectiveCount,
399
- totalCount: paging.total,
400
- pageCount: data.length,
401
- selectAll: enterSelectAll,
402
- clearSelection: clearAllSelection,
403
- })}
486
+ <!--
487
+ The rendered content of a single cell, shared by both layouts so that a `rowHref`
488
+ anchor wraps exactly what the cell would have rendered anyway (a consumer `cell`
489
+ snippet included).
490
+ -->
491
+ {#snippet cellBody(
492
+ col: DataTableColumn<T>,
493
+ rowData: T,
494
+ value: any,
495
+ rowIndex: number,
496
+ variant: "desktop" | "mobile"
497
+ )}
498
+ {#if cell}
499
+ {@render cell({ column: col, row: rowData, value, rowIndex, variant })}
404
500
  {:else}
405
- <div class={!unstyled ? "stuic-data-table-select-all-banner" : undefined}>
406
- {#if selectedAll}
407
- <span>{t("all_results_selected", { totalCount: paging.total })}</span>
408
- <Button variant="ghost" size="sm" onclick={clearAllSelection}>
409
- {t("clear_selection")}
410
- </Button>
501
+ {getCellDisplay(rowData, col)}
502
+ {/if}
503
+ {/snippet}
504
+
505
+ <!--
506
+ Selection bar -- one strip carrying the selection status, the consumer's
507
+ `batchActions`, and the select-all-across-pages offer. Deliberately a single element
508
+ rather than a stack of independent conditionals: each of those appearing on its own
509
+ reflows everything below it, which is exactly the jump `reserveBatchBar` exists to
510
+ prevent. Sits outside the root container so the loading overlay doesn't cover it.
511
+ -->
512
+ {#if showBatchBar}
513
+ <div
514
+ class={!unstyled ? "stuic-data-table-batch" : undefined}
515
+ data-idle={!unstyled && effectiveCount === 0 ? "true" : undefined}
516
+ data-select-all={!unstyled && showSelectAllBanner ? "true" : undefined}
517
+ >
518
+ <div class={!unstyled ? "stuic-data-table-batch-main" : undefined}>
519
+ {#if effectiveCount > 0 && batchActions}
520
+ {@render batchActions({
521
+ selected,
522
+ selectedRows,
523
+ selectedAll,
524
+ excluded,
525
+ effectiveCount,
526
+ totalCount,
527
+ clearSelection: clearAllSelection,
528
+ })}
411
529
  {:else}
412
- <span>{t("select_all_on_page_x", { count: data.length })}</span>
413
- <Button variant="ghost" size="sm" onclick={enterSelectAll}>
414
- {t("select_all_results", { totalCount: paging.total })}
415
- </Button>
530
+ <span class={!unstyled ? "stuic-data-table-batch-status" : undefined}>
531
+ {statusText}
532
+ </span>
416
533
  {/if}
417
534
  </div>
418
- {/if}
535
+
536
+ {#if showSelectAllBanner && paging}
537
+ <div class={!unstyled ? "stuic-data-table-batch-select-all" : undefined}>
538
+ {#if selectAllBanner}
539
+ {@render selectAllBanner({
540
+ selectedAll,
541
+ effectiveCount,
542
+ totalCount: paging.total,
543
+ pageCount: data.length,
544
+ selectAll: enterSelectAll,
545
+ clearSelection: clearAllSelection,
546
+ })}
547
+ {:else if selectedAll}
548
+ <span>{t("all_results_selected", { totalCount: paging.total })}</span>
549
+ <Button variant="ghost" size="sm" onclick={clearAllSelection}>
550
+ {t("clear_selection")}
551
+ </Button>
552
+ {:else}
553
+ <span>{t("select_all_on_page_x", { count: data.length })}</span>
554
+ <Button variant="ghost" size="sm" onclick={enterSelectAll}>
555
+ {t("select_all_results", { totalCount: paging.total })}
556
+ </Button>
557
+ {/if}
558
+ </div>
559
+ {/if}
560
+ </div>
419
561
  {/if}
420
562
 
421
563
  <!-- Root container -->
@@ -461,16 +603,20 @@
461
603
  {@const rowId = getRowId(rowData, rowIndex)}
462
604
  {@const isSelected = selectable && isRowSelected(rowId)}
463
605
  {@const selectDisabled = !!selectDisabledBy?.(rowData, rowIndex)}
606
+ {@const rowLink = rowHref?.(rowData, rowIndex)}
464
607
  {#if row}
465
608
  {@render row({ row: rowData, columns, rowIndex, isSelected })}
466
609
  {:else}
467
610
  <tr
468
611
  data-hoverable={!unstyled ? "true" : undefined}
469
- data-clickable={!unstyled && (onRowClick || selectOnRowClick)
470
- ? "true"
471
- : undefined}
612
+ data-clickable={!unstyled && rowClickable ? "true" : undefined}
472
613
  data-selected={!unstyled && isSelected ? "true" : undefined}
614
+ tabindex={rowIsActivatable ? 0 : undefined}
615
+ aria-label={rowIsActivatable ? rowLabel?.(rowData, rowIndex) : undefined}
473
616
  onclick={(e) => handleRowClick(rowData, rowIndex, e)}
617
+ onkeydown={(e) => {
618
+ if (rowActivatable) handleRowKeydown(rowData, rowIndex, e, false);
619
+ }}
474
620
  >
475
621
  {#if selectable}
476
622
  <td data-checkbox class="stuic-checkbox">
@@ -485,20 +631,17 @@
485
631
  {/if}
486
632
  {#each columns as col (col.key)}
487
633
  {@const value = getCellValue(rowData, col)}
634
+ {@const href = col.key === leadColumnKey ? rowLink : undefined}
488
635
  <td
489
636
  class={col.class}
490
637
  data-align={!unstyled && col.align ? col.align : undefined}
491
638
  >
492
- {#if cell}
493
- {@render cell({
494
- column: col,
495
- row: rowData,
496
- value,
497
- rowIndex,
498
- variant: "desktop",
499
- })}
639
+ {#if href}
640
+ <a class={rowLinkClass} {href}>
641
+ {@render cellBody(col, rowData, value, rowIndex, "desktop")}
642
+ </a>
500
643
  {:else}
501
- {getCellDisplay(rowData, col)}
644
+ {@render cellBody(col, rowData, value, rowIndex, "desktop")}
502
645
  {/if}
503
646
  </td>
504
647
  {/each}
@@ -531,6 +674,7 @@
531
674
  {@const rowId = getRowId(rowData, rowIndex)}
532
675
  {@const isSelected = selectable && isRowSelected(rowId)}
533
676
  {@const selectDisabled = !!selectDisabledBy?.(rowData, rowIndex)}
677
+ {@const rowLink = rowHref?.(rowData, rowIndex)}
534
678
  {#if mobileRow}
535
679
  {@render mobileRow({
536
680
  row: rowData,
@@ -542,25 +686,12 @@
542
686
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
543
687
  <div
544
688
  class={!unstyled ? "stuic-data-table-card" : undefined}
545
- data-clickable={!unstyled && (onRowClick || selectOnRowClick)
546
- ? "true"
547
- : undefined}
689
+ data-clickable={!unstyled && rowClickable ? "true" : undefined}
548
690
  data-selected={!unstyled && isSelected ? "true" : undefined}
549
- role={onRowClick || selectOnRowClick ? "button" : undefined}
550
- tabindex={onRowClick || selectOnRowClick ? 0 : undefined}
691
+ role={rowClickable ? "button" : undefined}
692
+ tabindex={rowClickable ? 0 : undefined}
551
693
  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
- }}
694
+ onkeydown={(e) => handleRowKeydown(rowData, rowIndex, e, true)}
564
695
  >
565
696
  {#if selectable}
566
697
  <div
@@ -579,6 +710,7 @@
579
710
  {/if}
580
711
  {#each mobileColumns as col (col.key)}
581
712
  {@const value = getCellValue(rowData, col)}
713
+ {@const href = col.key === mobileLeadColumnKey ? rowLink : undefined}
582
714
  <div class={!unstyled ? "stuic-data-table-card-row" : undefined}>
583
715
  <span class={!unstyled ? "stuic-data-table-card-label" : undefined}>
584
716
  {#if isTHCNotEmpty(col.label)}
@@ -588,16 +720,12 @@
588
720
  {/if}
589
721
  </span>
590
722
  <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
- })}
723
+ {#if href}
724
+ <a class={rowLinkClass} {href}>
725
+ {@render cellBody(col, rowData, value, rowIndex, "mobile")}
726
+ </a>
599
727
  {:else}
600
- {getCellDisplay(rowData, col)}
728
+ {@render cellBody(col, rowData, value, rowIndex, "mobile")}
601
729
  {/if}
602
730
  </span>
603
731
  </div>
@@ -56,8 +56,67 @@ export interface Props<T = Record<string, any>> extends Omit<HTMLAttributes<HTML
56
56
  selectedAll?: boolean;
57
57
  /** Set of row IDs explicitly deselected while in all-pages mode (bindable) */
58
58
  excluded?: Set<string | number>;
59
+ /**
60
+ * Keep the selection bar -- the strip above the table holding the idle status,
61
+ * `batchActions`, and the select-all-across-pages offer -- in the layout at all
62
+ * times, so checking the first row does not shove the table down. While nothing
63
+ * is selected it reads `t("no_rows_selected")`.
64
+ *
65
+ * Only meaningful when `selectable` is on AND there is something to reserve room
66
+ * for (a `batchActions` snippet or `allowSelectAllPages`); with neither, no bar
67
+ * is rendered at all.
68
+ *
69
+ * Set `false` for the pop-in behaviour: the bar appears only once it has content.
70
+ */
71
+ reserveBatchBar?: boolean;
59
72
  /** Callback when a row is clicked */
60
73
  onRowClick?: (row: T, index: number) => void;
74
+ /**
75
+ * Where this row's primary action navigates. When it returns a href for a row, the
76
+ * content of that row's "lead" cell is wrapped in an anchor -- which is what makes a
77
+ * clickable row keyboard-, middle- and modifier-click-reachable.
78
+ *
79
+ * Return `undefined` for a row with no destination (a placeholder, a row still
80
+ * minting its id) -- that row's lead cell renders exactly as it does today.
81
+ *
82
+ * Independent of `onRowClick`: supply both and the anchor handles the keyboard and
83
+ * modified clicks while `onRowClick` keeps handling a plain click anywhere else on
84
+ * the row -- the row click handler ignores events originating inside an anchor, so
85
+ * the two never double-fire. (Consequence: a plain click on the lead cell navigates
86
+ * via the href and does NOT call `onRowClick` -- point both at the same destination.)
87
+ */
88
+ rowHref?: (row: T, index: number) => string | undefined;
89
+ /**
90
+ * Which column is the "lead" cell for `rowHref`. Defaults to the first column in
91
+ * `columns`. On mobile, if that column is `hideOnMobile`, the first visible card
92
+ * field is used instead (so the card keeps a keyboard-reachable link).
93
+ */
94
+ rowHrefColumn?: string;
95
+ /** Additional CSS classes for the anchor generated by `rowHref`. */
96
+ classRowLink?: string;
97
+ /**
98
+ * Make the desktop `<tr>` itself focusable and Enter-activatable.
99
+ *
100
+ * For a row whose action is NOT a navigation -- prefer `rowHref` when it is, since a
101
+ * real link is better in every respect (tab order, `Enter`, middle/cmd-click, "copy
102
+ * link address", and an announcement of where it goes).
103
+ *
104
+ * Deliberately does NOT set `role="button"`: a `<tr>` carries an implicit `row` role
105
+ * that the table's own structure depends on, and overriding it detaches the row from
106
+ * the table for assistive technology. The row stays a row; it merely becomes focusable.
107
+ *
108
+ * Also deliberately `Enter`-only, unlike the mobile card (which IS a `role="button"`,
109
+ * where `Space` is expected). Taking `Space` -- page scroll -- away from a table is a
110
+ * worse trade than the one the card makes.
111
+ *
112
+ * No-op unless `onRowClick` or `selectOnRowClick` is set.
113
+ */
114
+ rowActivatable?: boolean;
115
+ /**
116
+ * Accessible name for an activatable row, e.g. `(row) => "Open " + row.name`.
117
+ * Only applied when the row is actually activatable.
118
+ */
119
+ rowLabel?: (row: T, index: number) => string | undefined;
61
120
  /** Show loading state (spinner overlay + reduced opacity) */
62
121
  loading?: boolean;
63
122
  /** Custom cell renderer snippet (rendered in both desktop table and mobile card layouts; use `variant` to tell them apart) */
@@ -80,7 +139,8 @@ export interface Props<T = Record<string, any>> extends Omit<HTMLAttributes<HTML
80
139
  }
81
140
  ]>;
82
141
  /**
83
- * Batch actions bar snippet (shown when items are selected).
142
+ * Content of the selection bar while something is selected. Replaces the default
143
+ * "{count} selected" status, so render your own count alongside the buttons.
84
144
  *
85
145
  * Note: in all-pages mode (`selectedAll === true`) `selectedRows` only contains
86
146
  * rows from the current page that aren't excluded. Off-page rows are not
@@ -100,8 +160,8 @@ export interface Props<T = Record<string, any>> extends Omit<HTMLAttributes<HTML
100
160
  }
101
161
  ]>;
102
162
  /**
103
- * Custom "select all results across pages" banner. When omitted, a default
104
- * banner is rendered.
163
+ * Custom "select all results across pages" offer, rendered on the trailing edge
164
+ * of the selection bar. When omitted, a default one is rendered.
105
165
  */
106
166
  selectAllBanner?: Snippet<[
107
167
  {
@@ -64,6 +64,30 @@ DataTable integrates with [`@marianmeres/paging-store`](https://github.com/maria
64
64
 
65
65
  Shift+click a checkbox to toggle a range of rows from the last clicked checkbox to the current one. Disabled rows in the range are skipped. The anchor resets when `data` changes (e.g. on page navigation).
66
66
 
67
+ #### The selection bar holds its place
68
+
69
+ Everything selection-related lives in one strip above the table: the status while nothing
70
+ is selected, your `batchActions` while something is, and the select-all-across-pages offer
71
+ on its trailing edge. That strip is rendered from the start and keeps a constant height
72
+ (`reserveBatchBar`, on by default), so checking the first row swaps its contents instead
73
+ of pushing the table down.
74
+
75
+ - The height comes from `--stuic-data-table-batch-min-height`, sized to a `size="sm"`
76
+ Button. Batch actions built from bigger controls need that token raised, or the bar
77
+ grows when they appear — which is the jump all over again.
78
+ - Idle, the bar is transparent (`--stuic-data-table-batch-bg-idle`) so a permanently
79
+ reserved strip doesn't shout; it takes on `--stuic-data-table-batch-bg` once something
80
+ is selected and `--stuic-data-table-select-all-bg` while the select-all offer is up.
81
+ - The bar only exists when there is something to reserve room for — `selectable` plus
82
+ either a `batchActions` snippet or `allowSelectAllPages`. `selectable` on its own
83
+ renders no bar.
84
+ - `reserveBatchBar={false}` restores the old behaviour: the bar appears only once it has
85
+ content.
86
+
87
+ Without a `batchActions` snippet the bar still reports the count itself
88
+ (`t("x_rows_selected")` / `t("no_rows_selected")`). With one, the snippet replaces that
89
+ status entirely — render your own count next to the buttons.
90
+
67
91
  ### Select All Across Pages
68
92
 
69
93
  When your data is paged, DataTable can offer a "select all results" affordance that expands selection beyond the current page. Enable it with `allowSelectAllPages`. When the user opts in, selection flips to **all-pages mode**: every row is implicitly selected, and `excluded` holds IDs the user has explicitly deselected.
@@ -115,7 +139,7 @@ function deleteSelection({ selectedAll, excluded }) {
115
139
 
116
140
  New records inserted while all-pages mode is active are implicitly selected (they aren't in `excluded`). This matches the conventional intent — "delete everything matching X" should include matches that arrive before the operation runs. If you need snapshot-at-click semantics, capture a timestamp or ID list in your consumer.
117
141
 
118
- **Customising the banner:** the default banner uses the built-in `t()` keys `select_all_on_page_x`, `select_all_results`, `all_results_selected`, and `clear_selection`. Override markup entirely with the `selectAllBanner` snippet:
142
+ **Customising the offer:** the default offer renders on the trailing edge of the selection bar and uses the built-in `t()` keys `select_all_on_page_x`, `select_all_results`, `all_results_selected`, and `clear_selection`. Override its markup entirely with the `selectAllBanner` snippet (which renders in the same slot — it is no longer a standalone block below the batch bar):
119
143
 
120
144
  ```svelte
121
145
  <DataTable
@@ -143,6 +167,89 @@ New records inserted while all-pages mode is active are implicitly selected (the
143
167
 
144
168
  **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
169
 
170
+ ### Clickable Rows That Work Without a Mouse
171
+
172
+ `onRowClick` alone makes a row **mouse-only** on the desktop table layout: a `<tr>` is not
173
+ focusable, so nothing about the row is reachable by keyboard. (The mobile card layout has
174
+ always been fine — it is a `<div role="button">`.) Two opt-in props close that gap, and
175
+ they are not alternatives:
176
+
177
+ | Your row action is… | Use |
178
+ | ------------------------------- | ---------------- |
179
+ | a navigation (it has a URL) | `rowHref` |
180
+ | anything else (drawer, expand…) | `rowActivatable` |
181
+
182
+ #### `rowHref` — the lead cell becomes a real link
183
+
184
+ ```svelte
185
+ <DataTable
186
+ {columns}
187
+ {data}
188
+ getRowId={(row) => row.id}
189
+ rowHref={(row) => `/users/${row.id}`}
190
+ />
191
+ ```
192
+
193
+ The content of the row's **lead cell** (the first column by default, or `rowHrefColumn`)
194
+ is wrapped in an `<a href>` — including whatever your `cell` snippet renders for it, and
195
+ respecting the column's `renderValue`. A link gets, for free and correctly: keyboard focus
196
+ in the right tab order, `Enter`, ⌘/middle-click and "open in new tab", "copy link address",
197
+ and a screen-reader announcement of _where it goes_ rather than an anonymous "button".
198
+
199
+ Return `undefined` for a row with no destination (a placeholder, a row still minting its
200
+ id) — that row's lead cell renders exactly as it would without `rowHref`.
201
+
202
+ `rowHref` composes with `onRowClick`: the row click handler ignores clicks originating
203
+ inside an anchor, so the two never double-fire. The consequence is worth stating — a plain
204
+ click **on the lead cell** navigates via the href and does _not_ call `onRowClick`, so point
205
+ both at the same destination.
206
+
207
+ On mobile the same field is linked. If the lead column is `hideOnMobile`, the card links its
208
+ first visible field instead, rather than silently dropping the anchor.
209
+
210
+ By default the link looks like the text it replaced (`color: inherit`, no underline until
211
+ hover) — a table where every lead cell is blue-and-underlined is a worse table. Three CSS
212
+ variables and `classRowLink` are there if you disagree.
213
+
214
+ #### `rowActivatable` — the `<tr>` itself
215
+
216
+ For a row whose action has no address:
217
+
218
+ ```svelte
219
+ <DataTable
220
+ {columns}
221
+ {data}
222
+ onRowClick={(row) => openDrawer(row)}
223
+ rowActivatable
224
+ rowLabel={(row) => `Open ${row.name}`}
225
+ />
226
+ ```
227
+
228
+ The `<tr>` gets `tabindex="0"`, an optional `aria-label` from `rowLabel`, and a
229
+ `:focus-visible` ring. It is a no-op unless `onRowClick` or `selectOnRowClick` is set.
230
+
231
+ Two deliberate choices, so nobody "fixes" them later:
232
+
233
+ - **No `role="button"` on the `<tr>`.** A `<tr>` carries an implicit `row` role that the
234
+ table's own structure depends on. Overriding it detaches the row from the table for
235
+ assistive technology — row/column counts stop making sense and cell-by-cell navigation
236
+ breaks. The row stays a row; it merely becomes focusable. (The mobile card is a `<div>`,
237
+ so `role="button"` is correct _there_.)
238
+ - **`Enter` only, no `Space`.** `Space` on a focused row scrolls the page, and a row is not
239
+ a button — taking `Space` away from a table is a worse trade than the one the card makes.
240
+
241
+ With `selectable` + `selectOnRowClick`, `Enter` toggles selection _and_ fires `onRowClick`,
242
+ matching the mobile card.
243
+
244
+ #### Both are opt-in
245
+
246
+ With neither prop set, the rendered markup is exactly what it was before they existed. A
247
+ consumer who replaces the whole `<tr>` with the `row` snippet opts out of both and owns the
248
+ keyboard story themselves.
249
+
250
+ Interactive descendants always win: a click, `Enter` or `Space` that originates inside a
251
+ `<button>`, an `<a>` or a checkbox in the row is that control's business, not the row's.
252
+
146
253
  ### Custom Cell Rendering
147
254
 
148
255
  The `cell` snippet is used for both desktop and mobile layouts. Use the `variant` param if rendering differs per layout.
@@ -204,33 +311,39 @@ Replace the entire `<tr>` on desktop. When this snippet is provided, DataTable d
204
311
 
205
312
  ## Props
206
313
 
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 |
314
+ | Prop | Type | Default | Description |
315
+ | --------------------- | ------------------------------------- | ------------- | -------------------------------------------------------------------------- |
316
+ | `columns` | `DataTableColumn<T>[]` | required | Column definitions |
317
+ | `data` | `T[]` | required | Array of row data |
318
+ | `getRowId` | `(row, index) => string \| number` | `(_, i) => i` | Row ID extractor |
319
+ | `paging` | `PagingCalcResult` | - | Paging state (from `@marianmeres/paging-store`) |
320
+ | `onPageChange` | `(offset: number) => void` | - | Called with the new offset when the user navigates pages |
321
+ | `selectable` | `boolean` | `false` | Enable selection checkboxes |
322
+ | `selected` | `Set<string \| number>` | `new Set()` | Selected row IDs (bindable) |
323
+ | `selectOnRowClick` | `boolean` | `false` | Clicking anywhere on a row toggles its selection |
324
+ | `selectDisabledBy` | `(row, index) => boolean` | - | Return `true` to disable selection for a specific row |
325
+ | `allowSelectAllPages` | `boolean` | `false` | Show a banner offering "select all results" across paged data |
326
+ | `selectedAll` | `boolean` | `false` | All-pages mode flag (bindable). In this mode `excluded` drives selection |
327
+ | `excluded` | `Set<string \| number>` | `new Set()` | Deselected row IDs while in all-pages mode (bindable) |
328
+ | `reserveBatchBar` | `boolean` | `true` | Keep the selection bar in the layout while nothing is selected |
329
+ | `onRowClick` | `(row, index) => void` | - | Row click callback |
330
+ | `rowHref` | `(row, index) => string \| undefined` | - | Wrap the lead cell's content in an `<a href>` (keyboard/⌘-click reachable) |
331
+ | `rowHrefColumn` | `string` | first column | Which column is the lead cell for `rowHref` |
332
+ | `classRowLink` | `string` | - | Extra classes for the `rowHref` anchor |
333
+ | `rowActivatable` | `boolean` | `false` | Make the desktop `<tr>` focusable + `Enter`-activatable (no `role`) |
334
+ | `rowLabel` | `(row, index) => string \| undefined` | - | Accessible name for an activatable row |
335
+ | `loading` | `boolean` | `false` | Show loading overlay |
336
+ | `small` | `boolean` | `false` | Force mobile/card layout regardless of viewport |
337
+ | `t` | `TranslateFn` | built-in | Optional translation function |
338
+ | `cell` | `Snippet` | - | Custom cell renderer (desktop + mobile) |
339
+ | `row` | `Snippet` | - | Custom desktop `<tr>` renderer (overrides default row) |
340
+ | `mobileRow` | `Snippet` | - | Custom mobile card renderer |
341
+ | `batchActions` | `Snippet` | - | Selection bar content while something is selected |
342
+ | `selectAllBanner` | `Snippet` | - | Override default "select all across pages" offer |
343
+ | `empty` | `Snippet` | - | Custom empty state |
344
+ | `unstyled` | `boolean` | `false` | Skip default styling |
345
+ | `class` | `string` | - | Additional CSS classes |
346
+ | `el` | `HTMLDivElement` | - | Bindable element ref |
234
347
 
235
348
  ### Snippet signatures
236
349
 
@@ -260,31 +373,83 @@ Replace the entire `<tr>` on desktop. When this snippet is provided, DataTable d
260
373
  | `hideOnMobile` | `boolean` | `false` | Hide in mobile view |
261
374
  | `renderValue` | `(value, row) => string` | - | Value formatter |
262
375
 
376
+ ## i18n
377
+
378
+ All built-in texts go through the `t` prop. English is the built-in default; Slovak ships
379
+ bundled and opt-in (importing it is what pulls it into your bundle — English-only
380
+ consumers pay nothing).
381
+
382
+ ```svelte
383
+ <script>
384
+ import {
385
+ DataTable,
386
+ createDataTableT,
387
+ DATA_TABLE_MESSAGES_SK,
388
+ } from "@marianmeres/stuic";
389
+
390
+ const t = createDataTableT(DATA_TABLE_MESSAGES_SK);
391
+ </script>
392
+
393
+ <DataTable {columns} {data} {t} />
394
+ ```
395
+
396
+ `createDataTableT(messages, fallbackMessages?)` falls back to `DATA_TABLE_MESSAGES_EN` for
397
+ any key the catalog does not define, so a partial catalog is fine and a raw key is never
398
+ rendered — pass your own object to translate into a language that is not bundled, or to
399
+ override individual bundled texts:
400
+
401
+ ```ts
402
+ const t = createDataTableT({ ...DATA_TABLE_MESSAGES_SK, no_data: "Nič tu nie je" });
403
+ ```
404
+
405
+ | Key | English | Used by |
406
+ | ---------------------- | ---------------------------------- | ------------------------------------ |
407
+ | `previous_page` | Prev | Paging |
408
+ | `next_page` | Next | Paging |
409
+ | `page_x_of_y` | Page {page} of {pageCount} | Paging |
410
+ | `no_data` | No data | Empty state (no `empty` snippet) |
411
+ | `select_all_rows` | Select all rows on this page | Header checkbox `aria-label` |
412
+ | `select_row` | Select row | Row checkbox `aria-label` |
413
+ | `no_rows_selected` | No rows selected | Idle selection bar |
414
+ | `x_rows_selected` | {count} selected | Selection bar without `batchActions` |
415
+ | `select_all_on_page_x` | All {count} on this page selected. | Select-all offer |
416
+ | `select_all_results` | Select all {totalCount} results | Select-all offer |
417
+ | `all_results_selected` | All {totalCount} results selected. | Select-all offer, all-pages mode |
418
+ | `clear_selection` | Clear selection | Select-all offer, all-pages mode |
419
+
263
420
  ## CSS Variables
264
421
 
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 |
422
+ | Variable | Default | Description |
423
+ | ---------------------------------------------- | ------------------------------------- | --------------------------------- |
424
+ | `--stuic-data-table-radius` | `var(--radius-md)` | Border radius |
425
+ | `--stuic-data-table-border-color` | `var(--stuic-color-border)` | Border color |
426
+ | `--stuic-data-table-header-bg` | `var(--stuic-color-muted)` | Header background |
427
+ | `--stuic-data-table-header-color` | `var(--stuic-color-muted-foreground)` | Header text |
428
+ | `--stuic-data-table-header-font-size` | `0.875rem` | Header font size |
429
+ | `--stuic-data-table-header-font-weight` | `var(--font-weight-semibold)` | Header font weight |
430
+ | `--stuic-data-table-header-padding-x` | `0.75rem` | Header horizontal padding |
431
+ | `--stuic-data-table-header-padding-y` | `0.5rem` | Header vertical padding |
432
+ | `--stuic-data-table-row-bg` | `transparent` | Row background |
433
+ | `--stuic-data-table-row-bg-hover` | `var(--stuic-color-muted)` | Row hover background |
434
+ | `--stuic-data-table-row-bg-selected` | `color-mix(primary 10%)` | Selected row background |
435
+ | `--stuic-data-table-row-border-color` | `var(--stuic-color-border)` | Row border color |
436
+ | `--stuic-data-table-row-link-color` | `inherit` | `rowHref` anchor color |
437
+ | `--stuic-data-table-row-link-decoration` | `none` | `rowHref` anchor decoration |
438
+ | `--stuic-data-table-row-link-decoration-hover` | `underline` | …on hover |
439
+ | `--stuic-data-table-row-ring-width` | `3px` | Activatable row focus ring |
440
+ | `--stuic-data-table-row-ring-color` | `var(--stuic-color-ring)` | Activatable row ring color |
441
+ | `--stuic-data-table-cell-padding-x` | `0.75rem` | Cell horizontal padding |
442
+ | `--stuic-data-table-cell-padding-y` | `0.75rem` | Cell vertical padding |
443
+ | `--stuic-data-table-cell-font-size` | `0.875rem` | Cell font size |
444
+ | `--stuic-data-table-loading-opacity` | `0.5` | Loading state opacity |
445
+ | `--stuic-data-table-card-bg` | `var(--stuic-color-background)` | Mobile card background |
446
+ | `--stuic-data-table-card-border-color` | `var(--stuic-color-border)` | Mobile card border |
447
+ | `--stuic-data-table-card-radius` | `var(--radius-md)` | Mobile card radius |
448
+ | `--stuic-data-table-card-padding` | `0.75rem` | Mobile card padding |
449
+ | `--stuic-data-table-card-gap` | `0.5rem` | Gap between mobile cards |
450
+ | `--stuic-data-table-batch-bg` | `var(--stuic-color-muted)` | Selection bar background |
451
+ | `--stuic-data-table-batch-bg-idle` | `transparent` | …while nothing is selected |
452
+ | `--stuic-data-table-batch-padding-x` | `0.75rem` | Selection bar h. padding |
453
+ | `--stuic-data-table-batch-padding-y` | `0.5rem` | Selection bar v. padding |
454
+ | `--stuic-data-table-batch-min-height` | `sm Button + padding` | Reserved selection bar height |
455
+ | `--stuic-data-table-select-all-bg` | `color-mix(primary 10%)` | …while the select-all offer is up |
@@ -0,0 +1,17 @@
1
+ import type { DataTableMessages } from "./i18n.js";
2
+ /**
3
+ * Slovak message catalog for `DataTable`. Opt-in — English stays the built-in default,
4
+ * and this module is only pulled into a bundle when it is actually imported (the
5
+ * component itself never references it).
6
+ *
7
+ * @example
8
+ * ```svelte
9
+ * <script>
10
+ * import { DataTable, createDataTableT, DATA_TABLE_MESSAGES_SK } from "@marianmeres/stuic";
11
+ * const t = createDataTableT(DATA_TABLE_MESSAGES_SK);
12
+ * </script>
13
+ *
14
+ * <DataTable {columns} {data} {t} />
15
+ * ```
16
+ */
17
+ export declare const DATA_TABLE_MESSAGES_SK: DataTableMessages;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Slovak message catalog for `DataTable`. Opt-in — English stays the built-in default,
3
+ * and this module is only pulled into a bundle when it is actually imported (the
4
+ * component itself never references it).
5
+ *
6
+ * @example
7
+ * ```svelte
8
+ * <script>
9
+ * import { DataTable, createDataTableT, DATA_TABLE_MESSAGES_SK } from "@marianmeres/stuic";
10
+ * const t = createDataTableT(DATA_TABLE_MESSAGES_SK);
11
+ * </script>
12
+ *
13
+ * <DataTable {columns} {data} {t} />
14
+ * ```
15
+ */
16
+ export const DATA_TABLE_MESSAGES_SK = {
17
+ previous_page: "Späť",
18
+ next_page: "Ďalej",
19
+ page_x_of_y: "Strana {page} z {pageCount}",
20
+ no_data: "Žiadne údaje",
21
+ select_all_rows: "Vybrať všetky riadky na tejto strane",
22
+ select_row: "Vybrať riadok",
23
+ select_all_on_page_x: "Vybrané všetky na tejto strane ({count}).",
24
+ select_all_results: "Vybrať všetky výsledky ({totalCount})",
25
+ all_results_selected: "Vybrané všetky výsledky ({totalCount}).",
26
+ clear_selection: "Zrušiť výber",
27
+ no_rows_selected: "Nič nie je vybrané",
28
+ x_rows_selected: "Vybrané: {count}",
29
+ };
@@ -0,0 +1,43 @@
1
+ import type { TranslateFn } from "../../types.js";
2
+ /**
3
+ * The built-in (English) message catalog of `DataTable`. Also the fallback of every
4
+ * other bundled locale, so a locale missing a key still renders text.
5
+ *
6
+ * Placeholders are single-brace (`{page}`, `{totalCount}`, ...).
7
+ */
8
+ export declare const DATA_TABLE_MESSAGES_EN: {
9
+ previous_page: string;
10
+ next_page: string;
11
+ page_x_of_y: string;
12
+ no_data: string;
13
+ select_all_rows: string;
14
+ select_row: string;
15
+ select_all_on_page_x: string;
16
+ select_all_results: string;
17
+ all_results_selected: string;
18
+ clear_selection: string;
19
+ no_rows_selected: string;
20
+ x_rows_selected: string;
21
+ };
22
+ /** Every message key `DataTable` may look up. */
23
+ export type DataTableMessageKey = keyof typeof DATA_TABLE_MESSAGES_EN;
24
+ /** A (possibly partial) catalog for one locale. */
25
+ export type DataTableMessages = Record<DataTableMessageKey, string>;
26
+ /**
27
+ * Builds the `t` prop of `DataTable` from a message catalog. Unknown or untranslated
28
+ * keys fall back to `fallbackMessages` (English by default), so a catalog may safely be
29
+ * partial and never renders a raw key.
30
+ *
31
+ * @example
32
+ * ```svelte
33
+ * <script>
34
+ * import { DataTable, createDataTableT, DATA_TABLE_MESSAGES_SK } from "@marianmeres/stuic";
35
+ * const t = createDataTableT(DATA_TABLE_MESSAGES_SK);
36
+ * </script>
37
+ *
38
+ * <DataTable {columns} {data} {t} />
39
+ * ```
40
+ */
41
+ export declare function createDataTableT(messages: Partial<DataTableMessages> | Record<string, string>, fallbackMessages?: Partial<DataTableMessages> | Record<string, string>): TranslateFn;
42
+ /** The component's built-in English `t`. */
43
+ export declare const t_default: TranslateFn;
@@ -0,0 +1,51 @@
1
+ import { isPlainObject } from "../../utils/is-plain-object.js";
2
+ import { replaceMap } from "../../utils/replace-map.js";
3
+ /**
4
+ * The built-in (English) message catalog of `DataTable`. Also the fallback of every
5
+ * other bundled locale, so a locale missing a key still renders text.
6
+ *
7
+ * Placeholders are single-brace (`{page}`, `{totalCount}`, ...).
8
+ */
9
+ export const DATA_TABLE_MESSAGES_EN = {
10
+ previous_page: "Prev",
11
+ next_page: "Next",
12
+ page_x_of_y: "Page {page} of {pageCount}",
13
+ no_data: "No data",
14
+ select_all_rows: "Select all rows on this page",
15
+ select_row: "Select row",
16
+ select_all_on_page_x: "All {count} on this page selected.",
17
+ select_all_results: "Select all {totalCount} results",
18
+ all_results_selected: "All {totalCount} results selected.",
19
+ clear_selection: "Clear selection",
20
+ no_rows_selected: "No rows selected",
21
+ x_rows_selected: "{count} selected",
22
+ };
23
+ /**
24
+ * Builds the `t` prop of `DataTable` from a message catalog. Unknown or untranslated
25
+ * keys fall back to `fallbackMessages` (English by default), so a catalog may safely be
26
+ * partial and never renders a raw key.
27
+ *
28
+ * @example
29
+ * ```svelte
30
+ * <script>
31
+ * import { DataTable, createDataTableT, DATA_TABLE_MESSAGES_SK } from "@marianmeres/stuic";
32
+ * const t = createDataTableT(DATA_TABLE_MESSAGES_SK);
33
+ * </script>
34
+ *
35
+ * <DataTable {columns} {data} {t} />
36
+ * ```
37
+ */
38
+ export function createDataTableT(messages, fallbackMessages = DATA_TABLE_MESSAGES_EN) {
39
+ return (k, values = null, fallback = "") => {
40
+ const out = messages[k] ??
41
+ fallbackMessages[k] ??
42
+ (typeof fallback === "string" ? fallback : k);
43
+ return isPlainObject(values)
44
+ ? replaceMap(out, values, {
45
+ preSearchKeyTransform: (k) => `{${k}}`,
46
+ })
47
+ : out;
48
+ };
49
+ }
50
+ /** The component's built-in English `t`. */
51
+ export const t_default = createDataTableT(DATA_TABLE_MESSAGES_EN);
@@ -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;
@@ -33,15 +42,19 @@
33
42
  --stuic-data-table-paging-gap: 0.5rem;
34
43
  --stuic-data-table-paging-padding-y: 0.75rem;
35
44
 
36
- /* Batch action bar */
45
+ /* Selection (batch action) bar */
37
46
  --stuic-data-table-batch-bg: var(--stuic-color-muted);
47
+ /* Nothing selected -- the bar is only holding its place, so it stays quiet. */
48
+ --stuic-data-table-batch-bg-idle: var(--stuic-color-muted);
38
49
  --stuic-data-table-batch-padding-x: 0.75rem;
39
- --stuic-data-table-batch-padding-y: 0.5rem;
50
+ --stuic-data-table-batch-padding-y: 0.25rem;
51
+ /* Sized to a `size="sm"` Button so the idle and active states are the same height. */
52
+ --stuic-data-table-batch-min-height: calc(var(--stuic-button-min-height-sm, 2.75rem) + var(--stuic-data-table-batch-padding-y) * 2);
53
+ /* */
54
+ --stuic-data-table-batch-margin: 0 0 0.125rem 0;
40
55
 
41
- /* Select-all-across-pages banner */
56
+ /* Select-all-across-pages offer (rendered inside the selection bar) */
42
57
  --stuic-data-table-select-all-bg: color-mix(in srgb, var(--stuic-color-primary) 10%, var(--stuic-color-background));
43
- --stuic-data-table-select-all-padding-x: 0.75rem;
44
- --stuic-data-table-select-all-padding-y: 0.5rem;
45
58
 
46
59
  /* Checkbox column */
47
60
  --stuic-data-table-checkbox-width: 3rem;
@@ -152,6 +165,31 @@
152
165
  );
153
166
  }
154
167
 
168
+ /*
169
+ `rowActivatable` puts tabindex on the <tr>, and a focusable element with no
170
+ visible ring is worse than no tabindex at all. The negative offset keeps the
171
+ ring inside the row box — an outset ring on a <tr> is clipped unpredictably
172
+ by border-collapse.
173
+ */
174
+ .stuic-data-table tbody tr:focus-visible {
175
+ outline: var(--stuic-data-table-row-ring-width) solid
176
+ var(--stuic-data-table-row-ring-color);
177
+ outline-offset: calc(var(--stuic-data-table-row-ring-width) * -1);
178
+ }
179
+
180
+ /* ============================================================================
181
+ ROW LINK (rowHref)
182
+ ============================================================================ */
183
+
184
+ .stuic-data-table-row-link {
185
+ color: var(--stuic-data-table-row-link-color);
186
+ text-decoration: var(--stuic-data-table-row-link-decoration);
187
+ }
188
+
189
+ .stuic-data-table-row-link:hover {
190
+ text-decoration: var(--stuic-data-table-row-link-decoration-hover);
191
+ }
192
+
155
193
  /* ============================================================================
156
194
  CELL
157
195
  ============================================================================ */
@@ -204,34 +242,52 @@
204
242
  BATCH ACTION BAR
205
243
  ============================================================================ */
206
244
 
245
+ /*
246
+ The min-height is the whole point of the reserved bar: an idle line of text is
247
+ shorter than a row of buttons, so without it the table would still shift -- just
248
+ by less. Keep it in sync with whatever button size the batch actions use.
249
+ */
207
250
  .stuic-data-table-batch {
208
251
  display: flex;
209
252
  align-items: center;
253
+ justify-content: space-between;
254
+ flex-wrap: wrap;
210
255
  gap: var(--stuic-data-table-paging-gap);
256
+ min-height: var(--stuic-data-table-batch-min-height);
211
257
  padding: var(--stuic-data-table-batch-padding-y)
212
258
  var(--stuic-data-table-batch-padding-x);
213
259
  background: var(--stuic-data-table-batch-bg);
214
260
  border-radius: var(--stuic-data-table-radius, var(--stuic-radius));
215
- margin-bottom: 0.5rem;
261
+ margin: var(--stuic-data-table-batch-margin, 0);
262
+ transition: background var(--stuic-data-table-transition, var(--stuic-transition));
216
263
  }
217
264
 
218
- /* ============================================================================
219
- SELECT-ALL-ACROSS-PAGES BANNER
220
- ============================================================================ */
265
+ .stuic-data-table-batch[data-idle="true"] {
266
+ background: var(--stuic-data-table-batch-bg-idle);
267
+ }
268
+
269
+ /* The select-all offer is the one state worth actively drawing attention to. */
270
+ .stuic-data-table-batch[data-select-all="true"] {
271
+ background: var(--stuic-data-table-select-all-bg);
272
+ }
221
273
 
222
- .stuic-data-table-select-all-banner {
274
+ .stuic-data-table-batch-main,
275
+ .stuic-data-table-batch-select-all {
223
276
  display: flex;
224
277
  align-items: center;
225
- justify-content: center;
278
+ flex-wrap: wrap;
226
279
  gap: var(--stuic-data-table-paging-gap);
227
- padding: var(--stuic-data-table-select-all-padding-y)
228
- var(--stuic-data-table-select-all-padding-x);
229
- background: var(--stuic-data-table-select-all-bg);
230
- border-radius: var(--stuic-data-table-radius, var(--stuic-radius));
231
- margin-bottom: 0.5rem;
280
+ }
281
+
282
+ .stuic-data-table-batch-select-all {
283
+ margin-left: auto;
284
+ font-size: var(--stuic-data-table-cell-font-size);
285
+ color: var(--stuic-data-table-header-color);
286
+ }
287
+
288
+ .stuic-data-table-batch-status {
232
289
  font-size: var(--stuic-data-table-cell-font-size);
233
290
  color: var(--stuic-data-table-header-color);
234
- text-align: center;
235
291
  }
236
292
 
237
293
  /* ============================================================================
@@ -1 +1,3 @@
1
1
  export { default as DataTable, type Props as DataTableProps, type DataTableColumn, } from "./DataTable.svelte";
2
+ export { createDataTableT, DATA_TABLE_MESSAGES_EN, type DataTableMessageKey, type DataTableMessages, } from "./i18n.js";
3
+ export { DATA_TABLE_MESSAGES_SK } from "./i18n-sk.js";
@@ -1 +1,3 @@
1
1
  export { default as DataTable, } from "./DataTable.svelte";
2
+ export { createDataTableT, DATA_TABLE_MESSAGES_EN, } from "./i18n.js";
3
+ export { DATA_TABLE_MESSAGES_SK } from "./i18n-sk.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.161.0",
3
+ "version": "3.163.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",